React Context Performance

Optimize context usage to avoid unnecessary re-renders with splitting and memoization.

TL;DR

  1. 01Split contexts into separate pieces to avoid cascading re-renders.
  2. 02Memoize context values to prevent unnecessary updates.
  3. 03Use useCallback for stable function references in context.

Tips

  1. 01Use useReducer instead of useState in context providers — dispatch is always stable, so no useCallback needed.

Warnings

  1. 01Over-optimization with memoization adds complexity — only optimize contexts that are accessed by many components and update frequently.

Context Re-render Problem

  • Understand that every context consumer re-renders when any value in the context changes.

    const AppContext = createContext<{ user: User | null; theme: string } | undefined>(undefined);
    
    function AppProvider({ children }: { children: React.ReactNode }) {
      const [user, setUser] = useState<User | null>(null);
      const [theme, setTheme] = useState("light");
    
      // All consumers re-render if EITHER user or theme changes
      return (
        <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
          {children}
        </AppContext.Provider>
      );
    }
    
  • Recognize that inline object literals create a new reference on every render, triggering all consumers.

    // Bad: new object on every parent render — all consumers re-render unnecessarily
    <MyContext.Provider value={{ user, setUser }}>
      {children}
    </MyContext.Provider>
    
    // Good: stable reference when user hasn't changed
    const value = useMemo(() => ({ user, setUser }), [user]);
    <MyContext.Provider value={value}>{children}</MyContext.Provider>
    
  • Profile with React DevTools before optimizing to confirm which components actually re-render.

    // Open React DevTools > Profiler tab
    // Record an interaction, then inspect the flame chart
    // Only optimize components that show unnecessary renders
    
  • Spot the problem by logging renders inside a context consumer.

    function ThemeButton() {
      const { theme } = useContext(AppContext)!;
      console.log("ThemeButton rendered"); // fires on every user change too
      return <button className={theme}>Click me</button>;
    }
    
  • Understand that splitting contexts into smaller pieces is the primary fix for this problem.

    // Instead of one big AppContext, use focused per-domain contexts:
    // UserContext → only user data
    // ThemeContext → only theme data
    // Each consumer subscribes only to what it needs
    

Splitting Contexts

  • Create separate contexts for independent pieces of state so updates are isolated.

    const UserContext = createContext<{ user: User | null; setUser: (u: User | null) => void } | undefined>(undefined);
    const ThemeContext = createContext<{ theme: string; setTheme: (t: string) => void } | undefined>(undefined);
    
    function AppProvider({ children }: { children: React.ReactNode }) {
      const [user, setUser] = useState<User | null>(null);
      const [theme, setTheme] = useState("light");
      return (
        <UserContext.Provider value={{ user, setUser }}>
          <ThemeContext.Provider value={{ theme, setTheme }}>
            {children}
          </ThemeContext.Provider>
        </UserContext.Provider>
      );
    }
    
  • Confirm that user-only consumers no longer re-render on theme changes after the split.

    function UserBadge() {
      const { user } = useContext(UserContext)!;
      // Only re-renders when user changes — theme updates are ignored
      return <span>{user?.name}</span>;
    }
    
  • Let each context update independently so unrelated parts of the tree stay stable.

    function ThemeToggle() {
      const { theme, setTheme } = useContext(ThemeContext)!;
      // Changing theme does NOT re-render UserBadge above
      return <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>{theme}</button>;
    }
    
  • Split read and write into separate contexts so read-only consumers never re-render from setter changes.

    const UserStateContext = createContext<User | null>(null);    // just the value
    const UserDispatchContext = createContext<(u: User | null) => void>(() => {}); // just the setter
    
    // Components that only display user data never re-render when setUser reference changes
    function UserDisplay() {
      const user = useContext(UserStateContext);
      return <p>{user?.name}</p>;
    }
    
  • Subscribe only to the contexts a component needs by consuming each context separately.

    function AdminPanel() {
      const { user } = useContext(UserContext)!;   // user updates trigger this
      // ThemeContext NOT consumed here — theme changes are irrelevant
      return user?.role === "admin" ? <div>Admin tools</div> : null;
    }
    

Memoizing Context Values

  • Wrap the context value object in useMemo so a new reference is only created when dependencies change.

    function UserProvider({ children }: { children: React.ReactNode }) {
      const [user, setUser] = useState<User | null>(null);
    
      const userValue = useMemo(
        () => ({ user, setUser }),
        [user] // only creates a new object when user changes
      );
    
      return (
        <UserContext.Provider value={userValue}>
          {children}
        </UserContext.Provider>
      );
    }
    
  • List every value used from the context object in the dependency array to avoid stale closures.

    const value = useMemo(
      () => ({ user, settings, updateUser }),
      [user, settings, updateUser] // include all three dependencies
    );
    
  • Combine useMemo with stable function references so functions don't invalidate the memo.

    const updateUser = useCallback((newUser: User) => {
      setUser(newUser);
    }, []); // stable reference — empty deps because setUser is always stable
    
    const value = useMemo(() => ({ user, updateUser }), [user, updateUser]);
    
  • Verify that memoization is working by checking that consumers don't re-render on unrelated parent renders.

    // Parent re-renders due to its own state, but context value stays the same
    // Consumers should NOT re-render — confirm in React DevTools Profiler
    function ParentWithOwnState() {
      const [tick, setTick] = useState(0); // this state is unrelated to context
      return <button onClick={() => setTick(t => t + 1)}>Tick: {tick}</button>;
    }
    
  • Avoid memoizing the initial value inline — always use useMemo inside the provider component.

    // Wrong: initial value object is only created once but is a constant, not reactive
    <MyContext.Provider value={{ user: null, setUser: () => {} }}>
    
    // Right: useMemo inside the provider component reacts to state changes
    const value = useMemo(() => ({ user, setUser }), [user]);
    <MyContext.Provider value={value}>
    

Memoized Consumers

  • Wrap context consumer components in React.memo so they skip re-renders when props are unchanged.

    const UserDisplay = React.memo(function UserDisplay() {
      const { user } = useContext(UserContext)!;
      // Skips re-renders when parent state changes but context value is stable
      return <div>{user?.name}</div>;
    });
    
  • Combine React.memo with a memoized context value for the best re-render reduction.

    // Provider memoizes the value object
    const value = useMemo(() => ({ user, setUser }), [user]);
    
    // Consumer is wrapped in React.memo
    const UserBadge = React.memo(() => {
      const { user } = useContext(UserContext)!;
      return <span>{user?.name}</span>;
    });
    // Only re-renders when the user object actually changes
    
  • Understand that React.memo still re-renders when the context value itself changes.

    // React.memo protects against prop changes, not context changes
    // If useContext returns a new reference, the component WILL re-render
    // Combine with useMemo on the provider value to control this
    
  • Pass a custom comparison function to React.memo for fine-grained prop-level control.

    const UserDisplay = React.memo(
      ({ label }: { label: string }) => {
        const { user } = useContext(UserContext)!;
        return <p>{label}: {user?.name}</p>;
      },
      (prevProps, nextProps) => prevProps.label === nextProps.label // only re-render if label changes
    );
    
  • Extract the context-consuming logic into a child component so React.memo can protect the expensive UI.

    // Expensive chart component is memoized; only re-renders when chartData changes
    const Chart = React.memo(({ chartData }: { chartData: number[] }) => {
      return <canvas>{/* expensive render */}</canvas>;
    });
    
    function ChartContainer() {
      const { chartData } = useContext(DashboardContext)!;
      return <Chart chartData={chartData} />; // passes stable reference when memoized
    }
    

Callback Optimization

  • Wrap context callbacks in useCallback so their references stay stable across parent renders.

    function UserProvider({ children }: { children: React.ReactNode }) {
      const [user, setUser] = useState<User | null>(null);
    
      const updateUser = useCallback((newUser: User) => {
        setUser(newUser); // setUser is stable, so deps array is empty
      }, []);
    
      const value = useMemo(() => ({ user, updateUser }), [user, updateUser]);
    
      return (
        <UserContext.Provider value={value}>{children}</UserContext.Provider>
      );
    }
    
  • Understand that functions created inline on every render invalidate the memoized context object.

    // Bad: inline function creates new reference every render
    const value = useMemo(() => ({
      user,
      updateUser: (u: User) => setUser(u), // new reference each render
    }), [user]); // updateUser changes every render, memo is useless
    
    // Good: useCallback gives updateUser a stable reference
    const updateUser = useCallback((u: User) => setUser(u), []);
    const value = useMemo(() => ({ user, updateUser }), [user, updateUser]);
    
  • Use useReducer dispatch instead of multiple callbacks — dispatch is always stable, no useCallback needed.

    const [state, dispatch] = useReducer(userReducer, initialState);
    
    // dispatch never changes reference — safe to include in memoized context value
    const value = useMemo(() => ({ state, dispatch }), [state]);
    
  • Compose multiple stable callbacks into a single memoized actions object for cleaner context values.

    const actions = useMemo(() => ({
      login: (credentials: Credentials) => dispatch({ type: "LOGIN", payload: credentials }),
      logout: () => dispatch({ type: "LOGOUT" }),
      updateProfile: (data: Partial<User>) => dispatch({ type: "UPDATE", payload: data }),
    }), [dispatch]); // dispatch is stable, so actions object is stable
    
  • Prefer useReducer over useState in context providers to eliminate most useCallback needs.

    // useState approach: need useCallback for every mutator
    const [user, setUser] = useState(null);
    const updateName = useCallback((name) => setUser(u => ({ ...u!, name })), []);
    
    // useReducer approach: dispatch is always stable — no useCallback needed
    const [state, dispatch] = useReducer(reducer, { user: null });
    

FAQ