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 and Drizzle ORM vs Prisma. 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:
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:
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:
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.reducerThe store and typed hooks:
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:
import { Provider } from 'react-redux'
import { store } from './store'
export function Root() {
return (
<Provider store={store}>
<App />
</Provider>
)
}And finally the component:
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":
const { value, name } = useStore((state) => ({ value: state.value, name: state.name }))The fix is useShallow, which compares the result shallowly:
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:
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:
features/
profile/
Profile.tsx
Profile.store.ts
theming/
Theming.tsx
Theming.store.tsZustand 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 for more details, and I'll include the Bored 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.
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.