Zustand vs Redux: which state manager should you use?
Tien Nguyen
· 11 min read
Most “Zustand vs Redux” posts lose before they start, because they compare Zustand to the wrong Redux: the 2018 version with action-type constants, hand-written reducers, and mapDispatchToProps boilerplate nobody misses. Nobody writes Redux that way anymore.
The fair fight is Zustand vs Redux Toolkit (RTK), the modern, official, batteries-included Redux. Compare those two and the real tradeoff shows up fast.
Short version up front: reach for Zustand for client UI state in small-to-mid apps; reach for Redux Toolkit when a large team, strict conventions, or heavy devtools earn its extra weight. And before either: most of this debate is actually about server state, which belongs in neither. Let me show you.
For context on where I’m coming from: I’ve shipped Redux (and later RTK) in production React apps, and Zustand is what I now reach for on newer, smaller projects.
vs
First: which Redux are you comparing?
This is the whole game. Redux Toolkit killed the boilerplate that made classic Redux miserable: createSlice generates your actions and reducers, Immer lets you “mutate” state safely, and configureStore wires up devtools and middleware for you. If your mental image of Redux is a folder of actionTypes.js, actions.js, and reducers.js, update it. That Redux is gone.
So the honest comparison is a small, unopinionated store library (Zustand) against a larger, opinionated, convention-driven framework (RTK). Call it minimal versus batteries-included.
How much does each tool actually do for you?
Here’s the same counter in each. Zustand gives you a hook-shaped store and nothing else:
import { create } from 'zustand';
const useCounter = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })),}));You read it in a component with a selector, and no provider wraps your app:
function Counter() { const count = useCounter((s) => s.count); return <button onClick={useCounter((s) => s.increment)}>{count}</button>;}Redux Toolkit does more, and asks for more in return: a slice, a configured store, and a provider at the root.
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counter = createSlice({ name: 'counter', initialState: { count: 0 }, reducers: { increment: (state) => { state.count += 1; }, // Immer makes this "mutation" safe },});
export const { increment } = counter.actions;export const store = configureStore({ reducer: { counter: counter.reducer } });And unlike Zustand, RTK wants a provider at the app root:
<Provider store={store}> <App /></Provider>A component reads state with useSelector and fires actions with useDispatch:
import { useSelector, useDispatch } from 'react-redux';import { increment } from './counter-slice';
// RootState = ReturnType<typeof store.getState>function Counter() { const count = useSelector((s: RootState) => s.counter.count); const dispatch = useDispatch(); return <button onClick={() => dispatch(increment())}>{count}</button>;}Neither is bad. Zustand is less to write and less to learn. RTK’s extra structure is the point: on a big team, “there is one right way to add state” is a feature, not a tax.
How much bigger is Redux Toolkit than Zustand?
The size gap is real and large. Zustand is about 0.5 KB gzipped; the Redux Toolkit plus react-redux stack is around 17 KB gzipped (bundlephobia, mid-2026). For most apps 17 KB is a rounding error against your framework and images, but for a widget, an embed, or a size-critical page, it matters.
On re-renders, both are good when used correctly, and both have footguns. Zustand re-renders a component only for the slice its selector returns, so useCounter((s) => s.count) is precise by default. Redux with useSelector is similar, but you reach for memoized selectors (reselect, bundled in RTK) once your derived state gets expensive. The equality-function details bite in both: return a fresh object from a selector without an equality check and you’ll re-render every update in either library.
What does a realistic store actually look like?
The short answer: in Zustand it’s a plain object where async is just an async function; Redux Toolkit splits the same work into a slice, a thunk, and lifecycle reducers. A counter hides that difference. Here’s a small todo store with multiple fields, actions that take arguments, and an async load. Zustand keeps it in one object:
import { create } from 'zustand';
type Todo = { id: string; text: string; done: boolean };
type TodoStore = { todos: Todo[]; loading: boolean; addTodo: (text: string) => void; toggleTodo: (id: string) => void; loadTodos: () => Promise<void>;};
export const useTodos = create<TodoStore>()((set) => ({ todos: [], loading: false, addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: crypto.randomUUID(), text, done: false }], })), toggleTodo: (id) => set((state) => ({ todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)), })), loadTodos: async () => { set({ loading: true }); const todos = await fetch('/api/todos').then((r) => r.json()); set({ todos, loading: false }); },}));Actions are plain functions, and the async load is just an async function that calls set. No new concepts. You read it with a selector, and no provider wraps your app:
import { useEffect } from 'react';import { useTodos } from './todo-store';
function TodoList() { const todos = useTodos((s) => s.todos); const loading = useTodos((s) => s.loading); const addTodo = useTodos((s) => s.addTodo); const toggleTodo = useTodos((s) => s.toggleTodo); const loadTodos = useTodos((s) => s.loadTodos);
useEffect(() => { loadTodos(); }, [loadTodos]); // load once on mount
if (loading) return <p>Loading...</p>;
return ( <> <button onClick={() => addTodo('New task')}>Add</button> <ul> {todos.map((todo) => ( <li key={todo.id} onClick={() => toggleTodo(todo.id)}> {todo.done ? '[x]' : '[ ]'} {todo.text} </li> ))} </ul> </> );}Redux Toolkit does the same work with more machinery: a slice for the sync reducers, a createAsyncThunk for the fetch, and extraReducers to handle that thunk’s pending and fulfilled states. The prepare callback is how you generate the id before the reducer runs.
import { createSlice, createAsyncThunk, nanoid, type PayloadAction,} from '@reduxjs/toolkit';
type Todo = { id: string; text: string; done: boolean };
export const loadTodos = createAsyncThunk('todos/load', async () => { return (await fetch('/api/todos').then((r) => r.json())) as Todo[];});
const todosSlice = createSlice({ name: 'todos', initialState: { todos: [] as Todo[], loading: false }, reducers: { addTodo: { reducer: (state, action: PayloadAction<Todo>) => { state.todos.push(action.payload); // Immer: safe to "mutate" }, prepare: (text: string) => ({ payload: { id: nanoid(), text, done: false }, }), }, toggleTodo: (state, action: PayloadAction<string>) => { const todo = state.todos.find((t) => t.id === action.payload); if (todo) todo.done = !todo.done; }, }, extraReducers: (builder) => { builder .addCase(loadTodos.pending, (state) => { state.loading = true; }) .addCase(loadTodos.fulfilled, (state, action) => { state.todos = action.payload; state.loading = false; }); },});
export const { addTodo, toggleTodo } = todosSlice.actions;Wire todosSlice.reducer into the configureStore from earlier, then read it with useSelector and fire actions with dispatch:
import { useEffect } from 'react';import { useSelector, useDispatch } from 'react-redux';import { addTodo, loadTodos, toggleTodo } from './todos-slice';
// RootState = ReturnType<typeof store.getState>function TodoList() { const dispatch = useDispatch(); const todos = useSelector((s: RootState) => s.todos.todos); const loading = useSelector((s: RootState) => s.todos.loading);
useEffect(() => { dispatch(loadTodos()); }, [dispatch]); // fire the thunk on mount
if (loading) return <p>Loading...</p>;
return ( <> <button onClick={() => dispatch(addTodo('New task'))}>Add</button> <ul> {todos.map((todo) => ( <li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}> {todo.done ? '[x]' : '[ ]'} {todo.text} </li> ))} </ul> </> );}That contrast is the point. RTK’s thunk-plus-lifecycle structure buys you consistency and devtools visibility on every async step, and it costs you more code. (Both examples skip the error path for brevity: RTK would add a .addCase(loadTodos.rejected, ...), Zustand a try/catch around the fetch. And the id calls differ only by library default. RTK ships nanoid; Zustand leaves it to you, so I used the platform’s crypto.randomUUID.) It’s also a preview of the next section: most of that async state is server state, and there’s a better home for it than either store.
The trap: most of this is server state
Here’s the part almost every comparison misses. A huge share of “which global store” debates are really about server state: data you fetched from an API, that can go stale and needs caching, refetching, and loading states. That does not belong in Zustand or Redux. It belongs in a data-fetching cache: TanStack Query (or RTK Query, which ships inside Redux Toolkit). Put server data there, and your global store shrinks to actual client state: theme, modals, the current wizard step, an auth token. Once you split it that way, most of the “Zustand vs Redux” decision evaporates, because whatever’s left is small and simple enough that the light option usually wins.
So the real first question isn’t Zustand or Redux. It’s: is this server state or client state? Answer that, and the store choice gets easy.

When should you choose Redux Toolkit over Zustand?
I like Zustand, but RTK is the right call more often than its detractors admit. Reach for Redux Toolkit when:
- You have a large team. RTK’s enforced conventions mean twenty engineers write state the same way. Zustand’s freedom becomes twenty different patterns.
- You lean on devtools. Redux DevTools with time-travel debugging and a full action log is genuinely better than anything in the Zustand ecosystem, and on a complex app it’s a real productivity tool.
- You have normalized, relational entity state. RTK’s
createEntityAdapteris purpose-built for this; in Zustand you’d hand-roll it. - You want a data-cache built in. RTK Query lives inside RTK, so if you’re already there, you get server-state caching without another dependency.
When should you choose Zustand over Redux Toolkit?
Reach for Zustand when the ceremony would cost more than it returns:
- Solo or small team, where conventions live in your head, not a framework.
- Client UI state that’s genuinely simple: toggles, modals, a bit of shared form state.
- You care about bundle size, or you just want to add a store in five lines and move on.
- You’ve already moved server state to TanStack Query, so the global store is small anyway.
Adoption backs this up as the new default for the simple majority: Zustand now pulls roughly 42 million weekly npm downloads to Redux Toolkit’s ~22 million (npm trends, mid-2026). It overtook RTK, but RTK is still enormous, which is exactly the point: two healthy tools for two different jobs.
Zustand vs Redux Toolkit at a glance
| Metric | Zustand | Redux Toolkit |
|---|---|---|
| Bundle (gzip) | ~0.5 KB | ~17 KB (with react-redux) |
| Setup | a create call, no provider | slice + configureStore + provider |
| Opinionated? | no, you impose structure | yes, conventions enforced |
| Devtools | basic | full time-travel + action log |
| Server-state caching | bring TanStack Query | RTK Query built in |
| Normalized entities | hand-rolled | createEntityAdapter |
| Best for | small-to-mid, client UI state | large teams, strict architecture |
The takeaway
Compare Zustand to Redux Toolkit, not the boilerplate Redux of 2018, and the real question gets simple: how much structure do you want the tool to enforce?
My decision, in order: move server state to TanStack Query or RTK Query first. Whatever client state is left, put it in Zustand, unless you have the team size, the entity complexity, or the devtools need that makes Redux Toolkit’s extra weight pay for itself. Both are good. The answer is about your constraints, not the libraries.
Frequently asked questions
Is Zustand better than Redux Toolkit?
Not universally. Zustand is smaller and has far less ceremony, so it's the better default for client UI state in small-to-mid apps. Redux Toolkit is better when you need strict conventions across a large team, powerful devtools and time-travel, normalized entity state, or RTK Query as a data-cache layer. Pick by constraints, not hype.
Is Redux still used in 2026?
Yes, heavily. Modern Redux is Redux Toolkit (RTK), not the boilerplate-heavy 2018 version, and it still pulls around 22 million weekly npm downloads. It remains a common default on large teams and big codebases. Redux didn't die; the verbose way of writing it did.
Is Zustand good for big projects?
It can be, but it asks more discipline of you. Zustand gives you a store and gets out of the way, so on a large team you have to impose the structure Redux Toolkit enforces for you (slices, conventions, devtools, normalized state). Plenty of big apps run Zustand happily; they just added the guardrails themselves.
Should Zustand or Redux hold my server data?
Neither, usually. Most "Zustand vs Redux" debates are really about server state (API data), and that belongs in a data-fetching cache like TanStack Query or RTK Query, not a global client store. Sort that first: server state to a query cache, client-only UI state to Zustand or Redux. Half the decision disappears.
Is Zustand replacing Redux?
For new client state, it's become many teams' default, and it recently overtook Redux Toolkit in weekly npm downloads. But "replacing" is too strong: Redux Toolkit still owns the large-team, strict-architecture, heavy-devtools end. It's less a replacement than the new default for the simpler majority of cases.
Keep reading
Tien Nguyen
· 8 min read
Closures in JavaScript, and the stale-closure bug in React
What a closure is in plain JavaScript, and the stale-closure bug it causes in React hooks: the one idea behind bugs AI writes and juniors paste unread.
Read article
Tien Nguyen
· 9 min read
Material UI vs Tailwind CSS: what you inherit vs build
Material UI vs Tailwind CSS: MUI gives you Material Design plus an Emotion runtime; Tailwind compiles to zero-runtime CSS, at ~10x MUI's npm downloads.
Read article
Tien Nguyen
· 7 min read
Is shadcn/ui worth it? The honest 2026 tradeoff
Is shadcn/ui worth it? You own the code: full control and no lock-in, but you maintain it. The honest tradeoff, the Base UI switch, and when to skip it.
Read articleLiked this? Let's build something.
I take on a small number of front end and AI-engineering projects, and I'm always up for talking shop. Tell me what you're working on.