Zustand vs Redux Toolkit: State Management for Modern React Apps
Redux Toolkit fixed most of Redux's problems. Zustand is simpler by design.
Here's when to use each.
The Problem Both Solve
Client-side global state: user auth, UI state, cached server data that multiple components need.
Zustand: Minimal Boilerplate
import { create } from 'zustand'
interface AuthStore {
user: User | null
token: string | null
login: (user: User, token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}))
// Usage in any component
function Header() {
const { user, logout } = useAuthStore()
return user ? <button onClick={logout}>{user.name}</button> : <LoginButton />
}
That's the entire store. No actions, reducers, or providers.
Redux Toolkit: Explicit and Powerful
import { createSlice, PayloadAction, configureStore } from '@reduxjs/toolkit'
interface AuthState {
user: User | null
token: string | null
status: 'idle' | 'loading' | 'failed'
}
const authSlice = createSlice({
name: 'auth',
initialState: { user: null, token: null, status: 'idle' } as AuthState,
reducers: {
loginSuccess: (state, action: PayloadAction<{ user: User; token: string }>) => {
state.user = action.payload.user
state.token = action.payload.token
},
logout: (state) => {
state.user = null
state.token = null
},
},
})
export const { loginSuccess, logout } = authSlice.actions
export const store = configureStore({
reducer: { auth: authSlice.reducer },
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
More code, but more structure.
Async Operations
Zustand:
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
loading: false,
error: null,
login: async (email: string, password: string) => {
set({ loading: true, error: null })
try {
const { user, token } = await api.login(email, password)
set({ user, token, loading: false })
} catch (err) {
set({ error: err.message, loading: false })
}
},
}))
Redux Toolkit (createAsyncThunk):
export const loginUser = createAsyncThunk(
'auth/login',
async ({ email, password }: LoginParams) => {
const { user, token } = await api.login(email, password)
return { user, token }
}
)
const authSlice = createSlice({
// ...
extraReducers: (builder) => {
builder
.addCase(loginUser.pending, (state) => { state.status = 'loading' })
.addCase(loginUser.fulfilled, (state, action) => {
state.user = action.payload.user
state.token = action.payload.token
state.status = 'idle'
})
.addCase(loginUser.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message
})
},
})
RTK automatically handles the loading/success/error lifecycle.
Persistence
Zustand with localStorage:
import { persist } from 'zustand/middleware'
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({ /* ... */ }),
{ name: 'auth-storage' }
)
)
Done in 5 lines. RTK requires redux-persist and more configuration.
DevTools
Both integrate with Redux DevTools:
// Zustand
import { devtools } from 'zustand/middleware'
const useStore = create(devtools((set) => ({ /* ... */ })))
// RTK — built in automatically
const store = configureStore({ reducer: { /* ... */ } })
When to Choose Each
Choose Zustand when:
- You want minimal boilerplate
- Your state is relatively flat
- Small-to-medium app
- You want zero provider setup
Choose Redux Toolkit when:
- Large team with shared state conventions
- Complex async flows with many loading states
- You need time-travel debugging heavily
- Existing Redux codebase migration
My Recommendation
New projects: Zustand for local state + React Query for server state.
This combination covers 95% of what Redux was used for, with a fraction of the code.
The AI SaaS Starter Kit ships with Zustand pre-configured for auth state and React Query for all server data. $99 one-time — no wiring required.
Top comments (0)