The State Management Landscape in 2026
React's built-in tools — useState, useReducer, and the Context API — handle local and lightweight global state well. But when multiple unrelated components need to share frequently-updating state, or when you need time-travel debugging and strict action logging, a dedicated state management library becomes the right tool. The two dominant choices are Zustand (minimal, 1 KB) and Redux Toolkit (full-featured, ~50 KB). Choosing between them is a team and complexity trade-off, not a performance one.
Zustand: Simple Global State
Zustand is a small state management library built on a subscription model. You define a store as a function that receives a set callback and returns an object with state and actions. No boilerplate, no reducers, no action creators.
Basic Zustand Store
// store/useCartStore.ts
import { create } from 'zustand'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
interface CartStore {
items: CartItem[]
total: number
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
}
export const useCartStore = create<CartStore>()((set, get) => ({
items: [],
total: 0,
addItem: (item) => set((state) => {
const existing = state.items.find(i => i.id === item.id)
const items = existing
? state.items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i)
: [...state.items, { ...item, quantity: 1 }]
return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) }
}),
removeItem: (id) => set((state) => {
const items = state.items.filter(i => i.id !== id)
return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) }
}),
updateQuantity: (id, quantity) => set((state) => {
const items = state.items.map(i => i.id === id ? { ...i, quantity } : i)
return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) }
}),
clearCart: () => set({ items: [], total: 0 }),
}))
// Usage in any component — no Provider needed
function CartButton() {
const total = useCartStore(state => state.total) // Subscribes only to total
const addItem = useCartStore(state => state.addItem) // Stable function reference
return <button onClick={() => addItem({ id: '1', name: 'Widget', price: 9.99 })}>
Cart (${total.toFixed(2)})
</button>
}
Zustand Middleware: Persist and Immer
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
// Persist state to localStorage
export const useSettingsStore = create(
persist(
immer<SettingsStore>()((set) => ({
theme: 'dark',
language: 'en',
setTheme: (theme) => set((state) => { state.theme = theme }), // Immer: mutate directly
setLanguage: (lang) => set((state) => { state.language = lang }),
})),
{
name: 'app-settings', // localStorage key
partialize: (state) => ({ theme: state.theme, language: state.language }),
}
)
)
Redux Toolkit: Structured State with DevTools
Redux Toolkit (RTK) is the official, opinionated way to write Redux. It eliminates the boilerplate of classic Redux (action types, action creators, switch reducers) while keeping Redux's architecture: a single store, unidirectional data flow, and excellent DevTools for time-travel debugging.
RTK Slice
// store/cartSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
interface CartItem { id: string; name: string; price: number; quantity: number }
interface CartState { items: CartItem[]; status: 'idle' | 'loading' | 'failed' }
// Async thunk for API calls
export const fetchSavedCart = createAsyncThunk(
'cart/fetchSaved',
async (userId: string) => {
const response = await fetch(`/api/carts/${userId}`)
return response.json() as Promise<CartItem[]>
}
)
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], status: 'idle' } as CartState,
reducers: {
addItem(state, action: PayloadAction<Omit<CartItem, 'quantity'>>) {
// Immer is built into RTK — mutate directly
const existing = state.items.find(i => i.id === action.payload.id)
if (existing) {
existing.quantity += 1
} else {
state.items.push({ ...action.payload, quantity: 1 })
}
},
removeItem(state, action: PayloadAction<string>) {
state.items = state.items.filter(i => i.id !== action.payload)
},
},
extraReducers: (builder) => {
builder
.addCase(fetchSavedCart.pending, (state) => { state.status = 'loading' })
.addCase(fetchSavedCart.fulfilled, (state, action) => {
state.status = 'idle'
state.items = action.payload
})
.addCase(fetchSavedCart.rejected, (state) => { state.status = 'failed' })
},
})
export const { addItem, removeItem } = cartSlice.actions
export default cartSlice.reducer
// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from './cartSlice'
export const store = configureStore({
reducer: { cart: cartReducer }
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Using RTK in Components
// Wrap your app with Provider
// app/layout.tsx
import { Provider } from 'react-redux'
import { store } from '@/store'
export default function RootLayout({ children }) {
return <Provider store={store}>{children}</Provider>
}
// In a component
import { useSelector, useDispatch } from 'react-redux'
import { addItem, removeItem } from '@/store/cartSlice'
import type { RootState, AppDispatch } from '@/store'
function CartComponent() {
const items = useSelector((state: RootState) => state.cart.items)
const dispatch = useDispatch<AppDispatch>()
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} × {item.quantity}
<button onClick={() => dispatch(removeItem(item.id))}>Remove</button>
</li>
))}
</ul>
)
}
When to Use Which
| Criterion | Zustand | Redux Toolkit |
|---|---|---|
| Bundle size | ~1 KB | ~50 KB |
| Boilerplate | Minimal | Moderate (slices) |
| DevTools | Redux DevTools extension (opt-in) | Redux DevTools (built-in) |
| Team size | Small to medium | Medium to large |
| Async data fetching | Manual (use fetch/SWR) | RTK Query (built-in) |
| Time-travel debugging | Via devtools middleware | First-class |
| Learning curve | 30 minutes | 1–2 days |
| Best for | UI state, preferences, modals | Complex business logic, large teams |
Performance: Selector Optimization
Both libraries re-render a component only when the selected slice of state changes. The key is selecting the smallest possible slice:
// Zustand — select individual fields (each is its own subscription)
const itemCount = useCartStore(state => state.items.length) // Only re-renders when count changes
const total = useCartStore(state => state.total) // Only re-renders when total changes
// Avoid selecting the whole object — re-renders on any state change
const { items, total } = useCartStore() // 🐌 Worse: re-renders whenever anything changes
// For computed values from multiple fields, use useShallow (Zustand 4+)
import { useShallow } from 'zustand/react/shallow'
const { name, email } = useUserStore(useShallow(state => ({ name: state.name, email: state.email })))
// RTK — use createSelector for memoized derived state
import { createSelector } from '@reduxjs/toolkit'
const selectItemCount = createSelector(
(state: RootState) => state.cart.items,
(items) => items.reduce((sum, i) => sum + i.quantity, 0)
)
// selectItemCount only recomputes when items array reference changes