State management in React has always been a hotly debated topic. With the ecosystem maturing rapidly, developers in 2026 are spoiled for choice — but that also means the wrong choice can haunt a codebase for years.
In this deep-dive, we'll compare three of the most prominent state management libraries: Redux Toolkit (RTK), Zustand, and Jotai. We'll evaluate them across bundle size, developer experience (DX), scalability, and real-world fit for small, medium, and large React applications.
Whether you're bootstrapping a side project or architecting an enterprise-scale SaaS platform, this guide will help you make an informed, confident decision.
Why State Management Still Matters in 2026
React's built-in state primitives — useState, useReducer, and the Context API — are powerful for local and simple shared state. But as your application grows, you inevitably run into:
- Prop drilling: Passing state through multiple layers of unrelated components.
- Performance bottlenecks: Context re-renders every consumer on every update.
- Scalability walls: Ad-hoc solutions that collapse under team or feature growth.
- Debugging chaos: No visibility into what changed, when, and why.
This is where dedicated state management libraries shine. Let's understand each contender before we pit them against each other.
Meet the Contenders
🔴 Redux Toolkit (RTK)
Redux has been the de facto standard for React state management since 2015. Redux Toolkit, released officially in 2020, eliminates the notorious boilerplate of vanilla Redux with opinionated defaults, built-in Immer for immutability, and RTK Query for data fetching.
Key features:
createSlice— combines reducers and actions into a single APIcreateAsyncThunk— handles async flows with ease- RTK Query — a powerful data fetching and caching layer
- DevTools integration out of the box
- Full TypeScript support
🐻 Zustand
Zustand ("state" in German) is a minimalist, unopinionated state management library by the creators of Jotai and React Spring. It uses a single store model with a simple API that feels almost too good to be true.
Key features:
- Tiny API surface — learn it in 10 minutes
- No providers required
- Selector-based subscriptions for performance
- Middleware support (persist, devtools, immer)
- Works outside React components
⚛️ Jotai
Jotai takes a fundamentally different approach — atomic state. Inspired by Recoil, it lets you define small units of state called "atoms" that components subscribe to individually. No global store, no selectors — just atoms.
Key features:
- Atomic, bottom-up state design
- No unnecessary re-renders — components only re-render when their atom changes
- First-class async support with Suspense
- Tiny bundle size
- Derived atoms with
atom(get => ...)syntax
Bundle Size Comparison
Bundle size matters — especially for performance-sensitive apps. Here's how they stack up in 2026:
| Library | Minified + Gzipped | Version (2026) |
|---|---|---|
| Redux Toolkit | ~14 KB | 2.x |
| Zustand | ~1.2 KB | 5.x |
| Jotai | ~3.5 KB | 2.x |
Note: RTK includes
immerandreduxas dependencies, which contributes to its larger size. Zustand and Jotai are impressively lean.
For applications where every byte counts — PWAs, mobile web, performance-budgeted apps — Zustand and Jotai have a clear edge.
Developer Experience (DX)
DX is arguably the most important metric for long-term productivity. Let's look at how each library feels to work with day-to-day.
Setting Up a Store
Redux Toolkit
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; },
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// App.tsx
import { Provider } from 'react-redux';
import { store } from './store';
export default function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}
Zustand
// store/useCounterStore.ts
import { create } from 'zustand';
interface CounterStore {
value: number;
increment: () => void;
decrement: () => void;
incrementByAmount: (amount: number) => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
value: 0,
increment: () => set((state) => ({ value: state.value + 1 })),
decrement: () => set((state) => ({ value: state.value - 1 })),
incrementByAmount: (amount) => set((state) => ({ value: state.value + amount })),
}));
// Counter.tsx — No Provider needed!
import { useCounterStore } from './store/useCounterStore';
export default function Counter() {
const { value, increment, decrement } = useCounterStore();
return (
<div>
<button onClick={decrement}>-</button>
<span>{value}</span>
<button onClick={increment}>+</button>
</div>
);
}
Jotai
// atoms/counterAtom.ts
import { atom } from 'jotai';
export const counterAtom = atom(0);
// Counter.tsx
import { useAtom } from 'jotai';
import { counterAtom } from './atoms/counterAtom';
export default function Counter() {
const [value, setValue] = useAtom(counterAtom);
return (
<div>
<button onClick={() => setValue((v) => v - 1)}>-</button>
<span>{value}</span>
<button onClick={() => setValue((v) => v + 1)}>+</button>
</div>
);
}
The DX difference is immediately apparent. Zustand and Jotai require dramatically less ceremony to get running. RTK, while significantly improved from vanilla Redux, still has a steeper initial setup curve.
Handling Async State
Modern apps are largely async — fetching data, handling loading states, managing errors. Let's compare.
Redux Toolkit with RTK Query
RTK Query is a first-class data fetching solution built into Redux Toolkit. It auto-generates hooks and handles caching, invalidation, and loading states.
// services/postsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com/' }),
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => 'posts',
}),
createPost: builder.mutation<Post, Partial<Post>>({
query: (body) => ({
url: 'posts',
method: 'POST',
body,
}),
}),
}),
});
export const { useGetPostsQuery, useCreatePostMutation } = postsApi;
// PostList.tsx
import { useGetPostsQuery } from './services/postsApi';
export default function PostList() {
const { data: posts, isLoading, isError } = useGetPostsQuery();
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error fetching posts.</p>;
return (
<ul>
{posts?.map((post) => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
Zustand with Async Actions
// store/usePostsStore.ts
import { create } from 'zustand';
interface PostsStore {
posts: Post[];
loading: boolean;
error: string | null;
fetchPosts: () => Promise<void>;
}
export const usePostsStore = create<PostsStore>((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
set({ posts, loading: false });
} catch (err) {
set({ error: 'Failed to fetch posts', loading: false });
}
},
}));
Jotai with Async Atoms
// atoms/postsAtom.ts
import { atom } from 'jotai';
export const postsAtom = atom(async () => {
const res = await fetch('https://api.example.com/posts');
return res.json() as Promise<Post[]>;
});
// PostList.tsx — Uses Suspense!
import { Suspense } from 'react';
import { useAtomValue } from 'jotai';
import { postsAtom } from './atoms/postsAtom';
function Posts() {
const posts = useAtomValue(postsAtom);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
export default function PostList() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Posts />
</Suspense>
);
}
Scalability: Small, Medium, and Large Apps
Small Apps (1–5 devs, under 10 routes)
Winner: Zustand or Jotai
For small apps — landing pages, personal projects, small SaaS MVPs — the minimal setup of Zustand or Jotai pays off immediately. You get reactive state without boilerplate overhead. RTK is overkill here; its conventions shine when you have teams and complex data flows, not when you're iterating fast solo.
Recommendation: Use Zustand if you want a traditional store model. Use Jotai if your state is naturally granular and component-centric.
Medium Apps (5–20 devs, 10–50 routes)
Winner: Zustand (with middleware) or RTK
As complexity grows, you'll need patterns — middleware for persistence, devtools for debugging, code organization conventions. Zustand handles this gracefully with its middleware ecosystem (zustand/middleware). RTK becomes attractive here too, especially if you have async-heavy features where RTK Query eliminates the need for a separate data-fetching library like TanStack Query.
Recommendation: Use Zustand for flexibility. Use RTK if you need a tight, opinionated convention for a growing team.
Large Apps (20+ devs, 50+ routes, enterprise scale)
Winner: Redux Toolkit
At enterprise scale, conventions beat ergonomics. RTK's prescriptive structure means every developer knows where slices live, how to define actions, and how to trace state changes. The Redux DevTools are unmatched for time-travel debugging and state auditing. RTK Query handles complex caching scenarios that would require significant custom logic in Zustand or Jotai.
Recommendation: Redux Toolkit. Its verbosity is a feature — it documents intent and enforces structure across large teams.
Real-World Use Case: Shopping Cart
Here's how each library handles a shopping cart — a universally relatable real-world scenario.
Zustand Shopping Cart
// store/useCartStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clearCart: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{ name: 'cart-storage' }
)
);
This example showcases Zustand's persist middleware — localStorage persistence in just a few lines. Clean, readable, production-ready.
Best Practices
Redux Toolkit
- Normalize state using
createEntityAdapterfor collections of entities. - Use RTK Query instead of rolling your own fetch/cache logic.
- Avoid storing derived state — compute it with selectors using
createSelectorfrom Reselect. - Split slices by feature, not by data type.
Zustand
- Use selectors to subscribe only to the state you need, preventing unnecessary re-renders:
const count = useCounterStore((state) => state.value); - Split stores by domain — don't put everything in one mega-store.
- Use the
devtoolsmiddleware in development:import { devtools } from 'zustand/middleware'; const useStore = create(devtools((set) => ({ ... })));
Jotai
- Keep atoms small and focused — one atom per concern.
- Use derived atoms instead of duplicating state:
const doubledAtom = atom((get) => get(counterAtom) * 2); - Use
atomWithStoragefromjotai/utilsfor persistence. - Leverage
atomWithQueryfromjotai-tanstack-queryfor server state.
Common Mistakes to Avoid
With Redux Toolkit
- ❌ Mutating state directly outside of
createSlicereducers (Immer only protects inside slice reducers). - ❌ Over-fetching by not leveraging RTK Query's cache invalidation tags.
- ❌ Putting UI state (modal open/closed) in Redux — use local
useStateinstead.
With Zustand
- ❌ Subscribing to the entire store without selectors — this re-renders on every update.
- ❌ Ignoring the
shallowequality check for object/array subscriptions:import { shallow } from 'zustand/shallow'; const { a, b } = useStore((state) => ({ a: state.a, b: state.b }), shallow); - ❌ Mixing server and client state in the same store without clear separation.
With Jotai
- ❌ Creating atoms inside components — atoms should be module-level constants.
- ❌ Over-atomizing — not every piece of state needs an atom. Group related state.
- ❌ Forgetting
<Provider>when you need isolated atom state per component tree.
🚀 Pro Tips
- Combine Jotai with TanStack Query for the best of both worlds: atomic UI state with powerful server state management. The
jotai-tanstack-queryadapter makes this seamless. - Use Zustand's
subscribeWithSelectormiddleware to react to specific state slices outside of React:useStore.subscribe( (state) => state.user, (user) => console.log('User changed:', user) ); - RTK Query supports optimistic updates — use
onQueryStartedto update the cache before the server responds for snappier UIs. - Zustand stores are just closures — you can read and write them anywhere, not just inside components. Perfect for integrating with non-React utilities, WebSockets, or event emitters.
- Jotai atoms can be scoped using
<Provider initialValues={[[myAtom, value]]}/>— incredibly useful for testing isolated component trees. - In 2026, prefer colocation — keep atoms and stores close to the features that use them, not in a global
/storefolder. Scale with your architecture.
📌 Key Takeaways
| Criteria | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|
| Bundle Size | ~14 KB | ~1.2 KB | ~3.5 KB |
| Learning Curve | Medium-High | Low | Low |
| DX / Ergonomics | Good (with RTK) | Excellent | Excellent |
| Data Fetching | RTK Query (built-in) | External (TanStack Query) | Jotai-TQ adapter |
| DevTools | Excellent | Good | Good |
| Best For | Large/Enterprise | Small-Medium | Component-centric |
| TypeScript Support | Excellent | Excellent | Excellent |
| Async Support | createAsyncThunk | Manual | Native (Suspense) |
| Scalability | ★★★★★ | ★★★★☆ | ★★★☆☆ |
The bottom line:
- Choose Redux Toolkit if you're building a large-scale app with a big team, need powerful devtools, or rely heavily on complex async data flows with RTK Query.
- Choose Zustand if you want a pragmatic, minimal store that grows with your app — ideal for most small-to-medium projects.
- Choose Jotai if your state is naturally granular, you love React's Suspense model, and you're building UI-heavy, component-centric applications.
Conclusion
There's no universally "best" state management library — the right choice depends on your team size, app complexity, and architectural preferences. In 2026, all three libraries are mature, well-maintained, and production-proven.
That said, a pragmatic heuristic works well:
Start with Zustand. Migrate to RTK if you outgrow it. Use Jotai if your state is atomic by nature.
The era of vanilla Redux boilerplate is over. All three of these libraries represent modern, thoughtful approaches to a genuinely hard problem. Pick one, understand it deeply, and resist the urge to over-engineer — your future teammates will thank you.