ReactJavascript

Zustand vs Redux Toolkit - Picking React State

A practical comparison of Zustand and Redux Toolkit covering boilerplate, devtools, middleware, scaling, and when each is the right pick.

Last updated: 20 May 2026

Side-by-side comparison of Zustand and Redux Toolkit state management approaches

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.

ConcernZustandRedux Toolkit
API surfacecreate() returns a hookcreateSlice, configureStore, Provider
Boilerplate per sliceOne function, ~10 linesSlice + reducer + selector + dispatch
Provider requiredNoYes (<Provider store={store}>)
DevtoolsOptional middleware, Redux DevTools compatibleRedux DevTools, action history, time travel
Data fetchingNone bundled. Pair with TanStack Query or fetchRTK Query bundled
Middleware ecosystempersist, devtools, immer, subscribeWithSelectorredux-saga, redux-observable, redux-persist, listener middleware
TypeScript inferenceGood, 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 patternOne store per concern, or slice patternFeature slices combined by combineReducers
Learning curveHoursDays

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.

Found this useful?

Share

Frequently asked questions

Is Zustand a replacement for Redux Toolkit?

Not always. Zustand replaces Redux for client-side state when an app needs a small, hook-first store without reducers, actions, or a Provider. Redux Toolkit remains the better pick when the project depends on RTK Query, formal middleware like redux-saga, time-travel debugging in Redux DevTools, or a team already trained on Redux patterns.

Which has better TypeScript support, Zustand or Redux Toolkit?

Both have good TypeScript support, but they differ in shape. Redux Toolkit infers reducer payload types from createSlice automatically and pairs with typed selectors via the configureStore RootState helper. Zustand needs a small amount of generic boilerplate at the create() call but infers everything inside the store after that. For inference inside the consuming component, the two are roughly equivalent.

Does Zustand work with Redux DevTools?

Yes, via the devtools middleware from zustand/middleware. It exposes the store as a Redux-shaped state tree to the browser extension so actions and state changes can be inspected. Time-travel debugging works, although the action labels are less rich than what createSlice emits in Redux Toolkit by default.

Can I use RTK Query without Redux Toolkit?

Not really. RTK Query ships as part of @reduxjs/toolkit and depends on the Redux store to hold its cache. If a project wants RTK Query's caching and codegen but does not want Redux for client state, the practical path is to keep the Redux store but write the client state in Zustand on the side. The more common alternative is TanStack Query paired with Zustand, which is what most non-Redux apps reach for.

When should I migrate from Redux Toolkit to Zustand?

Migrate when the Redux store has shrunk to a few isolated slices that do not need middleware, when the team no longer uses Redux DevTools time travel, and when the app does not depend on RTK Query. If any one of those three is still load-bearing, the migration costs more than it saves.

Thomas Findlay photo

About the author

Written by Thomas Findlay.

Thomas Findlay is a CTO, senior full-stack engineer, and the author of React - The Road To Enterprise and Vue - The Road To Enterprise. With 13+ years of experience, he has built and led engineering teams, architected multi-tenant SaaS platforms, and driven AI-augmented development workflows that cut feature delivery time by 50%+.

Thomas has spoken at international conferences including React Summit, React Advanced London, and Vue Amsterdam, and has written 50+ in-depth technical articles for the Telerik/Progress blog. He holds an MSc in Advanced Computer Science (Distinction) from the University of Exeter and a First-Class BSc in Web Design & Development from Northumbria University.

With a 5-star rating across 2,500+ mentoring sessions and 1,250+ reviews on Codementor, Thomas has helped developers and teams worldwide with architecture consulting, code reviews, and hands-on development support. Find him on LinkedIn, GitHub, or Twitter/X, or get in touch directly. Read the full bio →

Stop fighting your codebase. Start shipping.

Long-form articles like this one, plus a 400+ page book that takes you end-to-end through production React in TypeScript.