Skip to content

Zustand vs Redux: The State Library I Actually Reach For

Zustand vs Redux: The State Library I Actually Reach For

Confession: I defended Redux for years. I had the folder structure memorized, the createSlice muscle memory, the little speech about why global state needs structure. Then I built a small dashboard with Zustand over a weekend, and most of that speech stopped making sense. I kept waiting to miss something. I didn’t.

This isn’t a “Redux is dead” post. Redux is fine, and on a couple of my projects it’s still the right call. But the question I get asked most often by people on my team is some version of “do we actually need Redux for this?” and the honest answer, most of the time, is no. So here’s how I think about the two now, with real code, and where I draw the line.

The boilerplate that finally got to me

The thing about Redux isn’t that any single piece is hard. It’s that there are a lot of pieces, and you set them all up before you’ve stored a single value. Modern Redux Toolkit cut this down a lot from the old mapDispatchToProps days, and credit where it’s due. But a basic counter still looks like this:

// store.js — Redux Toolkit
import { configureStore, createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1 },
    addBy: (state, action) => { state.value += action.payload },
  },
})

export const { increment, addBy } = counterSlice.actions
export const store = configureStore({ reducer: { counter: counterSlice.reducer } })

And then the component side needs a Provider wrapped around your app, plus the two hooks:

import { Provider, useSelector, useDispatch } from 'react-redux'
import { store, increment } from './store'

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

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

That’s a counter. For one number. I’m not saying it’s wrong, the structure pays off on big apps, but it’s a lot of ceremony for what most features actually need.

What Zustand actually is

Zustand is a small state library from the same people who make React Three Fiber. The whole idea: a store is a hook. You call create, hand it a function that returns your state and the functions that change it, and you get back a hook you use anywhere. No provider. No actions, dispatchers, or reducers as separate concepts. You can read the Zustand source and docs on GitHub in about the time it takes to finish a coffee, which was the first sign I was going to like it.

Here’s the same counter:

// store.js — Zustand
import { create } from 'zustand'

export const useCounter = create((set) => ({
  value: 0,
  increment: () => set((s) => ({ value: s.value + 1 })),
  addBy: (n) => set((s) => ({ value: s.value + n })),
}))

And the component:

import { useCounter } from './store'

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

No Provider. The state and the functions that change it live in the same place. The first time I wrote this I genuinely went back to check I hadn’t forgotten a setup step.

Zustand vs Redux: the differences that actually matter

People frame this as a size contest, and yes, Zustand is tiny while a full Redux setup is heavier. But bundle size isn’t why I switched. Three differences changed how I work day to day.

First, there’s no provider and no action layer. In Redux, changing state means dispatching an action that a reducer handles. That indirection is genuinely useful when you want a strict audit trail of every change. Most of my features don’t need that. They need to set a value and move on.

Second, Zustand stores live outside React. The store is a plain object you can import and read in a utility function, a test, or an event handler, without a hook. That sounds minor until you’ve tried to read Redux state outside a component and remembered you can’t really do that cleanly.

Third, Redux gives you something Zustand doesn’t: a strong convention. On a team of eight, “all state changes go through actions” is a rule you can enforce, and Redux DevTools time-travel debugging is genuinely great when a bug only shows up after a specific sequence of dispatches. Zustand has a devtools middleware too, but the discipline is on you. That’s the trade. Less structure means less friction and also less guardrail.

If you’re weighing this against plain Context, by the way, that’s a different question. Context is a dependency-injection tool, not a state manager, and using it for frequently-changing values re-renders everything under the provider. The React docs on useContext are clear that it isn’t built for high-frequency updates, which is exactly where a real store earns its place.

Selectors and slices: the part people get wrong

The mistake I see most with Zustand is people pulling the whole store into a component:

// re-renders on ANY store change, even unrelated ones
const store = useCounter()

Don’t do that. Select the exact slice you need, and the component only re-renders when that slice changes:

// re-renders only when `value` changes
const value = useCounter((s) => s.value)

When you need a few fields at once, reach for useShallow so you don’t trigger a re-render every time the store object identity changes:

import { useShallow } from 'zustand/react/shallow'

const { value, increment } = useCounter(
  useShallow((s) => ({ value: s.value, increment: s.increment })),
)

For bigger stores, split state into slices and compose them. This is the closest Zustand gets to Redux’s structure, and it scales further than people expect:

const createFishSlice = (set) => ({
  fishes: 0,
  addFish: () => set((s) => ({ fishes: s.fishes + 1 })),
})

const createBearSlice = (set) => ({
  bears: 0,
  addBear: () => set((s) => ({ bears: s.bears + 1 })),
})

export const useStore = create((...a) => ({
  ...createFishSlice(...a),
  ...createBearSlice(...a),
}))

The other thing I reach for constantly is the persist middleware. Saving state to localStorage in Redux usually means wiring up a subscriber or pulling in another package. In Zustand it’s a wrapper around the store you already wrote, and it just works:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useSettings = create(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    { name: 'app-settings' },
  ),
)

That’s the whole thing. Theme survives a refresh, and I didn’t have to think about serialization or hydration timing for the common case. The first time I needed persisted state in a Redux app it took me most of a morning and a library I later regretted adding.

One thing worth saying clearly: this is client state. For data that lives on a server, neither Zustand nor Redux is the right tool, and I wrote about why in my post on why I reach for TanStack Query instead of useEffect. Mixing the two, server cache in a global store, is how state management gets a bad name.

When I still reach for Redux

I want to push back on my own enthusiasm here, because “just use Zustand” is the kind of hype I usually complain about.

I still pick Redux when the app’s whole value is the state itself. Think a complex editor, a trading view, anything where you’ll want to replay actions, undo and redo across many entities, or hand a new engineer a predictable mental model on day one. The action log isn’t ceremony there. It’s the feature. Redux Toolkit’s RTK Query also folds data fetching into the same store, and if your team already lives in Redux, adding another library to learn is a real cost, not a footnote.

What I’ve stopped doing is reaching for Redux by default on every new project because it’s what I knew. That habit cost me a lot of boilerplate over the years for apps that were really just holding a handful of values. I dig into more of these “what I actually use” calls in my work and writing, since most of my opinions here came from getting it wrong first.

What to try this week

Pick one feature in your app that uses Redux for genuinely simple state: a theme toggle, a modal’s open or closed flag, a small filter. Rebuild just that one store in Zustand and wire up the two or three components that touch it. Don’t migrate the whole app, that’s a different decision with a real cost. Just get one slice working so the difference stops being abstract. An afternoon is plenty. By the end of it you’ll have a much clearer sense of which side of the line your project actually sits on, which beats any blog post, including this one.