Why I Stopped Using Redux for New Projects
About eight months ago, I inherited a mid-sized React dashboard running Redux Toolkit. It worked fine — state was predictable, the DevTools were excellent, and the team knew the patterns. But every time we needed a new feature, even something as simple as tracking a modal’s open state, we’d end up writing a slice, adding actions, wiring up selectors, and touching at least four files. For a global loading flag.
That friction pushed me to look at alternatives. After testing Jotai, Recoil, and Zustand on side projects, I migrated one of our internal tools to Zustand. Six months later, it’s still running in production with zero state-related incidents. Here’s what I learned.
Redux vs Zustand: Comparing the Approaches
At the core, both libraries solve the same problem — sharing state across components without prop drilling. The difference is how much ceremony they require to get there.
Redux (with Redux Toolkit)
Redux enforces a strict unidirectional data flow. You define slices with reducers and actions, dispatch those actions from components, and read state via selectors. Redux Toolkit cuts the boilerplate significantly over vanilla Redux, but the mental model still requires understanding actions, reducers, the store, and the dispatch cycle.
On large teams with complex async flows, that structure pays off. The predictability and DevTools support are hard to match. On smaller teams, though — or for features where the overhead isn’t justified — it’s like wearing full plate armor to go grocery shopping.
Zustand
Zustand throws that model out entirely. You create a store as a plain JavaScript object — state and actions live together. Components subscribe with a hook and only re-render when the specific piece of state they use changes.
No Provider. No dispatch. No action types. You call functions. It feels closer to managing state with useState and useContext, minus the performance pitfalls that come with context re-renders across a large component tree.
Pros and Cons After Real Production Use
Zustand Advantages
- Minimal boilerplate: A complete store with async actions fits in under 30 lines. With Redux Toolkit, you’re looking at a slice file, async thunks, and selector definitions spread across multiple locations.
- No Provider needed: The store lives at module level. Import and use it anywhere — utility functions, event handlers, even Node.js test environments — without wrapping your app in anything.
- Selective subscriptions: Components only re-render when the specific state they subscribe to changes. No memoization gymnastics required. This alone fixed two performance regressions we had in our Redux dashboard.
- TypeScript that doesn’t fight you: Typing a Zustand store is straightforward. Redux generic types can spiral into real complexity, especially with nested state shapes.
- Tiny bundle: Zustand is ~1KB gzipped. Redux Toolkit weighs in at ~11KB. Not a dealbreaker on its own, but it adds up.
Zustand Limitations
- DevTools aren’t automatic: You add the
devtoolsmiddleware manually. Redux DevTools integration is more polished out of the box — action history, diff views, all of it. - No enforced structure: The freedom is also a trap. Without team conventions, stores can turn into junk drawers as the codebase grows.
- Time-travel debugging: Redux still wins here. Zustand supports it via the devtools middleware, but the experience is less mature.
- Smaller middleware ecosystem: Redux has deep middleware support — logging, persistence, saga-based orchestration. Zustand has
persistandimmer, which cover most cases, but the ecosystem is thinner.
Recommended Setup for a Production Project
Six months in, this is the setup that’s held up cleanest across three different projects.
Installation
npm install zustand
# Optional but recommended for immutable updates:
npm install immer
Folder Structure
Put stores in a dedicated store/ directory. One file per domain concept. Don’t dump everything into a single store file — that’s how you end up with 400-line monsters six months later.
src/
store/
useAuthStore.ts
useCartStore.ts
useNotificationStore.ts
Enabling DevTools
Always wrap your store creator with the devtools middleware in development. You’ll thank yourself the first time you’re hunting down a subtle state bug:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useAuthStore = create(devtools((set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
})))
export default useAuthStore
Implementation Guide: From Basic to Async
Basic Store
Start here — a counter. Trivial? Yes. But it shows the full pattern cleanly before async and middleware enter the picture:
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
And in a component:
import { useCounterStore } from '../store/useCounterStore'
export function Counter() {
const { count, increment, decrement } = useCounterStore()
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
)
}
Async Actions and API Calls
No middleware needed for async. Actions are just functions, so async/await works directly:
import { create } from 'zustand'
interface User {
id: number
name: string
email: string
}
interface UserState {
users: User[]
isLoading: boolean
error: string | null
fetchUsers: () => Promise<void>
}
export const useUserStore = create<UserState>((set) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true, error: null })
try {
const res = await fetch('/api/users')
const data = await res.json()
set({ users: data, isLoading: false })
} catch (err) {
set({ error: 'Failed to fetch users', isLoading: false })
}
},
}))
Selective Subscriptions for Performance
Extract only what you need from the store. Each component re-renders only when its specific slice changes — not when any part of the store changes:
// Re-renders only when `isLoading` changes
function LoadingIndicator() {
const isLoading = useUserStore((state) => state.isLoading)
return isLoading ? <Spinner /> : null
}
// Re-renders only when `users` changes
function UserList() {
const users = useUserStore((state) => state.users)
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
Persisting State Across Page Reloads
The built-in persist middleware handles syncing to localStorage or sessionStorage. Theme preference is the classic use case:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ThemeState {
theme: 'light' | 'dark'
toggleTheme: () => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{ name: 'theme-storage' } // localStorage key
)
)
Reading Store State Outside React
One of my most-used patterns — accessing store state in axios interceptors or utility functions without hooks:
// Attach auth token to every outgoing request
import { useAuthStore } from '../store/useAuthStore'
axios.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
When to Still Choose Redux
Zustand isn’t a drop-in replacement for every Redux use case. On large-scale apps with 10+ developers, Redux Toolkit’s enforced conventions and mature DevTools still earn their overhead. If you’re orchestrating multi-step async flows with Redux-Saga — think checkout pipelines, multi-stage form submissions, complex retry logic — the Redux middleware ecosystem goes deeper.
For everything else — SPAs, dashboards, internal tools, most customer-facing apps under a few hundred thousand lines — Zustand removes friction without removing capability. Six months in production, three projects, zero state-related incidents. Fewer files touched per feature. Faster onboarding for new teammates. Same reliability.
Start small. Migrate one feature slice. See how it feels after a week. That’s usually enough to make the decision obvious.

