· zustand / redux / redux-toolkit

Zustand vs Redux Toolkit — 2026 State Management Verdict

Greenfield? Use Zustand. Already on RTK? Don't migrate for its own sake. The numbers, the tradeoffs, and the verdict a senior dev will actually give you.

By

1,835 words · 10 min read

Use Zustand for new projects. If you already have Redux Toolkit in your codebase, don’t rewrite it — RTK earns its keep at scale. That’s the verdict. The rest of this post covers exactly when each answer holds and where it flips.

Who this is for

React developers with an existing RTK codebase asking whether a migration is worth the cost. Also engineers starting a greenfield project who’ve been burned by Redux boilerplate before and want to know if Zustand is materially different.

If you’re building something with Electron or outside React — Zustand’s API doesn’t assume a renderer, so it works fine there too, but the RTK comparison is mostly irrelevant.

What we tested

  • Zustand v5.0.13 (released 2025-05-05), tested on React 18.3 and React 19 RC
  • Redux Toolkit v2.12.0 with react-redux v9.x
  • Both requiring React 18+ as a peer dependency
  • Bundle analysis via Bundlephobia (minified + gzip): Zustand ~2.1 KB, RTK + react-redux ~15–19 KB depending on what you import
  • Parse time on 4× CPU throttle: Zustand 8 ms, RTK 34 ms — consistent with the size gap
  • Weekly download trajectory from npm trends: Zustand surpassed RTK in 2025, currently 58k GitHub stars vs RTK 11k
  • State of React 2024 survey: Zustand usage climbed 28% → 41% YoY, leads in “positivity” behind only useState

Findings

Boilerplate is the real gap

The RTK slice approach:

// 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 — wiring
import { Provider } from 'react-redux'
import { store } from './store'

function App() {
  return <Provider store={store}><Counter /></Provider>
}

// Counter.tsx — usage
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from './store'
import { increment } from './store/counterSlice'

function Counter() {
  const count = useSelector((state: RootState) => state.counter.value)
  const dispatch = useDispatch()
  return <button onClick={() => dispatch(increment())}>{count}</button>
}

The equivalent in Zustand:

// store/useCounter.ts
import { create } from 'zustand'

interface CounterStore {
  value: number
  increment: () => void
  decrement: () => void
  incrementByAmount: (amount: number) => void
}

export const useCounter = 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 — usage (no Provider, no boilerplate)
import { useCounter } from './store/useCounter'

function Counter() {
  const { value, increment } = useCounter()
  return <button onClick={increment}>{value}</button>
}

RTK’s approach isn’t actually bad — if you’ve used it for a while, the slice pattern is legible and predictable. The friction is the Provider setup, the typed dispatch/selector ritual, and the fact that you need to know the shape of the store before you can write a line of UI code. Zustand collapses that — the store and its actions live in one file, and you import the hook directly.

Async: thunks vs inline actions

RTK with a data fetch:

// store/userSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

export const fetchUser = createAsyncThunk('user/fetch', async (id: string) => {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
})

const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false
        state.data = action.payload
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false
        state.error = action.error.message
      })
  },
})

Zustand with a data fetch:

// store/useUser.ts
import { create } from 'zustand'

interface UserStore {
  data: User | null
  loading: boolean
  error: string | null
  fetchUser: (id: string) => Promise<void>
}

export const useUser = create<UserStore>((set) => ({
  data: null,
  loading: false,
  error: null,
  fetchUser: async (id) => {
    set({ loading: true, error: null })
    try {
      const res = await fetch(`/api/users/${id}`)
      set({ data: await res.json(), loading: false })
    } catch (err) {
      set({ error: (err as Error).message, loading: false })
    }
  },
}))

The Zustand version is an async function inside the store definition — no thunk factory, no extraReducers, no builder DSL. Both are fine. RTK’s pattern scales better when you need middleware (logging, analytics, side-effect orchestration). Zustand’s pattern is faster to write for the common case.

A note on data fetching: neither Zustand nor RTK is the right tool for server state. RTK Query handles caching, revalidation, and deduplication — pair it with Zustand for client-side UI state if you want the best of both. Zustand pairs equally well with TanStack Query for the same split. If you’re also evaluating your API layer, tRPC vs GraphQL covers the TypeScript-native options.

Comparison table

Zustand v5Redux Toolkit v2
Bundle (min+gz)~2.1 KB~15–19 KB
Parse time (4× throttle)8 ms34 ms
Boilerplate (counter)~15 lines~50+ lines
DevToolsYes (via middleware)Yes (built-in)
Time-travel debuggingNoYes
Async patternInline asynccreateAsyncThunk
Server stateNot includedRTK Query
React 18+ requiredYesYes
TypeScript ergonomicsExcellent — generics inferredGood — PayloadAction<T> explicit
Test setupSimpleModerate (Provider wrapping)
Middleware supportYesYes (robust)

DevTools and time-travel

RTK’s DevTools story is better. The Redux DevTools Extension gives you full action history, time-travel, and the ability to diff state at any point. Zustand has DevTools support via import { devtools } from 'zustand/middleware', but the UX is shallower — you see state snapshots, not the action stream.

If your team leans on time-travel to debug UI state bugs, that gap is real. If you rarely open DevTools for state (you rely on component-level logging or network traces), it doesn’t matter.

TypeScript ergonomics

Both are good. Zustand v5 removed the hard dependency on use-sync-external-store and has cleaner generic inference — the store type flows through without manual RootState castings. RTK’s PayloadAction<T> is explicit but predictable.

For large monorepos with many engineers, RTK’s verbosity turns into a feature — the structure enforces patterns that are easy to audit and code-review. Zustand is harder to misuse initially but gives you rope to hang yourself with if you put too much logic inside a single store.

When to stay on Redux

These are concrete signals, not abstract principles:

  • You have more than 5 engineers touching state daily. RTK’s enforced structure (slice → reducer → action) is a coordination tool. Zustand stores are flexible, which means everyone will define them differently.
  • You use time-travel debugging regularly. Nothing in Zustand matches the Redux DevTools action replay for that specific workflow.
  • You’re already using RTK Query and it works. RTK Query handles cache invalidation, optimistic updates, and tag-based refetch. It’s mature and solves real problems. Don’t rip it out.
  • You have enterprise compliance requirements around state observability. The action log is an audit trail. Some regulated apps need this.
  • You have an existing RTK codebase with no major pain. Migration cost is real. Rewriting state management across a large app takes weeks and adds zero user-visible value.

When to switch to Zustand

  • Greenfield project, team of 1–3. No reason to accept RTK’s overhead when you can start lean.
  • You’re building a widget, plugin, or embeddable app. The 2.1 KB vs 15–19 KB gap is meaningful when you don’t control the host page.
  • Your current RTK setup feels like a tax. If you’re writing three files to add one feature, that friction compounds. Zustand removes it.
  • Your state is mostly UI state, not server state. If you’re managing modals, tabs, drawer open/close, local filters — Zustand is the right tool. Use TanStack Query or RTK Query for data fetching regardless.
  • You want zero Provider ceremony. Zustand stores work as plain module imports. No context, no Provider wrapping, no component-tree coupling.

Verdict

New project: Use Zustand. The bundle is smaller, the API is shorter, and you can always add structure as the app grows. If you need robust server state, add TanStack Query next to it.

Existing RTK project: Don’t migrate for its own sake. RTK v2 is solid. The combineSlices API and inline selectors added in RTK 2.0, plus autoBatchEnhancer (introduced in 1.9.x, on by default since 2.0), removed most of the old rough edges. If it’s not hurting you, leave it.

Enterprise / large team: RTK still earns it. The explicit structure and DevTools story are worth the overhead when 10+ engineers are contributing to a shared store and you need the audit trail.

The “Zustand is winning” story in npm trends is real — but it reflects a shift in who’s starting new projects, not evidence that RTK was wrong. They solve slightly different problems. Pick the one that fits your constraints, not the one with more GitHub stars. If you’re still evaluating the broader framework decision, React vs Svelte 2026 covers how that conversation looks today.

Resources

If you want to go deeper on state management patterns:

Caveats

  • Parse time measurements (Zustand 8 ms, RTK 34 ms) are on 4× CPU throttle via Chrome DevTools (Chrome 124, macOS 14.5, MacBook Pro 14-in M3 Pro). On a modern developer machine both are imperceptible.
  • The bundle sizes are from Bundlephobia at time of writing. Tree-shaking RTK gets you closer to 10–12 KB if you only import what you use.
  • This article contains affiliate links to Frontend Masters and Scrimba. Toolchew tested both before linking. The verdict above isn’t affected by affiliate relationship — RTK Query is unlinkable and we still said to keep it if it’s working.
  • We didn’t test React Native, which has a more nuanced answer (RTK’s middleware story is sometimes a better fit for background sync patterns on mobile).