A no-fluff engineering breakdown for frontend developers building production React apps.
The State Problem Hasn't Gone Away
If anything, it's got more complicated.
In 2026, a "typical" React app might span a Next.js App Router setup, stream RSC payloads from the edge, manage optimistic UI mutations via React Query or SWR, coordinate real-time updates over WebSockets, and now – increasingly – track AI assistant state across multi-turn interactions.
Client-side state management isn't dead. It's evolved. And the tooling decision you make still has real consequences for render performance, DX, bundle size, and long-term maintainability.
This article assumes you're already past "what is useState" and have shipped at least one production React application. We're going to get specific.
The Contenders in 2026
Before the deep dive: here's where the ecosystem actually stands.
- React Context — Built-in, no install, widely misused
- Redux Toolkit (RTK) — The modernized Redux; no longer the verbose monster it was
- Zustand — Lightweight, fast, dangerously easy to reach for
We're deliberately excluding Jotai, Recoil, MobX, and Valtio from the core comparison—not because they're irrelevant, but because these three represent the most common real-world decision tree. We'll reference the others where relevant.
React Context: What It Actually Is (and Isn't)
React Context is a dependency injection mechanism, not a state management library. This distinction matters enormously for how you architect with it.
jsx
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
Every consumer of this context re-renders when value it changes — including components that only care about setTheme it and never read theme it. React doesn't do granular subscription at the context level. You either subscribe to the whole context object or you don't.
When Context Works Well
- Truly static or near-static values: theme, locale, feature flags, auth user object
- Deeply nested prop drilling avoidance: passing a config object 5 levels deep
- Component library internals: compound components (Tabs/Tab, Accordion/Panel)
- Small, isolated slices: a single form's state scoped to a modal subtree
When Context Breaks Down
The moment you're putting frequently updated state into a context provider — cart quantities, filter state, UI toggle state, and live data — you've created a performance trap. React's context propagation doesn't bail out on unchanged values unless you memoise aggressively and correctly.
// This will cause every consumer to re-render on ANY state change
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
const [filters, setFilters] = useState({});
// DON'T do this — monolithic context is an anti-pattern
return (
<AppContext.Provider value={{ user, cart, filters, setCart, setFilters }}>
{children}
</AppContext.Provider>
);
}
The fix – splitting into separate contexts, memoising provider values, and using useContextSelector 'from' – quickly becomes more effort than reaching for a purpose-built tool.
Bottom line: Context is excellent glue. It's a poor substitute for a state store.
Redux Toolkit: Modernized, Still Powerful
Redux's reputation is stuck in 2018. The ecosystem moved on. If you haven't revisited RTK since the hand-rolled reducers era, you're working from outdated priors.
Redux Toolkit eliminates:
- Action type string constants
- Separate action creators
- Manual immutable update logic (Immer is built-in)
- The majority of boilerplate
A modern slice looks like this:
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], total: 0 },
reducers: {
addItem(state, action) {
// Direct mutation is fine — Immer handles this
state.items.push(action.payload);
state.total += action.payload.price;
},
removeItem(state, action) {
state.items = state.items.filter(item => item.id !== action.payload);
},
},
});
export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;
RTK's Actual Strengths in 2026
Predictability at scale. When your team has 10+ developers touching shared state, the unidirectional data flow and explicit action model become assets rather than constraints. Code reviews are easier. State bugs are traceable.
RTK Query. If you're not using RTK Query for server state, you're leaving real value on the table. It handles caching, invalidation, polling, and optimistic updates with a declarative API that rivals React Query for ergonomics and stays inside your existing Redux store.
const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: builder => ({
getProducts: builder.query({ query: () => '/products' }),
updateProduct: builder.mutation({
query: ({ id, ...patch }) => ({ url: `/products/${id}`, method: 'PATCH', body: patch }),
invalidatesTags: ['Product'],
}),
}),
});
DevTools. Redux DevTools with time-travel debugging is still unmatched for complex state debugging. For teams that live in the browser debugger, this is a genuine multiplier.
RTK's Honest Downsides
- Initial overhead. Store setup, provider wrapping, slice creation — there's a setup cost even with RTK's improvements
- Verbosity in small apps. For a 3-person startup building an MVP, RTK is almost certainly overkill
- Bundle size. Redux Toolkit adds ~13KB gzipped. Negligible for most apps, but not zero
- Paradigm gap. Developers new to Redux take time to internalize the action/reducer mental model, even with RTK's improvements
Zustand: The Quiet Default in 2026
Zustand has become the de facto standard for new mid-size React projects, and it earned that status honestly.
import { create } from 'zustand';
const useCartStore = create((set, get) => ({
items: [],
total: 0,
addItem: (item) => set(state => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (id) => set(state => ({
items: state.items.filter(i => i.id !== id),
total: state.total - (state.items.find(i => i.id === id)?.price ?? 0),
})),
getItemCount: () => get().items.length,
}));
That's a complete, working, performant store. No providers. No boilerplate ceremony. Zustand components subscribe granularly:
// Only re-renders when `items` changes — not on total change
const items = useCartStore(state => state.items);
// Only re-renders when `total` changes
const total = useCartStore(state => state.total);
This selector-based subscription model is the key performance win. Zustand's internal diffing means components opt into exactly the state slice they need.
Zustand's Strengths
- ~1KB gzipped. Essentially free in bundle terms
- No provider required. The store lives outside React's component tree — it works in hooks, event handlers, service modules, and even non-React code
- TypeScript ergonomics. First-class TS support without ceremony
- Middleware. Immer, devtools, persist, and subscribeWithSelector middleware are all available
- Simplicity scales surprisingly well. With disciplined slice separation, Zustand holds up in mid-size applications
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
const useStore = create(
devtools(
persist(
immer((set) => ({
user: null,
setUser: (user) => set(state => { state.user = user; }),
})),
{ name: 'app-storage' }
)
)
);
Zustand's Honest Downsides
- No opinionated structure. Zustand gives you rope. Teams without discipline create tangled stores
- No built-in server state. You'll still need React Query or similar for async data
- Debugging at scale. Redux DevTools integration exists but feels bolted on compared to first-class. Redux support
- Action traceability. There's no enforced action/event model — mutations are direct function calls, which can make complex state flows harder to audit
Technical Deep Dive: Performance and Re-Renders
This is where the rubber meets the road.
Context Re-render Behaviour
React Context uses reference equality on the 'value'. When the provider re-renders (e.g., parent state changes), a new object reference triggers all consumers to re-render — even if the derived values they care about haven't changed.
// Every render creates a new object — all consumers re-render
<ThemeContext.Provider value={{ theme, setTheme }}>
// Memoized — only re-renders consumers when theme or setTheme actually changes
const contextValue = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
<ThemeContext.Provider value={contextValue}>
Even with memoisation, you're constrained to whole-context subscriptions. The use-context-selector library patches this with a custom hook, but you're now adding a dependency to fix a built-in limitation.
Redux Selector Performance
RTK uses Reselect under the hood via 'Reselect'. Memoised selectors ensure components only re-render when their specific derived data changes:
const selectExpensiveItems = createSelector(
state => state.cart.items,
items => items.filter(i => i.price > 100) // Only recomputes when items changes
);
With useSelector Redux, components subscribe at the selector level. If unrelated parts of the store update, subscribed components won't re-render unless their selectors outputs change.
Zustand Selector Granularity
Zustand's re-render model is arguably the most intuitive: you pick a state, and you get granular subscriptions automatically.
// This component ONLY re-renders when the items array changes
function CartCount() {
const count = useCartStore(state => state.items.length);
return <span>{count}</span>;
}
No extra memoisation layer needed. This is Zustand's biggest practical win for performance-sensitive UIs.
Boilerplate Comparison
For a cart feature with add/remove/clear and a derived item count:
Context: ~40 lines (createContext, Provider, useState, useMemo, custom hook)
Redux Toolkit: ~35 lines (createSlice with reducers, store config, selectors) + initial store setup (~10 lines, amortized)
Zustand: ~20 lines (create with state + actions, selectors inline in components)
For day-to-day feature velocity, Zustand's lower ceremony is a real DX win—especially for small teams.
Real-World Scenarios
SaaS Dashboard (Analytics Platform, 50+ Components)
Profile: Complex filters, user preferences, real-time metric updates, role-based UI, deep-linked state in URLs.
Recommendation: Redux Toolkit
The structured action model pays dividends here. When five developers are touching the same shared state and you need to audit why a chart is rendering stale data, Redux DevTools time travel is invaluable. RTK Query handles the server-state layer (metrics and user data). RTK slices handle client state (active filters, selected date ranges, and UI preferences). The verbosity is justified by the complexity.
E-Commerce Frontend (Next.js App Router)
Profile: Cart state, auth session, product filters, wishlist, checkout multi-step flow.
Recommendation: Zustand (with React Query or SWR for server state)
This is Zustand's sweet spot. Cart state, UI filters, and multi-step checkout flow all map cleanly to Zustand stores. With RSC handling product catalogue rendering at the edge, you're not fighting the server/client boundary. Zustand's provider-free model integrates cleanly with Next.js App Router without the hydration ceremony that a Redux provider requires. Use zustand/middleware 'persist' for cart persistence across sessions.
Real-Time Chat App (WebSocket-Driven)
Profile: Conversation list, active chat thread, unread counts, typing indicators, online presence.
Recommendation: Zustand with subscribeWithSelector middleware
Real-time apps punish context-heavy architectures hard. Presence updates, typing indicators, and message arrivals need surgical re-renders. Zustand's selector model handles this gracefully. For the conversation list and active thread, you might split into separate stores to avoid cross-contamination of update frequency.
const useChatStore = create(
subscribeWithSelector((set) => ({
activeThreadId: null,
messages: {},
typingUsers: {},
setActiveThread: (id) => set({ activeThreadId: id }),
appendMessage: (threadId, message) => set(state => ({
messages: {
...state.messages,
[threadId]: [...(state.messages[threadId] ?? []), message],
}
})),
}))
);
// Subscribe outside React for WebSocket integration
useChatStore.subscribe(
state => state.activeThreadId,
(threadId) => {
if (threadId) ws.send(JSON.stringify({ type: 'join', threadId }));
}
);
Comparison Table
| Dimension | React Context | Redux Toolkit | Zustand |
|---|---|---|---|
| Performance | Poor for dynamic state | Good with selectors | Excellent with selectors |
| Scalability | Low–Medium | High | Medium–High |
| Boilerplate | Low (but grows) | Medium (much less than legacy Redux) | Very Low |
| Learning Curve | Low | Medium | Very Low |
| Bundle Size | 0KB (built-in) | ~13KB gzipped | ~1KB gzipped |
| DevTools | Limited | Excellent (time-travel) | Good (via middleware) |
| Server State | DIY | RTK Query (excellent) | External (React Query) |
| TypeScript | Manual typing | Good (built-in) | Excellent |
| Ecosystem | N/A (built-in) | Large, mature | Growing, active |
| RSC Compatibility | Careful | Careful (provider needed) | Easiest (no provider) |
Decision Framework
Apply these rules in order:
Use Context if:
- The value changes infrequently (theme, locale, auth user, feature flags)
- You're passing data deeply to avoid prop drilling — and it's not performance-sensitive
- You're building a component library with compound component patterns
- The alternative would be a Zustand store with a single boolean
Use Redux Toolkit if:
- Your team has 5+ developers regularly modifying shared state
- You need robust DevTools for debugging complex state transitions
- You're already using RTK Query and want unified state management
- Your app has complex interdependent state (e.g., multi-step workflows with rollback)
- You're in a heavily regulated domain (fintech, health tech) where auditability matters
Use Zustand if:
- You're building a new app and haven't committed to Redux
- You need performance without ceremony
- You're in a Next.js App Router project and want minimal provider overhead
- Your team's Redux experience is limited and you can't absorb the ramp-up cost
- The state is genuinely UI-local but shared across sibling subtrees
Avoid mixing all three in a single app unless each is serving a distinct purpose (e.g., Context for theme/auth, Zustand for UI state, and React Query for server state). That combination is actually reasonable. A free-for-all isn't.
The Future of State Management: Trends in 2026
Server State Is Now a First-Class Concern
TanStack Query v5, SWR 3, and RTK Query have effectively solved the server state problem. In 2026, mixing client state management with server data fetching is an anti-pattern. If you're storing API responses in Zustand or Redux without a caching layer, you're doing extra work for a worse result.
React Server Components Shifted the Balance
RSC moved a non-trivial chunk of what used to be client state into the server rendering layer. Pagination state, filter state for static data, and user session—these increasingly live in URL parameters, cookies, or server components. The effective "surface area" of the client state has shrunk. This is why Zustand's smaller footprint feels increasingly appropriate for many apps that would have defaulted to Redux three years ago.
Is Redux Still Relevant?
Yes — but the use case has narrowed and sharpened. Redux is no longer the default answer for "I need state management". It's the answer for "I need predictable, auditable, structured state management across a large codebase with multiple contributors."
Redux's survival into 2026 is a testament to RTK's successful modernisation. But it's now a specialised tool rather than a general one.
The AI UI Dimension
Multi-turn AI interfaces introduce a new class of state management problem: managing conversation history, streaming token buffers, tool call state, and speculative UI updates simultaneously. This is genuinely novel territory. Most teams are solving it with Zustand + custom middleware or with purpose-built libraries (the Vercel AI SDK's useChat manages this well for simpler flows). Expect this space to mature rapidly.
Signals and Reactivity Primitives
Preact Signals, Solid.js, and Vue's reactivity model have all demonstrated that fine-grained reactivity can coexist with component-based UIs. React hasn't adopted signals natively (the React team has been explicit about this), but the influence is visible in how Zustand and Jotai design their subscription models. Watch this space — the long-term direction of React's reactivity model remains in flux.
Conclusion
There's no universally correct answer here – and anyone telling you there is isn't thinking about your constraints.
Context is genuinely good at what it was designed for. Use it for static-ish values and dependency injection. Stop using it as a state store.
Redux Toolkit is not overkill for large teams — it's appropriately scaled. The boilerplate critique is increasingly stale. If you need structure, audit trails, and DevTools at scale, RTK remains the best-engineered option in the space.
Zustand has earned its place as the pragmatic default for mid-size applications. Its performance model, TypeScript support, and minimal footprint make it a low-risk, high-value choice for teams that want to move fast without accumulating architectural debt.
The most common mistake in 2026 isn't choosing the wrong tool — it's overengineering client state when the problem has already been solved at the server layer. Before reaching for any of these, ask, 'Does this state actually need to live on the client?'
Often, the right answer is "less than you think."
Have a different take on state management tradeoffs in your stack? Drop it in the comments — especially if you're working with RSC-heavy architectures or AI-native UIs.
Top comments (0)