Cons of using React context

Monday 08/08/2022

·6 min read
Share:

React Context is the recommended tool for sharing state without prop-drilling, but it has a sharp edge most tutorials don't mention: every component that consumes a context re-renders every time the context value changes — even if the component doesn't use the part that changed. On a tree of 50 components consuming a single "AppContext," changing one field re-renders all 50. Below: the failure modes, three patterns that fix them, and when to reach for a real state library instead.

The problem, in code

import React, { useContext, useState, createContext } from 'react'

const AppContext = createContext()

export default function App() {
    const [count, setCount] = useState(0)
    const [user, setUser] = useState({ name: 'Alice' })

    return (
        <AppContext.Provider value={{ count, setCount, user, setUser }}>
            <Counter />
            <UserBadge />
            <Clock />
        </AppContext.Provider>
    )
}

function Counter() {
    const { count, setCount } = useContext(AppContext)
    return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

function UserBadge() {
    const { user } = useContext(AppContext)
    console.log('UserBadge rendered')
    return <div>{user.name}</div>
}

function Clock() {
    const { count } = useContext(AppContext)
    // doesn't use `user` at all
    console.log('Clock rendered')
    return <div>{new Date().toString()}</div>
}

Click the counter. Both UserBadge and Clock re-render, even though neither cares about count. Clock doesn't even consume anything that changed — but because its useContext dependency did change identity (the parent object is new each render), React schedules a re-render.

On three components this is invisible. On a real app with deeply nested consumers and a context value that mutates frequently (animation frames, mouse position, scroll position), it's a noticeable performance regression.

Why this happens

When the provider's value changes by reference, React notifies every consumer. The consumer compares the new value to the old via Object.is, sees it's different, and re-renders. The consumer doesn't get to say "I only care about value.count" — it gets the whole object or nothing.

In the example above, the provider's value is a fresh object literal on every render: { count, setCount, user, setUser }. So every render of App notifies every consumer, even renders triggered by something unrelated.

Fix 1: split into multiple contexts

The cleanest fix when you have distinct concerns: one context per concern.

const CountContext = createContext()
const UserContext = createContext()

export default function App() {
    const [count, setCount] = useState(0)
    const [user, setUser] = useState({ name: 'Alice' })

    return (
        <CountContext.Provider value={{ count, setCount }}>
            <UserContext.Provider value={{ user, setUser }}>
                <Counter />
                <UserBadge />
                <Clock />
            </UserContext.Provider>
        </CountContext.Provider>
    )
}

Now Counter consumes CountContext, UserBadge consumes UserContext, Clock consumes neither (or just CountContext if it actually needs count). Clicking the counter only re-renders count-consuming components.

Split contexts is the right tool 80% of the time. The other 20% is when the state is genuinely coupled (a user object whose individual fields update independently) — for that, the selector pattern below.

Fix 2: stabilize the value with useMemo

If you can't split, at least stop creating a new value object every render:

function App() {
    const [count, setCount] = useState(0)
    const [user, setUser] = useState({ name: 'Alice' })

    const value = useMemo(
        () => ({ count, setCount, user, setUser }),
        [count, user]
    )

    return (
        <AppContext.Provider value={value}>
            <Counter />
            <UserBadge />
            <Clock />
        </AppContext.Provider>
    )
}

The value is only a new reference when count or user actually changes — not on every parent render. This doesn't solve the "all consumers re-render when ANY field changes" problem, but it stops gratuitous re-renders from parent updates that have nothing to do with the context.

setCount and setUser from useState are stable across renders by React's guarantee, so they don't need to be in the dependency array — but listing them is harmless and explicit.

Fix 3: the selector pattern

For genuine fine-grained subscriptions, use use-context-selector (a popular community library) or migrate to a state manager that supports selectors natively — Zustand, Jotai, Valtio:

import { useContextSelector } from 'use-context-selector'

function Counter() {
    const count = useContextSelector(AppContext, v => v.count)
    const setCount = useContextSelector(AppContext, v => v.setCount)
    return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

Counter only re-renders when count changes, not when user changes. This is what most "context performance" articles ultimately recommend, because the API is identical to the built-in useContext but the re-render semantics are what you actually want.

When to abandon Context entirely

Context is best for infrequently-changing global state — theme, locale, auth user. For state that changes on every keystroke, scroll event, or animation frame, reach for a real store:

| Use case | Tool | |---|---| | Theme, locale, auth user | Context | | Form state | React Hook Form or local state | | Server data | React Query, SWR | | Complex client state with selectors | Zustand, Jotai | | Multi-step form / wizard | URL state or local state | | Animation, scroll, mouse | Refs + DOM updates |

Context is not a state manager. It's a value-propagation primitive. Using it as a state manager is the source of nearly every "Context is slow" complaint.

FAQ

Does using Context always cause performance issues?

No — Context is fine for infrequently-changing values like theme, locale, or auth state. The performance problem only shows up when a frequently-mutating value has many deep consumers, or when the provider value isn't memoized and triggers re-renders on every parent render.

Will React.memo on consumer components fix Context re-renders?

No. React.memo compares props, but useContext reads from context outside the props pipeline. A memoized component still re-renders when its context changes. Use selectors or split contexts to actually fix this.

Is useMemo on the provider value always necessary?

Not always, but it's a cheap insurance policy. Without it, the provider value is a new object on every parent render, which re-runs every consumer. The cost of useMemo is far less than gratuitous re-renders down the tree.

Should I use Zustand or Context for app state?

Use Context for state that's truly global and rarely changes (theme, auth). Use Zustand (or similar) for application state that updates frequently or needs selector-based subscriptions — its API is simpler than Redux and avoids Context's re-render trap entirely.

Share:
VA

Vadim Alakhverdov

Software developer writing about JavaScript, web development, and developer tools.

Related Posts