Redux Toolkit vs Zustand in 2026: When to Use Which
Editor's note: We keep this alternate 2026 comparison URL for readers arriving from older links. Our primary 2026 guide is Zustand vs Redux Toolkit in 2026: Full Decision Guide. This page is set to noindex so search visibility consolidates on the canonical version.
TL;DR
Zustand for most apps; Redux Toolkit for large teams and complex state. Zustand (~10M weekly downloads) has much less boilerplate and is significantly easier to learn — a store is defined in five lines and consumed immediately. Redux Toolkit (~6M downloads) offers a full ecosystem: Redux DevTools with time-travel debugging, RTK Query for server state, normalized entity adapters, and enforced patterns that scale to large teams. For a solo developer or small team building a standard SaaS app, Zustand is almost always the better choice. For a large engineering team with complex interdependent state, Redux Toolkit's structure pays dividends.
Key Takeaways
- Zustand: ~10M weekly downloads — Redux Toolkit: ~6M (npm, March 2026)
- Zustand requires ~5 lines per store — Redux Toolkit requires slices, reducers, actions, and a root store
- Redux DevTools is unmatched — time-travel debugging, state replay, action history
- RTK Query gives Redux a built-in server state layer that competes with React Query
- Zustand is lighter — ~1KB gzipped vs Redux Toolkit's ~12KB
- Redux Toolkit download growth has plateaued — Zustand is still growing
Boilerplate Comparison: Same Counter Feature
The clearest way to see the difference is side-by-side implementations of the same feature:
// Zustand — minimal store definition
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Usage — no Provider required
function Counter() {
const { count, increment, decrement } = useCounterStore();
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
// Redux Toolkit — slice + store setup
import { createSlice, configureStore, type PayloadAction } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';
// 1. Define the slice
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
increment: (state) => { state.count += 1; },
decrement: (state) => { state.count -= 1; },
reset: (state) => { state.count = 0; },
incrementByAmount: (state, action: PayloadAction<number>) => {
state.count += action.payload;
},
},
});
export const { increment, decrement, reset } = counterSlice.actions;
// 2. Configure the store
const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
// 3. Type helpers
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
// 4. Wrap app in Provider
function App() {
return <Provider store={store}><Counter /></Provider>;
}
// 5. Usage in components
function Counter() {
const count = useSelector((state: RootState) => state.counter.count);
const dispatch = useDispatch<AppDispatch>();
return (
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}
Redux Toolkit is significantly less verbose than legacy Redux (no switch statements, no manual action creators), but still requires considerably more setup than Zustand for the same feature.
Async Operations
Real applications need to fetch data and handle loading/error states. Both libraries handle this, but with different philosophies:
// Zustand — async directly in store actions
import { create } from 'zustand';
interface PostsState {
posts: Post[];
loading: boolean;
error: string | null;
fetchPosts: () => Promise<void>;
addPost: (post: Omit<Post, 'id'>) => Promise<void>;
}
const usePostsStore = create<PostsState>((set, get) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null });
try {
const posts = await fetch('/api/posts').then(r => r.json());
set({ posts, loading: false });
} catch (e) {
set({ loading: false, error: 'Failed to fetch posts' });
}
},
addPost: async (post) => {
const newPost = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
}).then(r => r.json());
set((state) => ({ posts: [...state.posts, newPost] }));
},
}));
// Redux Toolkit — createAsyncThunk for async operations
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
// Async thunk handles the three states automatically
export const fetchPosts = createAsyncThunk(
'posts/fetchAll',
async (_, { rejectWithValue }) => {
try {
const res = await fetch('/api/posts');
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
} catch (e) {
return rejectWithValue((e as Error).message);
}
}
);
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [] as Post[],
loading: false,
error: null as string | null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
Both approaches work. Zustand is simpler and more direct. Redux's createAsyncThunk is more verbose but enforces consistency: every async operation always has pending, fulfilled, and rejected states handled explicitly.
Redux DevTools: Time-Travel Debugging
Redux DevTools is Redux Toolkit's single biggest advantage, and nothing in the Zustand ecosystem matches it:
Redux DevTools features:
Full action history with timestamps and type labels
Jump to any previous state (time-travel)
Replay action sequences from a bug report
Import/export state snapshots — share exact app state with a colleague
Diff view between consecutive states
Filter actions by type or name
Trace action to source code location
Test in isolation: dispatch actions manually from DevTools
Zustand DevTools (via zustand/middleware devtools):
Basic state inspection in Redux DevTools extension
Action log (if actions are named in the middleware config)
No time-travel
No state snapshots
No replay
For large apps where state bugs are hard to reproduce — e-commerce checkout flows, multi-step form wizards, real-time collaborative features — Redux DevTools alone can justify the extra boilerplate. Being able to jump to the exact state where a bug occurred, replay the action sequence, and export the state snapshot to share with a colleague is a significant debugging superpower.
// Zustand with named devtools middleware — better than nothing
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useCartStore = create<CartState>()(
devtools(
(set) => ({
items: [],
addItem: (item) => set(
(state) => ({ items: [...state.items, item] }),
false, // don't replace state
'cart/addItem', // action name shown in DevTools
),
}),
{ name: 'CartStore' }
)
);
Zustand Middleware Ecosystem
Zustand's middleware model is composable and covers most common needs:
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
const useSettingsStore = create<SettingsState>()(
devtools(
persist(
immer(
subscribeWithSelector((set) => ({
theme: 'light' as 'light' | 'dark',
language: 'en',
setTheme: (theme) => set((state) => { state.theme = theme; }),
}))
),
{
name: 'user-settings', // localStorage key
partialize: (state) => ({ theme: state.theme, language: state.language }),
}
),
{ name: 'SettingsStore' }
)
);
// React to specific changes without triggering full re-renders
useSettingsStore.subscribe(
(state) => state.theme,
(theme) => document.documentElement.setAttribute('data-theme', theme)
);
The available middleware covers: devtools (Redux DevTools integration), persist (localStorage/sessionStorage/custom storage), immer (Immer-based immutable updates), subscribeWithSelector (subscribe to slices of state), and combine (merging multiple store slices).
RTK Query: Redux's Data-Fetching Layer
If you need server state management (caching, polling, optimistic updates, cache invalidation), Redux Toolkit includes RTK Query — a full-featured data-fetching solution that competes directly with React Query:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post'],
}),
createPost: builder.mutation<Post, Omit<Post, 'id'>>({
query: (body) => ({ url: '/posts', method: 'POST', body }),
invalidatesTags: ['Post'], // Automatically refetches getPosts after create
}),
}),
});
export const { useGetPostsQuery, useCreatePostMutation } = postsApi;
// Usage in components — no manual loading/error state management
function PostList() {
const { data: posts, isLoading, error } = useGetPostsQuery();
const [createPost] = useCreatePostMutation();
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage />;
return <ul>{posts?.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
This is a compelling reason to choose Redux Toolkit if you are not already using React Query or TanStack Query. RTK Query is tightly integrated with Redux DevTools, so every API call is visible in the action log.
Normalized State with Entity Adapters
For apps with large relational data collections (user lists, product catalogs, message threads), Redux Toolkit's entity adapter provides O(1) lookup without manual normalization:
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
const usersAdapter = createEntityAdapter<User>({
selectId: (user) => user.id,
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState(),
reducers: {
addUser: usersAdapter.addOne,
updateUser: usersAdapter.updateOne,
removeUser: usersAdapter.removeOne,
upsertUsers: usersAdapter.upsertMany,
},
});
// Auto-generated selectors
const { selectAll, selectById, selectTotal } = usersAdapter.getSelectors(
(state: RootState) => state.users
);
// O(1) lookup
const user = selectById(state, userId);
// Sorted list (respects sortComparer)
const allUsers = selectAll(state);
Zustand can achieve similar results with Maps or manual normalization, but there is no built-in equivalent to createEntityAdapter.
Package Health
| Package | Weekly Downloads | Size (gzip) | Latest Version | Active |
|---|---|---|---|---|
| @reduxjs/toolkit | ~6M | ~12KB | 2.x | Yes |
| zustand | ~10M | ~1KB | 5.x | Yes |
| react-redux | ~9M | ~4KB | 9.x | Yes |
| redux | ~13M | ~2KB | 5.x | Yes (via RTK) |
Both packages are actively maintained. Zustand is developed by the same team behind Jotai, Valtio, and Jotai (Pmndrs collective). Redux Toolkit is maintained by the Redux team at Redux.js.org, with Mark Erikson as the primary maintainer.
When to Choose
Choose Zustand when:
- Solo developer or small team (1-5 people)
- Standard SaaS app state (auth, UI state, cart, preferences)
- You want to ship quickly without boilerplate overhead
- Your team does not need time-travel debugging in day-to-day work
- Migrating away from Redux — Zustand is the lowest-friction migration target
- Performance is critical — Zustand's minimal footprint reduces bundle size
Choose Redux Toolkit when:
- Large team (5+) needs enforced conventions and shared patterns
- State shape is complex with many interdependent slices requiring predictable updates
- Time-travel debugging and action replay are genuinely needed for bug investigation
- You have existing Redux code — RTK is the modern upgrade path (not a full rewrite)
- App has large normalized collections requiring entity adapters
- You want RTK Query instead of adding a separate server state library
- Team includes junior developers who benefit from the enforced one-way data flow pattern
Related Resources
State Management in the React Server Components Era
React Server Components have materially changed the state management question. In a Next.js App Router application, a significant portion of application state — data fetched from a database, user session information, feature flags — lives in server components and never reaches the client bundle at all. This reduces the surface area that client-side state managers like RTK and Zustand need to cover.
The practical consequence: applications migrating to Next.js App Router often find they can replace a complex Redux store with a much simpler Zustand store or even React's built-in useState and useContext, because the server components handle the data fetching and caching that previously required Redux's async thunks or RTK Query. RTK Query becomes less necessary when React Server Components plus SWR or React Query handles server synchronization at the component level.
This does not make Redux obsolete. It changes which applications benefit from it. Client-heavy applications — real-time collaborative tools, complex form wizards with multi-step state, applications with significant offline functionality or optimistic UI — still benefit from Redux's disciplined state machine model. But the class of "Next.js app that fetches some data and displays it" that previously reached for Redux can now use simpler tools with less ceremony.
Migration Paths
Migrating from Redux Toolkit to Zustand is straightforward for simple stores but more complex for stores that rely on RTK Query or entity adapters. The migration path: identify which slices are pure client state (UI state, form state) and which are server state (fetched data with caching). Migrate the server state slices to React Query or SWR, which solve the same caching and synchronization problem with better ergonomics. Then migrate the remaining client state slices to Zustand stores, which are 1-to-1 analogous to RTK slices without the boilerplate. The total bundle size reduction from this migration is typically 30-50KB gzipped for applications that used RTK Query heavily.
Teams going the other direction — from Zustand to Redux Toolkit — typically make this deliberate decision when the application has grown meaningfully beyond what Zustand's simple and flexible API can comfortably handle at scale: multiple developers working on overlapping state, debugging sessions that benefit from Redux DevTools' action log and time-travel, or the introduction of complex async workflows that Redux Saga or Thunk handles more predictably than Zustand's set-based mutations.
RTK Query's automatic cache invalidation via tags is one of Redux Toolkit's most underrated features for teams managing server state. When a mutation is dispatched, RTK Query automatically re-fetches any query tagged with matching invalidation tags — eliminating the "stale data after mutation" bugs that require manual cache-busting in Zustand or React Query setups. This makes RTK Query competitive with TanStack Query for server state management, particularly in applications where the Redux DevTools action timeline is already providing debugging value.
- Compare Redux Toolkit and Zustand download trends: /compare/redux-toolkit-vs-zustand
- Recoil vs Jotai — atomic state alternatives to both: /blog/recoil-vs-jotai-2026
- Zustand package details and health metrics: /packages/zustand