On this page
State Management
Local useState and Context work for many apps. As complexity grows, dedicated state libraries provide better performance and developer experience.
When Context Isn’t Enough
- Frequent updates cause wide re-renders
- Deep component trees with prop drilling
- Complex state logic across many features
- Need for devtools and middleware
Zustand (Recommended for Most Apps)
Minimal API, no boilerplate:
npm install zustand
// store/useCartStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useCartStore = create(
persist(
(set, get) => ({
items: [],
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
)
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
}),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
clearCart: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0)
}),
{ name: 'cart-storage' }
)
);
Usage in components:
function CartButton() {
const itemCount = useCartStore(state =>
state.items.reduce((sum, i) => sum + i.quantity, 0)
);
return <button>Cart ({itemCount})</button>;
}
function AddToCartButton({ product }) {
const addItem = useCartStore(state => state.addItem);
return <button onClick={() => addItem(product)}>Add to Cart</button>;
}
Select only the state you need — components re-render only when that slice changes.
Redux Toolkit (Large Apps)
Structured, predictable, excellent devtools:
npm install @reduxjs/toolkit react-redux
// store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; },
incrementBy: (state, action) => { state.value += action.payload; }
}
});
export const { increment, decrement, incrementBy } = counterSlice.actions;
export default counterSlice.reducer;
import { useSelector, useDispatch } from 'react-redux';
import { increment } from './store/counterSlice';
function Counter() {
const count = useSelector(state => state.counter.value);
const dispatch = useDispatch();
return <button onClick={() => dispatch(increment())}>{count}</button>;
}
TanStack Query (Server State)
Separate server state from client state:
npm install @tanstack/react-query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json())
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return data.map(user => <div key={user.id}>{user.name}</div>);
}
function CreateUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser)
}).then(r => r.json()),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] })
});
return (
<button onClick={() => mutation.mutate({ name: 'New User' })}>
Add User
</button>
);
}
Choosing a Solution
| Tool | Best for |
|---|---|
| useState + Context | Small apps, theme, auth |
| Zustand | Medium apps, cart, UI state |
| Redux Toolkit | Large teams, complex client state |
| TanStack Query | API data, caching, sync |
| Jotai/Recoil | Atomic fine-grained state |
Use TanStack Query for server data and Zustand for client UI state — a powerful combination for most React apps.