React Context is the default state tool for most teams. It ships with React, no extra library, easy to set up. It's also the slowest option for anything that updates often.
I see this pattern in every React codebase I audit: a giant AppContext holding auth, theme, cart, search filters, and form state. Every keystroke in the search input re-renders the whole app. The fix isn't more React.memo. The fix is using the right tool for each piece of state.
The actual cost of Context
Context's contract: when the value changes, every consumer re-renders. Period.
const AppContext = createContext(null)
function AppProvider({ children }) {
const [searchQuery, setSearchQuery] = useState('')
const [theme, setTheme] = useState('dark')
const [user, setUser] = useState(null)
return (
<AppContext.Provider value={{ searchQuery, setSearchQuery, theme, setTheme, user, setUser }}>
{children}
</AppContext.Provider>
)
}When searchQuery changes:
- The Provider value is a new object → every consumer re-renders
- Even components that only read
themere-render - Even components that only read
userre-render
For low-frequency state (theme changes once a day), this is fine. For search input (changes on every keystroke), this janks your app.
Zustand: only re-render what changed
Zustand uses a selector pattern. Components subscribe to specific slices of state. When the slice they read changes, they re-render. When it doesn't, they don't.
import { create } from 'zustand'
const useStore = create((set) => ({
searchQuery: '',
theme: 'dark',
user: null,
setSearchQuery: (q) => set({ searchQuery: q }),
setTheme: (t) => set({ theme: t }),
setUser: (u) => set({ user: u }),
}))
// This component ONLY re-renders when searchQuery changes
function SearchInput() {
const searchQuery = useStore((s) => s.searchQuery)
const setSearchQuery = useStore((s) => s.setSearchQuery)
return <input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
}
// This component ONLY re-renders when theme changes
function ThemeToggle() {
const theme = useStore((s) => s.theme)
const setTheme = useStore((s) => s.setTheme)
return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>Toggle</button>
}Type a in the search input:
SearchInputre-renders (it readsearchQuery)ThemeToggledoesn't re-render (it didn't)Headerthat only readsuserdoesn't re-render
This is the magic. Surgical re-renders without React.memo, useMemo, or useCallback.
The decision framework
| State type | Update frequency | Use |
|---|---|---|
| Theme, locale | Once per session | Context |
| Auth, current user | Rare | Context |
| Feature flags | Never | Context |
| Cart total | On add/remove | Either (small apps: Context, large: Zustand) |
| Search filter | Every keystroke | Zustand |
| Form state | Every keystroke | Zustand (or local useState) |
| Real-time data | Constant | Zustand |
| Server cache | On mutation | React Query (not Zustand) |
The rule: high-frequency state goes to Zustand, low-frequency state goes to Context. If you're not sure, start with local useState and promote when you need to share.
Splitting Context when you can't migrate
If you're stuck with Context, split it. One Provider per concern.
// Bad: one giant context
<AppContext.Provider value={{ user, theme, searchQuery, cart }}>
{children}
</AppContext.Provider>
// Better: split by update frequency
<AuthProvider value={{ user }}>
<ThemeProvider value={{ theme }}>
<CartProvider value={{ cart }}>
{children}
</CartProvider>
</ThemeProvider>
</AuthProvider>Now changing cart doesn't re-render components that only read theme. Splitting is not as good as Zustand, but it's a 10x improvement over the giant context.
Migrating incrementally
You don't have to rewrite the app. Add Zustand for the high-frequency state and leave the rest on Context.
// Keep auth on Context (low-frequency)
const AuthContext = createContext(null)
// Move search to Zustand (high-frequency)
const useSearchStore = create((set) => ({
query: '',
filters: {},
setQuery: (q) => set({ query: q }),
}))Start with the state that's actually causing re-render problems. Profile with React DevTools, find the consumer that re-renders on every keystroke, move that state to Zustand. Done.
What about Redux, Jotai, Recoil?
Redux, overkill for most apps in 2026. Use it if you have a team that knows it, or if you need time-travel debugging. Otherwise, Zustand covers the same ground with less code.
Jotai, atomic state, great for fine-grained dependencies. Pick Jotai if your state is highly interdependent (computed state that derives from other computed state). Otherwise, Zustand is simpler.
React Query / TanStack Query, different problem. Use it for server state (data fetched from APIs). Don't put server state in Zustand, React Query handles caching, refetching, and invalidation better.
The setup I ship
Most apps I build end up with three layers:
<QueryClientProvider client={queryClient}> {/* Server state */}
<AuthProvider> {/* Auth (Context) */}
<ThemeProvider> {/* Theme (Context) */}
<App /> {/* UI state in Zustand */}
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>- React Query for server cache
- Context for low-frequency global state (auth, theme)
- Zustand for high-frequency UI state (filters, search, modals)
- useState for component-local state
That's it. No Redux. No giant Context. Each piece of state lives in the right layer.
Context is not a state management library. It's a dependency injection mechanism. Use it to inject values that rarely change. For everything else, use the right tool.
Want a React app with state done right?
I build React and Next.js apps where state is in the right place, no Context re-render bugs, no Redux ceremony, just clean and fast. Let's talk.