# Zustand vs Redux: Why I Switched (and When Not To)

A few days into rewriting the apps from my portfolio, I tried Zustand and it felt refreshing after Redux, MobX and the Context API. Here is the Zustand vs Redux Toolkit comparison with code side by side: boilerplate, performance, devtools, team scale, and when Redux still wins.

Tags: react, javascript, typescript
Published: 2023-06-07 (updated 2026-10-22)


Hi, guys!

A few days ago, I started rewriting the apps from my portfolio. For now, I have only rewritten one, but the others will follow soon. You might wonder why I'm doing that. Well, there are a couple of reasons. The first reason is that I love to try new things - new technologies, new tricks, etc. A second reason might be the Bored application I wrote some time ago. More precisely, it was a couple of years ago. It's so old that I wrote it in React using class-based components - just imagine that.

> **Zustand vs Redux, the short answer:** Use Zustand for most React apps. It needs one function call, no provider and almost no boilerplate. Use Redux Toolkit when a large team needs enforced structure, strict conventions, time-travel debugging, or built-in data fetching with RTK Query. Both handle performance well if you select state carefully.

Throughout my five years of working and eight years of learning, I have come across many state management systems. But the state management systems I have worked with include Redux, Context API, and MobX. If you're curious how this fits alongside other "I tried X" reviews, I've also written about [Hono.js after years of Express](/blog/honojs-vs-express-2026/) and [Drizzle ORM vs Prisma](/blog/drizzle-orm-vs-prisma-2026/). I have also briefly examined Jotai, but I haven't tried it yet - though I plan to.

MobX: I worked with MobX in a legacy project. The project was based on class-based components, and even though I still understand quite well how to work on this type of project, the MobX state manager seems to me not very developer-friendly. There was a lot of configuration and boiler-plating required to add new functionality.

Context API: It's suitable for small projects like this Bored app I have. However, for large projects, I don't think it's the best choice. You need to create a context/store for each component because if you don't, you'll experience numerous unnecessary re-renders, leading to poor performance.

Redux: Redux is excellent, and I'm thrilled with it. A few years ago, Redux Toolkit appeared, and it significantly reduced the complexity of setup. However, there's still a bit of boilerplate to do - not too much, but not too little.

Then I tried Zustand, and it seemed very refreshing and straightforward. This post is the Zustand vs Redux comparison I wish I had read before I started, rebuilt with current code for Zustand v5 and Redux Toolkit.

---

## Zustand vs Redux Toolkit at a Glance

| | Zustand | Redux Toolkit |
|---|---|---|
| **Setup** | `create()` returns a hook | `configureStore`, slices, `Provider`, typed hooks |
| **Provider needed** | No | Yes |
| **Updating state** | `set()` in the store | Reducers in slices, dispatched as actions |
| **Immutability** | You return new objects (or add the `immer` middleware) | Immer built in, "mutating" syntax in reducers |
| **Devtools** | `devtools` middleware | Set up automatically by `configureStore` |
| **Async and data fetching** | Async functions in the store | `createAsyncThunk` and RTK Query |
| **Structure** | Your choice | Enforced by convention |
| **Best for** | Small to mid-size apps, fast iteration | Large teams, strict patterns |

---

## The Same Store in Zustand and Redux Toolkit

Here is a counter with a typed payload, the same behavior in both. First Zustand, which is the whole thing, in one file:

```ts
import { create } from 'zustand'

type CounterState = {
  value: number
  increment: () => void
  decrement: () => void
  incrementByAmount: (amount: number) => void
}

export const useCounterStore = create<CounterState>()((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 })),
}))
```

Using it in a component, with no provider anywhere:

```tsx
import { useCounterStore } from './counterStore'

export function Counter() {
  const value = useCounterStore((state) => state.value)
  const increment = useCounterStore((state) => state.increment)

  return <button onClick={increment}>{value}</button>
}
```

Now Redux Toolkit, based on the official TypeScript quick start. The slice:

```ts
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'

type 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
```

The store and typed hooks:

```ts
import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector } from 'react-redux'

import counterReducer from './counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
```

Wrap the app in a provider:

```tsx
import { Provider } from 'react-redux'

import { store } from './store'

export function Root() {
  return (
    <Provider store={store}>
      <App />
    </Provider>
  )
}
```

And finally the component:

```tsx
import { useAppDispatch, useAppSelector } from './store'
import { increment } from './counterSlice'

export function Counter() {
  const value = useAppSelector((state) => state.counter.value)
  const dispatch = useAppDispatch()

  return <button onClick={() => dispatch(increment())}>{value}</button>
}
```

Same behavior. Zustand is one file and one concept: a store that is also a hook. Redux Toolkit is four pieces, plus the concepts of actions, reducers and dispatch. That is what I meant by "not too much, but not too little" boilerplate in Redux. Redux Toolkit already cut most of the old Redux ceremony, and the Redux docs are fair about that: it is the official recommended way to write Redux, and Immer lets you write `state.value += 1` safely.

The Redux version is not wrong. The extra pieces are structure, and structure pays off at scale. For a small app it is overhead you carry from day one.

---

## Performance: Selectors, Re-renders and the v5 Gotcha

Both libraries avoid unnecessary re-renders the same way: components subscribe to a slice of state with a selector, and only re-render when that slice changes. In the examples above, `Counter` re-renders when `value` changes and not when something else in the store does.

Zustand v5 has one gotcha that bites people moving from v4. A selector that returns a new object or array on every call makes the component think the state changed every time, and React throws "Maximum update depth exceeded":

```tsx
const { value, name } = useStore((state) => ({ value: state.value, name: state.name }))
```

The fix is `useShallow`, which compares the result shallowly:

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

const { value, name } = useStore(
  useShallow((state) => ({ value: state.value, name: state.name })),
)
```

Or select each value with its own selector, which is simpler and what I do by default. Redux has the same class of problem. Selectors that build new arrays or objects re-render on every store update, and the standard fix is memoized selectors with `createSelector`.

The other v5 changes to know: it needs React 18 or newer, and the default export is gone, so use `import { create } from 'zustand'`. If you were using `create` with a custom equality function, the migration guide points you to `createWithEqualityFn` from `zustand/traditional` or to `useShallow`.

---

## Devtools and Debugging

Redux wins this one, though not by a lot. `configureStore` sets up the Redux DevTools extension for you, so you get the full action history and state diffs with no setup. Redux also has dev-time checks for accidental mutation, which the docs call the number one cause of Redux bugs.

Zustand gets the same extension through middleware:

```ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

type CounterState = {
  value: number
  increment: () => void
}

export const useCounterStore = create<CounterState>()(
  devtools((set) => ({
    value: 0,
    increment: () => set((state) => ({ value: state.value + 1 }), undefined, 'counter/increment'),
  })),
)
```

You see the state changes in the same tool. The difference is that you opt in, and you name actions yourself (the third argument to `set`), where Redux names them from the slice for free. Persisting to `localStorage` is also one middleware, `persist` from `zustand/middleware`, with a `name` for the storage key.

---

## Async Code and Data Fetching

In Zustand an async action is just an async function in the store that calls `set` when it finishes. That is enough for simple cases and easy to read.

Redux Toolkit has more built in: `createAsyncThunk` for the request lifecycle, `createListenerMiddleware` for reacting to actions, and RTK Query, an optional data fetching layer that generates hooks like `useGetPokemonQuery('pikachu')` and handles caching for you. If your app is mostly server data, RTK Query is a real reason to pick Redux. The other path is to keep server data in a dedicated fetching library and use Zustand only for client state like UI flags, filters and preferences. That split is what I would do for a new app.

---

## Team Scale and Structure

This is where Redux still wins, and it is not about performance. Redux's constraints are the point. Every state change is an action, every reducer lives in a slice, and a new developer can read the action log to see what happened. On a large team, that predictability is worth the boilerplate.

Zustand gives you freedom, and freedom needs discipline. In the Bored app I only have two pieces of state, one for dark mode and one for the activity, so I put them in one file. But I believe it is a good pattern to declare a store for each feature. If an app has a user and a theme, I would do this:

```text
features/
  profile/
    Profile.tsx
    Profile.store.ts
  theming/
    Theming.tsx
    Theming.store.ts
```

Zustand can also follow a slice pattern when one store grows large, which is close to how Redux organizes things. If your team will not stick to a convention on its own, that is the argument for Redux.

---

## When to Choose Zustand, and When Redux Still Wins

**Choose Zustand when:**
- The app is small to mid-size and you want to move fast
- You want global state without wrapping the app in a provider
- You are a solo developer or a small team that can agree on a structure
- Most of your state is client state, with server data handled elsewhere

**Choose Redux Toolkit when:**
- A large team needs enforced structure and a visible action history
- You want RTK Query for data fetching and caching in the same toolchain
- You already have a Redux codebase that works (do not rewrite it for fun)
- You rely on the Redux DevTools workflow and time-travel debugging every day

**Skip both when:** the state is small and local. React's `useState` and `useReducer` cover a lot, and the Context API suits a small app like my Bored project. For bigger apps I would not rely on Context alone, because without a context per concern you get the unnecessary re-renders I described above.

---

## My Take

I started moving my portfolio apps to Zustand because the Redux boilerplate was not buying me much on projects this size. I would not tell a large team to leave Redux Toolkit. I would tell them the same thing I would tell a solo developer: pick the one that matches how many people touch the code.

Zustand offers much more than I have covered here. You can check the [documentation](https://github.com/pmndrs/zustand) for more details, and I'll include the [Bored](https://github.com/alexcloudstar/bored-v2) application and GitHub links for the repo if you're interested in taking a look.

If you're weighing up where the React ecosystem is going, you might also enjoy my piece on [Lynx vs React Native vs Flutter](/blog/lynx-vs-react-native-vs-flutter-tiktoks-new-framework-in-perspective/).

---

## Frequently Asked

**Is Zustand better than Redux?** For most small to mid-size React apps, yes: less boilerplate, no provider and one file per store. Redux Toolkit is better for large teams that want enforced structure, an action log and RTK Query.

**Is Zustand faster than Redux?** Neither is slow. Both re-render a component only when its selected state changes. Performance depends more on how you write selectors than on which library you pick.

**Why did I switch from Redux to Zustand?** Redux Toolkit cut most of the old boilerplate, but there was still some left, and it felt like more than a small app like my Bored project needed. Zustand needed one store definition and a hook.

**Does Zustand have devtools?** Yes. Add the `devtools` middleware from `zustand/middleware` and the store appears in the Redux DevTools extension. Redux Toolkit sets it up automatically.

**What changed in Zustand v5?** It needs React 18 or newer, the default export was removed, and selectors that return a new object or array on each call now fail with an infinite loop error. Wrap them in `useShallow` or select values one at a time.

**Should I rewrite my Redux app in Zustand?** Usually not. If Redux Toolkit works for your team, the rewrite costs more than it saves. Use Zustand for new projects or new features instead.
