Zustand vs Redux Toolkit vs Jotai: A 2026 Decision Guide
A practical React state management comparison of Zustand, Redux Toolkit, Jotai, and Context API — covering bundle size, re-render scope, and DevTools support.

State management is where React teams lose days to the wrong choice. Pick something too heavy and you're writing boilerplate for every feature. Pick something too lightweight and you hit a ceiling six months later when your app has grown and cross-cutting concerns don't fit the model anymore.
The React ecosystem in 2026 has four credible options for most apps: Zustand, Redux Toolkit, Jotai, and the built-in Context API. Each one has a different mental model, a different re-render boundary, and a different cost in bundle kilobytes. This guide cuts through the noise on a React state management comparison and gives a recommendation tied to team size and app complexity rather than a single universal winner.
We've used all four in production at Laxaar across projects ranging from solo-founder MVPs to multi-team SaaS platforms. Our view is that Zustand wins for most small-to-mid teams, Redux Toolkit wins when you need serious DevTools and cross-team conventions, and Jotai wins when fine-grained reactivity genuinely matters.
What you'll learn
- Why Context API has real limits
- Zustand: minimal API, broad applicability
- Redux Toolkit: structure at scale
- Jotai: atom-level reactivity
- Bundle size and re-render comparison
- Per-team-size recommendations
- Common migration patterns
- Frequently Asked Questions
Why Context API has real limits
React Context is not a state management library. It's a dependency injection mechanism that happens to trigger re-renders. The distinction matters: every component that calls useContext re-renders when any value in that context changes, not just the slice it cares about.
// Every consumer re-renders when *any* of these fields change
type AppContextValue = {
user: User;
cart: CartItem[];
theme: 'light' | 'dark';
notifications: Notification[];
};
const AppContext = createContext<AppContextValue>({ user: null, cart: [], theme: 'light', notifications: [] });
// A Header that only needs `user` still re-renders on cart updates
function Header() {
const { user } = useContext(AppContext);
return <nav>{user?.name}</nav>;
}
You can work around this with context splitting (one context per domain), but that quickly becomes 8 or 10 providers stacked in your component tree. You also can't subscribe to a derived value without either computing it in every consumer or adding a custom hook that memoises the selector. Context works fine for truly global, slowly-changing values like theme or locale. It's the wrong tool for cart state, form state, or anything that updates more than a few times per second.
The zero-bundle-cost argument for Context is also weaker than it sounds. The logic you add to compensate for its re-render behaviour (memo calls, split contexts, custom comparison hooks) adds its own weight and complexity.
Zustand: minimal API, broad applicability
Zustand is a small store library built on React's subscription model. A store is a function that returns state and actions; components subscribe to slices of it using a selector. Only components that subscribe to a changed slice re-render.
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartStore {
items: CartItem[];
total: number;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
total: 0,
addItem: (item) =>
set((state) => {
const items = [...state.items, item];
return { items, total: items.reduce((s, i) => s + i.price, 0) };
}),
removeItem: (id) =>
set((state) => {
const items = state.items.filter((i) => i.id !== id);
return { items, total: items.reduce((s, i) => s + i.price, 0) };
}),
clearCart: () => set({ items: [], total: 0 }),
}),
{ name: 'cart-storage' }
)
)
);
// Subscribing to a slice — only re-renders on `items` changes, not `total`
function CartBadge() {
const count = useCartStore((state) => state.items.length);
return <span>{count}</span>;
}
The selector pattern is Zustand's core value. You pass a function to useCartStore that picks only what you need, and Zustand does a strict equality check on the result. If it hasn't changed, the component doesn't re-render. No memo, no useMemo, no boilerplate.
Zustand's bundle is around 1.1 kB gzipped. The middleware system (devtools, persist, immer) is opt-in, so you pay only for what you use. DevTools support through the devtools middleware is solid: you get time-travel debugging and state diffs in the Redux DevTools browser extension.
The honest trade-off: Zustand has no enforced conventions. Two engineers on the same team can structure stores completely differently. For a team of three, that's fine. For a team of fifteen, it becomes a maintenance problem.
Redux Toolkit: structure at scale
Redux Toolkit (RTK) is the official, opinionated Redux wrapper that eliminates the ceremony of plain Redux. Slices replace action creators and reducers. RTK Query handles server state. Immer is included by default, so you write mutating logic that gets converted to immutable updates under the hood.
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit';
interface CartState {
items: CartItem[];
}
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] } as CartState,
reducers: {
addItem: (state, action: PayloadAction<CartItem>) => {
state.items.push(action.payload); // Immer makes this safe
},
removeItem: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((i) => i.id !== action.payload);
},
clearCart: (state) => {
state.items = [];
},
},
});
// Memoised derived selector — only recomputes when `items` changes
export const selectCartTotal = createSelector(
[(state: RootState) => state.cart.items],
(items) => items.reduce((sum, item) => sum + item.price, 0)
);
export const { addItem, removeItem, clearCart } = cartSlice.actions;
RTK's real advantage on larger engagements is the DevTools experience. Every dispatched action is logged with a label, a before-state, and an after-state. Replay action sequences, export state snapshots for bug reports, write tests that assert on dispatched actions. Replicating that in Zustand requires significant custom tooling. For teams that need to debug complex state sequences or hand a session replay to a colleague, it's a genuine difference.
RTK Query adds co-located server-state management: define an endpoint once and get hooks for loading, error, and cached data with automatic invalidation. If you're already using Redux, RTK Query beats reaching for a separate library.
The honest trade-off: RTK bundles to around 11 kB gzipped. It's still relatively small, but it's ten times Zustand's weight. More importantly, the conceptual surface area is large: slices, selectors, reducers, RTK Query endpoints, middleware, store setup. New engineers need real onboarding time. For a two-person startup, it's almost certainly overkill.
Jotai: atom-level reactivity
Jotai's model is different from both of the above. State lives in atoms (small, independent units), and components subscribe to individual atoms. Derived state comes from derived atoms that automatically track their dependencies.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
// Primitive atoms
const cartItemsAtom = atomWithStorage<CartItem[]>('cart-items', []);
// Derived atom — recomputes only when cartItemsAtom changes
const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.price, 0);
});
// Write atom with logic
const addItemAtom = atom(null, (get, set, item: CartItem) => {
const current = get(cartItemsAtom);
set(cartItemsAtom, [...current, item]);
});
// This component only re-renders when the total changes
function CartTotal() {
const total = useAtomValue(cartTotalAtom);
return <span>${total.toFixed(2)}</span>;
}
// This component only re-renders when items changes
function CartCount() {
const items = useAtomValue(cartItemsAtom);
return <span>{items.length} items</span>;
}
Jotai's reactivity is more granular than Zustand's. With Zustand, a selector function determines what a component subscribes to. With Jotai, the atom is the subscription unit. No selector needed. For UIs with many small, interdependent pieces of state (think spreadsheet-like interfaces, complex form graphs, or real-time dashboards), Jotai's model can produce fewer unnecessary re-renders with less code.
The bundle is around 3 kB gzipped. DevTools support is available via the jotai-devtools package, though it's less mature than the Redux DevTools ecosystem.
The honest trade-off: atomic state can get messy fast. When atoms depend on other atoms which depend on other atoms, the dependency graph becomes hard to visualise mentally. Debugging "why did this atom change" requires either DevTools or careful instrumentation. Jotai doesn't enforce a structure for grouping related atoms, so large apps need discipline or a shared convention.
Bundle size and re-render comparison
Numbers that actually matter for your bundle and performance budget:
| Library | Bundle (gzipped) | Re-render scope | DevTools | Server state | Learning curve |
|---|---|---|---|---|---|
| Context API | 0 kB (built-in) | All consumers of a context | None native | No | Low |
| Zustand | ~1.1 kB | Selector-based slices | Via devtools middleware | No (use SWR/TanStack) | Low |
| Jotai | ~3 kB | Individual atoms | Via jotai-devtools | No | Medium |
| Redux Toolkit | ~11 kB | Selector-based + RTK Query | Redux DevTools (excellent) | Yes (RTK Query) | High |
A few things worth noting here. Bundle size matters less than re-render scope for most apps. A 10 kB library that causes zero unnecessary renders often produces faster perceived performance than a 1 kB one that renders the whole tree. The DevTools column is not cosmetic: it determines how fast you can diagnose production bugs, which is a real operational cost.
RTK's "high" learning curve is relative to the others, not to the broader landscape. For engineers already familiar with Redux concepts, RTK is a strict improvement. The cost is for engineers encountering the Redux model for the first time.
Per-team-size recommendations
Our take, informed by what we see work and fail at Laxaar across different project types:
Solo developer or two-person team: Start with Zustand. The API fits in your head, setup is three lines, and if you later need more structure you can migrate a store at a time. Don't reach for Redux Toolkit because you think you might need it eventually.
3-to-8 person team, product in growth phase: Zustand still works, but agree on conventions early: store file structure, where actions live, how you handle async. If your app has significant server state, pair Zustand with TanStack Query rather than trying to stuff remote data into client stores.
8-plus person team or multi-team product: Redux Toolkit. The convention enforcement and DevTools experience become worth the overhead. Action logs you can share in Slack, a consistent pattern every engineer already knows, RTK Query for server state: those all matter when ten engineers are touching the same codebase.
Real-time UIs or fine-grained reactivity needs: Jotai. If you're building a Figma-like canvas, a live collaborative document, or a dashboard where dozens of cells update independently, Jotai's atom model is a better fit than writing dozens of Zustand selectors.
Already using Context for everything: Don't migrate everything at once. Identify the contexts that update frequently and cause obvious re-render cascades. Replace those first. Keep slow-changing contexts (theme, locale, auth) in Context.
Common migration patterns
Moving from Context to Zustand is the most common migration we help teams with. The pattern is mechanical:
// Before: Context with a useReducer
const CartContext = createContext<CartContextType>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
// After: Zustand store — drop the Provider entirely
export const useCartStore = create<CartStore>()((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
}));
The Provider disappears. Consumer components replace useContext(CartContext) with useCartStore(selector). The migration is file-by-file and doesn't require a big-bang rewrite.
For teams moving from plain Redux to RTK, the migration is even more mechanical. RTK is designed to accept existing Redux stores and you can migrate one slice at a time. Our web development team has run several of these migrations and the coexistence period (old Redux actions alongside RTK slices) typically lasts two to four sprints before the codebase is fully on RTK.
For deeper patterns on React's rendering behaviour and when to reach for memoisation, our post on React performance optimization covers the profiler-driven approach that makes these decisions evidence-based rather than intuitive.
Frequently Asked Questions
Can a team use Zustand and RTK in the same app?
Yes. They don't conflict. A common pattern is RTK for cross-team global state (user session, feature flags, critical UI state) and Zustand for local feature state that one team owns. We'd be cautious about this in new projects, since it adds cognitive overhead, but for migrating a large existing Redux app to Zustand incrementally it's a valid strategy.
Does Jotai work with React Server Components?
Partially. Atom state is client-side by definition; atoms can't live in Server Components. You can pass server-fetched data as initial values to atoms via atomWithStorage or through props, but Jotai's reactivity model is entirely client-side. If server state is your primary concern, TanStack Query or RTK Query are better answers regardless of your client-state choice.
How does Zustand handle async actions?
Zustand has no built-in async middleware. You write async functions directly in your store using set inside a regular async function. This is simpler than Redux's thunk/saga model but gives you less structure for error states and loading indicators. For anything beyond simple async mutations, pairing Zustand with TanStack Query (which owns the server state lifecycle) is the cleaner approach.
Is Redux Toolkit still worth learning in 2026?
Yes, for the right context. RTK is the dominant pattern in large enterprise React codebases, and knowing it makes you immediately productive on those projects. The DevTools ecosystem, the RTK Query pattern, and the createSelector memoisation API are all genuinely good tools. Don't learn it because it's popular. Learn it if you're working on, or building, a large multi-team app where conventions and debuggability are worth the cost.
What about Valtio, MobX, or XState?
Valtio uses a proxy-based model (similar to Vue's reactivity) that feels natural but can surprise engineers who aren't tracking what triggers a re-render. MobX is mature and capable but has mostly been displaced by RTK and Zustand in new projects. XState is excellent for complex finite-state machines: UI flows with many states and explicit transitions. It's a state machine library, not a general state manager. Use it for the specific problems it solves.
How does server state fit into this comparison?
This comparison covers client state: UI state, local app state, user interactions. Server state (data fetched from an API) has a different lifecycle and is better managed by TanStack Query or RTK Query. The mistake teams make is putting server responses into a global Zustand or Redux store and manually managing loading, error, and cache invalidation. Let a purpose-built server-state library own that. Your client state store stays lean and focused.
Picking the wrong state library rarely breaks a product early. It slows you down six months later when the patterns don't fit and the refactor is expensive. The Laxaar team helps React teams make this decision as part of architecture reviews before projects start, not after the first friction appears. If you're starting a new product or rethinking an existing architecture, talk to us — a short conversation now saves weeks later. You can also explore the custom software development services we offer for teams that want expert hands on the build from day one.
Working on something like this?
Get a fixed scope, timeline, and price within one business day — no obligation.


