Skip to content

Zustand vs Redux Toolkit in 2026: The State I Actually Ship

Zustand vs Redux Toolkit in 2026: The State I Actually Ship

Confession: I spent about two hours last Tuesday arguing with a coworker about Zustand vs Redux Toolkit. We were both wrong in slightly different ways. He wanted to Redux-ify a tiny admin dashboard that had exactly one piece of shared state. I wanted to Zustand-ify a multi-tenant billing UI that had a genuinely complex reducer graph, undo/redo, and three engineers touching it at once.

We swapped opinions halfway through the argument, which is the kind of thing that only happens after too much coffee. Neither library is “better.” The interesting question is which one you should reach for on the app you’re actually building this month.

I’ve now shipped both in production for a few years. This post is my honest map of which one I pick, when I pick the other one, and the specific moment I realized my defaults were wrong.

The problem I keep hitting with Redux Toolkit

I have nothing against Redux Toolkit. It’s the most polished version of Redux that has ever existed. createSlice fixed the most painful boilerplate, RTK Query is genuinely great for server state, and the DevTools are still the gold standard for time-travel debugging. If you’re building anything with a large team and a long tail of features, RTK is a defensible pick.

But here’s the thing I keep running into on small-to-medium apps.

Even after createSlice, you’re still writing a Provider, a store file, typed hooks, and a slice per feature. For a modal-toggle-and-a-user-object app, that’s a lot of ceremony. The old-way version, before I got tired of it, looked like this:

// store/uiSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

const uiSlice = createSlice({
  name: 'ui',
  initialState: { isSidebarOpen: false, theme: 'light' as 'light' | 'dark' },
  reducers: {
    toggleSidebar: (state) => { state.isSidebarOpen = !state.isSidebarOpen },
    setTheme: (state, action: PayloadAction<'light' | 'dark'>) => {
      state.theme = action.payload
    },
  },
})

export const { toggleSidebar, setTheme } = uiSlice.actions
export default uiSlice.reducer

// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import ui from './uiSlice'
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'

export const store = configureStore({ reducer: { ui } })
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

Then a <Provider store={store}> in your root, and every component doing const dispatch = useAppDispatch(); dispatch(toggleSidebar()) to flip a boolean.

It’s not offensive. It’s just a lot of typing for one boolean.

Zustand’s whole thing in about eight lines

Zustand does the same job with less machinery. There’s no Provider, no dispatch, no typed hooks helper. You define a store as a hook, and the hook is the store.

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

type UIState = {
  isSidebarOpen: boolean
  theme: 'light' | 'dark'
  toggleSidebar: () => void
  setTheme: (t: 'light' | 'dark') => void
}

export const useUI = create<UIState>((set) => ({
  isSidebarOpen: false,
  theme: 'light',
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),
  setTheme: (theme) => set({ theme }),
}))

In a component:

const isSidebarOpen = useUI((s) => s.isSidebarOpen)
const toggleSidebar = useUI((s) => s.toggleSidebar)

That selector function on the hook is the important part. Zustand only re-renders the component when the slice you selected actually changes, so you get the “only re-render what changed” behavior for free without wrapping anything in useMemo or React.memo. Read the Zustand docs on selectors once and you’ll internalize the pattern in about ten minutes.

The whole library ships in a few kilobytes. The Redux Toolkit + React Redux pair is meaningfully bigger, and that adds up on mobile where JS parse time actually shows up in your Core Web Vitals.

Where Zustand actually wins (and it’s not what people say)

Everyone brings up bundle size and boilerplate. Fine, those matter. But the win I keep noticing is more subtle.

The win is colocation. Zustand stores don’t need to live in a top-level store file. I’ve started putting feature-scoped stores in the feature folder next to the components that use them:

features/
  billing/
    BillingTable.tsx
    useBillingStore.ts  <-- lives here, not in /store
    edit-modal/

That colocation is quietly wonderful. When we deleted the billing feature six months later, the store went with it. No dead code in a central rootReducer. No orphaned selectors. It just disappeared with the folder.

You can do this with RTK if you’re disciplined, but the library’s shape actively pushes you toward a central store. Zustand’s shape pushes you toward the store belonging to whatever needs it.

The other quiet win is async. No thunks, no sagas, no listener middleware. If you want a fetch inside an action, write a fetch:

export const useBilling = create<BillingState>((set) => ({
  invoices: [],
  loading: false,
  fetchInvoices: async () => {
    set({ loading: true })
    const res = await fetch('/api/invoices').then((r) => r.json())
    set({ invoices: res, loading: false })
  },
}))

This is one of those things that sounds like a small thing until you’ve written your fifth createAsyncThunk with three lifecycle actions and realized you were basically hand-rolling useState with extra steps.

I wrote about a related shift in React Query vs SWR — server state is a different animal from UI state, and once you separate those in your head, the “state management” question gets much smaller.

Where Redux Toolkit still wins

This is the section where I stop being a Zustand cheerleader.

Time-travel DevTools are still the gold standard. Zustand has DevTools middleware and it works, but Redux DevTools with full action history is a different tier of debugging. On a support bot I was building last year, I could replay the last twenty user actions and see exactly where a state transition went sideways. That doesn’t work as cleanly in Zustand, and if your app has a truly complex reducer graph, you’ll miss it.

RTK Query is a real feature. If you’re going all-in on Redux for both UI and server state, RTK Query gives you caching, deduplication, and normalized results without adding another dependency. Zustand doesn’t try to do server state. You’d pair it with TanStack Query, which is fine, but that’s two libraries instead of one.

Bigger teams also appreciate the guardrails. Redux’s whole idea, that actions are the only way state changes and every action is traceable, is more useful the more people are touching the code. On a team of two, “here’s a hook, call the function” is fine. On a team of twelve rotating between features, “every state change is a named, typed action that shows up in DevTools” starts earning its keep.

The official Redux Toolkit docs are also much better now than they used to be. If you’re picking Redux for the first time in 2026, follow their quickstart. Do not follow a 2018 blog post.

How I actually pick between them now

My current heuristic, which I have not yet been embarrassed by:

  • Small app, one or two devs, mostly UI state: Zustand. Every time. The ceremony-to-value ratio isn’t close.
  • Large app, five-plus devs, complex flows, need DevTools for support: RTK. The guardrails and tooling pay for the boilerplate.
  • Server state is the main thing you’re managing: neither is the answer. Use TanStack Query for server state and Zustand (or useState) for whatever UI state is left over. This is the setup I reach for on 80% of new Next.js apps I start.
  • Migrating away from legacy Redux: RTK, not Zustand. A gradual createSlice migration is much lower risk than a full rewrite to a different mental model.

Notice that “which library has better performance” is not on that list. In real apps, on real devices, the perf difference between a properly-configured Redux Toolkit setup and a properly-configured Zustand setup is inside the noise. If you’re picking a state library for raw speed, you’re optimizing the wrong thing.

I share more of these opinionated dev calls in my work notes on abrarqasim.com if you want the running list.

The one migration path most people miss

Here’s the thing nobody told me until I did it wrong once.

Zustand and Redux Toolkit can coexist in the same app. There is no rule that says you have to pick one. On the multi-tenant billing UI I mentioned at the top, we kept the existing RTK setup for the complex undo/redo reducer graph and put all the new feature-local state in Zustand stores. Nothing broke. The two libraries do not fight over React’s context or scheduling.

That was the moment my defaults shifted. “Which do I pick” turned into “which do I pick for this specific piece of state.” Cheaper decisions, better outcomes.

If you’re staring at a legacy RTK setup and wishing you could Zustand-ify everything, don’t. Zustand-ify one feature. See how it feels. Ship it. If you like it, do the next one. This is one of those situations where the incremental migration is the correct migration.

Try this next time you start a Next.js app

Concrete action for this week: on the next new Next.js project you spin up, don’t add a state library at all in the first commit. Use useState and Server Components for as long as it works. When you actually need shared client state — usually around the point you’re prop-drilling through three levels — reach for Zustand first. Only reach for RTK if you can name the specific feature it gives you that Zustand doesn’t.

You’ll be surprised how often the answer is “nothing, actually.” Which is the correct answer more often than the internet suggests.