React Context Performance
Optimize context usage to avoid unnecessary re-renders with splitting and memoization.
TL;DR
- 01Split contexts into separate pieces to avoid cascading re-renders.
- 02Memoize context values to prevent unnecessary updates.
- 03Use useCallback for stable function references in context.
Tips
- 01Use useReducer instead of useState in context providers — dispatch is always stable, so no useCallback needed.
Warnings
- 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 rendersSpot 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
useMemoso 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
useMemowith 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
useMemoinside 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.memoso 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.memowith 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 changesUnderstand that
React.memostill 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 thisPass a custom comparison function to
React.memofor 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.memocan 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
useCallbackso 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
useReducerdispatch instead of multiple callbacks — dispatch is always stable, nouseCallbackneeded.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 stablePrefer
useReduceroveruseStatein context providers to eliminate mostuseCallbackneeds.// 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
Every component subscribed to a context re-renders whenever any value in that context changes, even if the component only uses an unrelated field. Split your context into separate providers per concern so updates to one domain don't trigger consumers of another.
Wrap the value object in useMemo with its dependencies: const value = useMemo(() => ({ user, setUser }), [user]). Without this, a new object reference is created on every parent render, causing all consumers to re-render even when the data hasn't changed.
Wrap a consumer in React.memo when it receives context values as props passed down from a parent rather than via useContext directly — this lets React bail out of re-rendering if the specific props it receives haven't changed. Components using useContext directly still re-render on context changes regardless of React.memo.
Functions defined inline in a component recreate on every render, making them new references that invalidate the memoized context object. Wrap each function with useCallback and declare its dependencies so the reference stays stable between renders.
There's no hard limit — providers are cheap and multiple providers composed in a tree is the recommended pattern. Combining unrelated state into one context trades boilerplate reduction for broader re-render scope, which is usually the wrong tradeoff for frequently-updating values.