Confession: I once shipped a Redux Toolkit slice for a single feature-flag dropdown. One slice, one boolean, three files. The moment I caught myself writing a createAsyncThunk to fetch a 6KB JSON config, I realized the project had drifted somewhere strange. Redux is a great tool. It was the wrong tool for that.
So in early 2025 I started replacing Redux Toolkit with Zustand in my smaller React apps. Six months in, I’ve finished the migration on two production apps and decided not to do it on a third. This post is the version of that conversation I wish someone had handed me at the start, with code, opinions, and a couple of things I got wrong along the way.
If you want the short version: Zustand fits about 80% of the React apps I work on better than Redux Toolkit does. The other 20% genuinely need Redux. Knowing the difference saved me a lot of weekends.
Why I wanted to move off Redux Toolkit
I like Redux Toolkit. The team behind it shipped a real improvement over classic Redux, and RTK Query is excellent if you want a single library for both UI state and server state. But here’s what I kept running into on small-to-medium React apps.
The boilerplate is still too much for what most apps actually need. Every piece of state needs a slice. Every slice has a reducer and an action, plus usually a selector to read it back. Then you wire them through a store and a Provider. For an app with five screens and one auth flow, that’s a lot of ceremony just to read a logged-in user.
The mental model also pulls everything into one global place. That’s by design: single source of truth, predictable updates, time-travel debugging when you need it. Useful when you have it. But for a marketing site with a checkout flow, I rarely need to time-travel through state. I need a small piece of shared state that two components can read.
Then there’s bundle size. Redux Toolkit pulls in Redux, Reselect, Immer, and the toolkit helpers. The minified gzipped weight is around 12 to 13 KB depending on what you import. Zustand is closer to 1 KB. On a hot landing page that delta is real.
I’d been reading Mark Erikson’s commentary on when Redux is and isn’t the right call, and his answer is honest: most small apps don’t need it. So I took his advice seriously.
What Zustand actually looks like
Here’s the simplest auth store I have in production. Annotated for the unfamiliar.
import { create } from 'zustand'
type User = { id: string; email: string }
type AuthState = {
user: User | null
setUser: (user: User | null) => void
signOut: () => void
}
export const useAuth = create<AuthState>((set) => ({
user: null,
setUser: (user) => set({ user }),
signOut: () => set({ user: null }),
}))
That’s the whole store. No Provider in the React tree. No reducer. To use it in a component:
function Header() {
const user = useAuth((s) => s.user)
const signOut = useAuth((s) => s.signOut)
if (!user) return <SignInButton />
return (
<div>
<span>{user.email}</span>
<button onClick={signOut}>Sign out</button>
</div>
)
}
The selector argument matters. useAuth((s) => s.user) only re-renders when user changes. If a different component changes some unrelated piece of state, this header doesn’t re-render. That’s the same shallow-equality behavior Redux’s useSelector gives you, without the surrounding ceremony.
For comparison, the Redux Toolkit version of the same thing involves a slice file, a typed useAppSelector hook, a typed useAppDispatch hook, an action creator, and a Provider in the root. It works. It’s also a lot of files for the same outcome.
Three patterns I actually use
After enough rewrites, I settled on a few patterns that cover most of what I need.
The first is slice your store by feature, not by type. Don’t make one giant store and one giant useAppStore. Make a useAuth, a useCart, a useFilters. Keep them small and named after the feature they belong to. You can read multiple stores in one component without any setup. This is the opposite of how I used Redux, where everything lived in one combined reducer.
The second is persist only what you need. Zustand’s persist middleware is great, but I used to wrap entire stores with it. Then I’d save things I never actually needed across reloads, like a UI panel’s open state. Now I only persist user-survival state, like auth tokens or theme preference. Anything else gets recomputed on load.
import { persist } from 'zustand/middleware'
export const useTheme = create(
persist<ThemeState>(
(set) => ({
mode: 'system',
setMode: (mode) => set({ mode }),
}),
{ name: 'theme' }
)
)
The third is derive, don’t store. I keep getting tempted to add isAdmin or cartTotal as state. Don’t. Compute them with a selector. They’re cheap and they can never be out of sync. No extra setter to call from three different places either.
const isAdmin = useAuth((s) => s.user?.role === 'admin')
const total = useCart((s) => s.items.reduce((sum, i) => sum + i.price, 0))
If the computation gets expensive, wrap it in useMemo at the call site, or use useShallow to avoid re-rendering on object identity changes. I almost never need that in practice.
When Redux Toolkit still wins
I want to be fair. Two months ago I was about to replace Redux Toolkit on a third app and I stopped. Here’s why.
It was a B2B internal tool with around 40 screens, plus complex permissions stitched into a workflow engine. The state had cross-feature dependencies everywhere. Approving a record changed the visible columns in a different table. Saving a draft updated two sidebars at once. The team needed deterministic, replayable state with strong dev tools.
That’s exactly what Redux is for. Specifically, Redux DevTools time-travel is unmatched when you’re debugging “wait, why did the third sidebar update?” An entire app’s reducer history is a luxury that Zustand doesn’t try to replicate. You can wire something close with the devtools middleware, but it’s not the same.
A few other places I’d reach for Redux Toolkit over Zustand:
When the team is large and you want clear conventions. RTK is opinionated; Zustand lets you do whatever, which is great until two engineers disagree about where the cart total should live. Conventions reduce arguments.
When you already use RTK Query for data fetching and don’t want to mix TanStack Query in. RTK Query is a solid integrated experience, and replacing it with TanStack Query plus Zustand is two libraries instead of one.
When you genuinely need a single normalized client cache shared across many features. RTK’s createEntityAdapter is good at this. Zustand has no equivalent, so you’d write it yourself.
I covered a related tradeoff in my post on when React hooks bite back; the same logic applies here. Reach for the simpler tool until simplicity stops paying.
A few things I got wrong
A short list of mistakes, so you don’t have to make them.
I used to put everything in a single Zustand store because “isn’t that the whole point of state management?” No. Zustand is happiest when stores are small and feature-scoped. If you find yourself selecting from the same store in 30 different components for unrelated reasons, you’ve rebuilt Redux without the dev tools.
I also tried to use Zustand for server state. Don’t. Server state is data you fetched from an API; client state is UI you’re holding in memory. The boundary matters. Server state belongs in TanStack Query, RTK Query, or something that handles caching and revalidation for you. Zustand handles the slice that says “the user dismissed this banner.” Mixing them turns into a maintenance pile fast.
The other thing I underestimated: how often I’d want a typed action that updates multiple stores. In Redux that’s a thunk dispatching to multiple slices. In Zustand it’s a function that calls useAuth.getState() and useCart.getState() directly. It works fine, but it’s worth having a lib/actions.ts file where these cross-store actions live, so they don’t get buried in a component somewhere.
Try this next week
If you’re on Redux Toolkit and feel like the boilerplate-to-feature ratio is off, pick one slice. The smallest one, ideally a UI flag like “is the sidebar open.” Rewrite it as a Zustand store. Delete the slice, the action, the selector, and the dispatch wiring. Keep the same component code. Run your tests.
That single PR will tell you almost everything you need to know. Either it slots in cleanly and you’ll start eyeing other slices, or it feels weirdly under-structured and you’ll know Redux is the right home for this codebase. Both answers are useful.
If Zustand fits, my open-source side projects all use it now, with TanStack Query for server state. That combination has been my React default for the last six months and I haven’t regretted it.