Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 012 / 100

Context API, the right way: patterns, pitfalls and fixes

Context is dependency injection for React — not a state manager.

Expona Admin7 min read

Context API, the right way: patterns, pitfalls and fixes

Most teams reach for Context API too early and wonder why their app slows down. Context is dependency injection for React — not a state manager — yet we often treat it as a replacement for Redux or Zustand. When you understand what Context does well and where it fails, you unlock a much cleaner architecture.

What Context actually is

Context avoids prop drilling by letting descendants read a value without threading it through every intermediate component. That's it. It's a transport mechanism, not a store. The moment you start storing frequently-changing state in Context and expecting good performance, you've misunderstood the tool.

Think of it like this: you wouldn't use your company's internal chat system to stream database replication events. You'd use a message queue. Context is internal chat. It's great for broadcasting config, rarely-changed metadata, and dependency resolution. It's terrible for fast-moving state like form input, list filters, or animation frames.

Good uses: theme, auth, locale, feature flags

Context shines when the data changes rarely and every consumer genuinely wants the same value. Theme is the canonical example: your entire app needs to know if it's dark or light mode, and that changes maybe once per session. Same with the authenticated user, locale, or feature flags.

Here's a realistic auth context:

tsx
interface AuthContextType {
  user: User | null;
  loading: boolean;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Verify session on mount
    verifySession().then((userData) => {
      setUser(userData);
      setLoading(false);
    });
  }, []);

  const logout = useCallback(() => {
    clearSession();
    setUser(null);
  }, []);

  const value = useMemo(
    () => ({ user, loading, logout }),
    [user, loading]
  );

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

This works because user changes only when the session updates—maybe once at startup, once on logout. Every component that calls useAuth() re-renders when the user changes, but that's rare and often necessary.

The same pattern holds for locale or feature flags. These are typically loaded once and rarely change during a session. Context is the right tool.

The re-render problem

Here's where teams get stuck. Suppose you put a form's input state in Context because it makes validation logic easier to share:

tsx
const [formData, setFormData] = useState({ name: '', email: '' });

return (
  <FormContext.Provider value={formData}>
    <NameInput />
    <EmailInput />
    <SomeOtherComponent />
  </FormContext.Provider>
);

Now every keystroke in the name input changes the context value, which re-renders EmailInput, SomeOtherComponent, and every other consumer. If those components aren't memoized (and most aren't), they re-render from scratch. With a complex form or a large subtree, this becomes noticeably slow.

React's diffing algorithm can't help you here. The context value changed, so all consumers re-render. Period. This is especially painful if SomeOtherComponent renders a list or complex calculations—it all runs on every keystroke.

The fix isn't to memoize everything (that's a band-aid). The fix is to not put frequently-changing state in Context in the first place. Use local component state or a lightweight store like Zustand.

Split contexts: state and dispatch

When you have genuinely shared state that changes occasionally, use the split-context pattern. Separate the read side from the write side.

tsx
interface NotificationContextType {
  notifications: Notification[];
}

interface NotificationDispatchContextType {
  addNotification: (msg: string) => void;
  removeNotification: (id: string) => void;
}

const NotificationContext = createContext<NotificationContextType | undefined>(
  undefined
);
const NotificationDispatchContext = createContext<NotificationDispatchContextType | undefined>(
  undefined
);

export function NotificationProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [notifications, setNotifications] = useState<Notification[]>([]);

  const addNotification = useCallback((msg: string) => {
    const id = Math.random().toString();
    setNotifications((prev) => [...prev, { id, msg }]);
    setTimeout(() => removeNotification(id), 3000);
  }, []);

  const removeNotification = useCallback((id: string) => {
    setNotifications((prev) => prev.filter((n) => n.id !== id));
  }, []);

  const notificationValue = useMemo(
    () => ({ notifications }),
    [notifications]
  );

  const dispatchValue = useMemo(
    () => ({ addNotification, removeNotification }),
    [addNotification, removeNotification]
  );

  return (
    <NotificationDispatchContext.Provider value={dispatchValue}>
      <NotificationContext.Provider value={notificationValue}>
        {children}
      </NotificationContext.Provider>
    </NotificationDispatchContext.Provider>
  );
}

export function useNotifications() {
  const context = useContext(NotificationContext);
  if (!context) {
    throw new Error('useNotifications must be used within NotificationProvider');
  }
  return context;
}

export function useNotificationDispatch() {
  const context = useContext(NotificationDispatchContext);
  if (!context) {
    throw new Error(
      'useNotificationDispatch must be used within NotificationProvider'
    );
  }
  return context;
}

Now a component that only calls addNotification doesn't re-render when the notifications list changes, because it's reading from a different context. The callbacks are stable (memoized), so the dispatch context value rarely changes. Components reading the notification list will update when new toasts appear, but that's expected.

This pattern works because you've aligned re-renders with actual data needs. Writers and readers are decoupled.

Memoize the value

This is non-negotiable. If you create a new object literal as your context value on every render, React has no choice but to treat it as a new value and re-render all consumers:

tsx
// ❌ Bad: new object every render
return (
  <ThemeContext.Provider value={{ isDark: theme === 'dark' }}>
    {children}
  </ThemeContext.Provider>
);

// ✅ Good: memoized
const value = useMemo(
  () => ({ isDark: theme === 'dark' }),
  [theme]
);
return (
  <ThemeContext.Provider value={value}>
    {children}
  </ThemeContext.Provider>
);

When theme doesn't change, the memoized value object is reused. React's context comparison sees the same reference and skips the re-render. Without memoization, even if theme hasn't changed, the new object reference triggers updates.

Include all dependencies in the dependency array. If your context value depends on multiple pieces of state, list them all:

tsx
const value = useMemo(
  () => ({
    user,
    permissions,
    organization,
    logout,
  }),
  [user, permissions, organization, logout]
);

Common mistakes

  • Putting fast-changing state in Context. Forms, animations, or real-time data belong in local state or a store. Context will re-render too aggressively.
  • Forgetting to memoize the value. A new object on every render defeats the whole point. Always wrap your context value in useMemo.
  • Not using the custom hook pattern. Exporting useContext(AuthContext) directly is fragile. Wrap it in a custom hook that validates the consumer is inside the provider and provides better error messages.
  • Mixing read and write in one context. This creates unnecessary re-renders. Split them so consumers only subscribe to what they need.
  • Using Context for caching. If you need to cache async data, use a proper cache or a dedicated hook like React Query. Context is for transport, not storage.

Key takeaways

  • Context is dependency injection for React, not a state manager. Use it for config, auth, theme, and locale—values that change rarely.
  • Every consumer re-renders when the context value changes. If your data is fast-changing, Context will hurt performance.
  • Split state and dispatch contexts so readers and writers update independently.
  • Always wrap your provider value in useMemo to prevent unnecessary re-renders when the value hasn't actually changed.
  • Export a custom hook that wraps useContext, validates the consumer is inside the provider, and provides clear error messages.
  • When in doubt, reach for local state or a lightweight store first. Context is powerful but shouldn't be your default.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles