Picking a React state library in 2026 usually narrows to two options: Zustand or Redux Toolkit. Zustand is the small, hook-first store with almost no ceremony. Redux Toolkit is the official, opinionated Redux setup with reducers, devtools, and RTK Query bundled in. If the app is small and isolated, Zustand wins on ergonomics. If RTK Query, formal middleware, or a team already fluent in Redux is in play, Redux Toolkit wins.
Most comparisons of the two libraries treat the choice as a stylistic preference. It is not. The decision changes how data fetching is wired, how middleware composes, what devtools experience the team gets, and how much code needs to move when requirements shift. The sections below walk through where each library actually wins, where each one stops scaling, and what a migration in either direction looks like.
At a glance comparison
A quick table to anchor the rest of the article. Numbers are the most recent published values at the time of writing. Source notes are underneath.
| Concern | Zustand | Redux Toolkit |
|---|---|---|
| API surface | create() returns a hook | createSlice, configureStore, Provider |
| Boilerplate per slice | One function, ~10 lines | Slice + reducer + selector + dispatch |
| Provider required | No | Yes (<Provider store={store}>) |
| Devtools | Optional middleware, Redux DevTools compatible | Redux DevTools, action history, time travel |
| Data fetching | None bundled. Pair with TanStack Query or fetch | RTK Query bundled |
| Middleware ecosystem | persist, devtools, immer, subscribeWithSelector | redux-saga, redux-observable, redux-persist, listener middleware |
| TypeScript inference | Good, with a small generic at create() | Strong via createSlice payload inference |
| Bundle size (gzipped, core) | ~0.5 KB | ~13.6 KB |
| Weekly npm downloads (May 2026) | ~37M | ~16M |
| Scaling pattern | One store per concern, or slice pattern | Feature slices combined by combineReducers |
| Learning curve | Hours | Days |
Bundle sizes pulled from Bundlephobia (Zustand 5.0.13: 486 B gzipped; @reduxjs/toolkit 2.12.0: 13.6 KB gzipped). Weekly download counts pulled from the npm registry downloads API for the week of 13–19 May 2026.
The table sets up the trade-off. Zustand is dramatically smaller and faster to adopt. Redux Toolkit ships more in the box, including the data layer.
Same slice, two libraries
Theory only gets you so far. The same todo store written in both libraries shows where the real differences sit.
The Zustand version
We start with Zustand. The store is a single function call. Actions and state live in the same object.
src/stores/todoStore.ts
import { create } from 'zustand'
type Todo = {
id: string
text: string
done: boolean
}
type TodoState = {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
}
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: crypto.randomUUID(), text, done: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
),
})),
}))
A consumer pulls exactly what it needs out of the hook.
src/components/TodoList.tsx
import { useTodoStore } from '../stores/todoStore'
export const TodoList = () => {
const todos = useTodoStore((state) => state.todos)
const toggleTodo = useTodoStore((state) => state.toggleTodo)
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.done ? 'Done' : 'Todo'}: {todo.text}
</li>
))}
</ul>
)
}
That is the whole store and the whole consumer. There is no Provider, no dispatch, no separate selector layer. The hook is the API, and the selector function inside the hook controls re-renders. The component only re-renders when state.todos or the toggleTodo reference changes.
The Redux Toolkit version
The same slice in Redux Toolkit takes more pieces. We need a slice file, a store file, a Provider at the app root, and typed hooks for the consumer.
src/store/todoSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
type Todo = {
id: string
text: string
done: boolean
}
type TodoState = {
todos: Todo[]
}
const initialState: TodoState = { todos: [] }
const todoSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.todos.push({
id: crypto.randomUUID(),
text: action.payload,
done: false,
})
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.todos.find((t) => t.id === action.payload)
if (todo) todo.done = !todo.done
},
},
})
export const { addTodo, toggleTodo } = todoSlice.actions
export default todoSlice.reducer
The reducer uses Immer internally, which is why the push and the direct property assignment look like mutations but produce a new state object. Next, the slice needs to be wired into a store.
src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import todoReducer from './todoSlice'
export const store = configureStore({
reducer: {
todos: todoReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Typed hooks live next to the store so consumers do not have to type their selectors and dispatch every time.
src/store/hooks.ts
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from './index'
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
The Provider wraps the app once, near the root.
src/main.tsx
import { Provider } from 'react-redux'
import { store } from './store'
createRoot(document.getElementById('root')!).render(
<Provider store={store}>
<App />
</Provider>
)
And finally the consumer.
src/components/TodoList.tsx
import { useAppSelector, useAppDispatch } from '../store/hooks'
import { toggleTodo } from '../store/todoSlice'
export const TodoList = () => {
const todos = useAppSelector((state) => state.todos.todos)
const dispatch = useAppDispatch()
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
{todo.done ? 'Done' : 'Todo'}: {todo.text}
</li>
))}
</ul>
)
}
The consumer reads roughly the same as the Zustand one, but to get here we wrote five files instead of two. The slice file alone is fine. createSlice is genuinely concise compared to old-school Redux. What stings is the store file, the Provider, the typed-hooks file, and the implicit coupling between them. None of those exist in the Zustand version, because there is no global store object to configure and no context boundary to cross.
Cleaner on the Zustand side. More structure on the Redux Toolkit side. The question is whether that structure pays for itself.
Where the structure earns its keep
Redux Toolkit's extra ceremony is not gratuitous. The structure exists because Redux exposes three things Zustand does not give you by default.
The first is action history in Redux DevTools. Every dispatched action shows up as a labelled entry in the timeline, with a diff of the state before and after, plus time-travel playback. Zustand can plug into Redux DevTools via the devtools middleware, and that gets most of the way there, but the action labels are less informative because Zustand does not have named action types. Every set() call shows up as an anonymous mutation unless you name it manually inside devtools({ name: '...' }, set).
The second is RTK Query. RTK Query is the data-fetching and caching layer that ships inside @reduxjs/toolkit. It writes its cache into the Redux store, which is why it is coupled to Redux. If the app needs RTK Query, Zustand is not an alternative. They solve different problems. The realistic comparison there is RTK Query versus TanStack Query paired with Zustand for client state.
The third is the middleware ecosystem. Redux has fifteen years of middleware behind it: redux-saga for complex async flows, redux-observable for RxJS-based effects, redux-persist for storage, the official listener middleware for side-effects, redux-undo for history, and so on. Zustand has a handful of first-party middlewares (persist, devtools, immer, subscribeWithSelector, plus combine, redux, and ssrSafe) and a thin layer of community ones, but the ecosystem is an order of magnitude smaller. For most apps that is fine. For apps that genuinely need saga-style orchestration or RxJS pipelines, Redux still wins.
That is the trade-off in one sentence. Redux Toolkit gives you a wider ecosystem at the cost of more setup. Zustand gives you faster setup at the cost of a smaller ecosystem.
Where Zustand stops scaling
The first place Zustand strains is when a state change needs to coordinate side-effects across multiple slices. Zustand has subscribeWithSelector for reacting to state changes outside React, and that solves most cases, but it does not give you the orchestrated, replayable, testable effect model that redux-saga or the Redux listener middleware does. If the app has a workflow like "when the user logs out, clear seven different stores, cancel three in-flight requests, and persist a final analytics event in order", Zustand can do it, but you will be hand-rolling the orchestration. Redux Toolkit with the listener middleware models that workflow declaratively.
The second place is debugging. With Zustand, the devtools middleware gives you state inspection, but the action history is thin because Zustand has no named actions by default. When a bug reproduces by clicking through ten different actions, the Redux DevTools timeline in a Redux Toolkit app shows you exactly which ten actions, in which order, with which payloads. The Zustand timeline shows you ten anonymous setState entries unless every action was wrapped manually. For small apps this does not matter. For an app with eighty-plus components and a dozen interlocking slices, the debugging gap is real.
Where Redux Toolkit is overkill
The reverse failure mode also exists. Redux Toolkit is overkill when an app has one or two pieces of client state (a sidebar open/closed flag, a UI theme, a small set of filters) and no need for RTK Query or formal middleware. Adding a Provider, a configured store, typed hooks, and a slice file for a boolean is exactly the kind of ceremony that makes new contributors roll their eyes.
It is also overkill for component-tree-scoped state that does not need to live globally. useState and useReducer are still the right answer for state that belongs to one screen. Reaching for any global store, Zustand or Redux Toolkit, when the data does not cross component-tree boundaries is the most common state-management mistake I see in code reviews. The library is a hammer, but the problem is not always a nail.
When the global store is genuinely warranted and the requirements are modest, Zustand fits the bill with less code. When the requirements grow into the territory of cache invalidation, optimistic updates, polling, or multi-slice orchestration, Redux Toolkit's bundled features start paying back the cost of its setup.
TypeScript inference, side by side
Both libraries have respectable TypeScript stories, but the shape is different.
Redux Toolkit's createSlice infers the payload type from the reducer signature. The action creator generated for addTodo is automatically typed as (text: string) => PayloadAction<string> because the reducer declared action: PayloadAction<string>. The selector layer gets typed via the RootState helper, and the dispatch hook via AppDispatch. Once those typed hooks are written, consumers get full inference for free.
Zustand requires you to write the state interface once and pass it as a generic to create<TodoState>(). Inside the store, every call to set() and get() is typed against that interface. Outside the store, the hook is already typed. The pattern is slightly more manual at the definition site but slightly less verbose at the consumer site, because there is no separate RootState indirection and no typed selector helpers to wire.
A practical observation: the friction shows up most when generics are inferred through middleware. Zustand's middleware composition (create<State>()(devtools(persist((set) => ({ ... }))))) used to require manual generic threading. Recent versions (Zustand 4.0 onwards, when the curried create<T>()(...) form landed) have improved the inference, but the type errors when something is wrong are still less helpful than the Redux Toolkit equivalents.
For a small store, the two are a wash. For a large store with three or four middlewares stacked, Redux Toolkit's type errors are usually easier to read.
When to pick Zustand
A handful of scenarios where Zustand is the right default:
- The app is small or medium, and global state is one or two concerns (theme, sidebar, filters, modal stack).
- The team is using TanStack Query for server state and only needs a thin client-state layer alongside it.
- A library or design-system package needs its own internal store without forcing consumers to add a Provider.
- A team wants to start writing global state immediately without spending a week on Redux ergonomics first.
- Bundle size is a genuine constraint, like a content-heavy site or a mobile-targeted web app where every kilobyte counts.
The library wins on time-to-first-store and on bundle size. The trade-off you accept is a smaller middleware ecosystem and less formality as the app grows.
When to pick Redux Toolkit
Redux Toolkit is the right default in these cases:
- The app needs RTK Query and the bundled cache layer.
- The team is already trained on Redux patterns from a previous project, and re-training to a new mental model has a real cost.
- The app has complex async workflows that benefit from the listener middleware, redux-saga, or redux-observable.
- Action-level debugging, time travel, and replayable test fixtures are part of the engineering culture.
- The codebase will grow past twelve or fifteen feature slices and needs a single, enforced structure for state.
Redux Toolkit's value compounds with app size and team size. Below a threshold, it is friction. Above the threshold, the friction pays for itself.
Migration considerations
Teams asking the migration question usually fall into two camps. Each direction has a different cost profile.
Migrating from Redux Toolkit to Zustand
This is the more common direction in 2026. The trigger is usually that an existing Redux app has shrunk to a few isolated slices after server state was moved to TanStack Query or RTK Query was never adopted.
The migration is mechanical for slices that do not use middleware. Each slice becomes a create() call. Selectors map almost one-to-one. Redux Toolkit's useAppSelector((state) => state.todos.todos) becomes Zustand's useTodoStore((state) => state.todos). Dispatched actions become direct method calls on the store. The Provider goes away. Typed hooks go away.
What does not migrate cleanly: anything that uses redux-saga, redux-observable, or the listener middleware. Those patterns assume the Redux dispatch pipeline. Reimplementing them in Zustand means rebuilding the orchestration by hand using subscribeWithSelector and async functions. If a slice depends on saga-style coordination, that slice is a poor migration candidate.
RTK Query also does not migrate cleanly. If the app uses RTK Query, the migration is not from Redux Toolkit to Zustand. It is from RTK Query to TanStack Query first, then from Redux Toolkit to Zustand. That is two migrations, not one, and they should be planned separately. I would recommend doing the data-fetching swap first, letting it settle for a few weeks, and only then evaluating whether the remaining client state still needs Redux.
Migrating from Zustand to Redux Toolkit
The reverse migration is less common but happens when a Zustand codebase has outgrown its conventions. The triggers are usually inconsistent store patterns across features, debugging pain from anonymous actions, or a new requirement for RTK Query.
The migration is more work because Redux Toolkit demands structure that Zustand does not. Each Zustand store becomes a slice. Each store's actions become reducer cases. The Provider gets added once at the root. Typed hooks get wired. Consumers shift from useStoreName(selector) to useAppSelector(selector) plus dispatch(action(payload)).
The migration is mostly mechanical, but the diff is large. Plan it feature by feature, not all at once. Run both stores in parallel during the transition. Redux Toolkit's Provider and Zustand stores do not conflict, so a half-migrated codebase still works.
Coupling with the rest of the stack
State-management choices are not made in isolation. They couple with the data-fetching choice, the form-library choice, and the routing choice.
Zustand pairs naturally with TanStack Query. The pattern is server state in TanStack Query, client state in Zustand, and the two layers do not need to know about each other. This is the dominant pattern in non-Redux React apps in 2026.
Redux Toolkit pairs naturally with RTK Query, since they ship together and share the store. Server state in RTK Query, client state in Redux Toolkit slices, both inspected in the same Redux DevTools panel. The coupling is tighter, but the developer experience is cohesive.
Mixing the two (Redux Toolkit for client state with TanStack Query for server state, for example) works but loses the cohesion. The team has to operate in two mental models simultaneously, and the devtools experience is split across two panels.
Picking, finally
Zustand or Redux Toolkit is the wrong question if it is asked in the abstract. The right version of the question is: what does the rest of the stack look like, how big will the app be, and what does the team already know?
For a small app, a library, or a team that wants TanStack Query for data, pick Zustand. For an app that needs RTK Query, action-level debugging, or formal middleware, pick Redux Toolkit. For everything in between, the deciding factor is usually team familiarity rather than technical fit.


