State Management — Context API, Zustand, Jotai, Recoil
Context API Limitations and Use Cases
The Context API is React's built-in solution for sharing global state without additional packages. However, it has the following limitations.
Limitations:
- When a Context value changes, all components subscribed to that Context re-render.
- To handle multiple values, you need to nest multiple Providers.
- No DevTools support makes debugging difficult.
Suitable use cases:
- Values that don't change often, such as theme and language settings
- Authenticated user information
- UI state like modals and toasts
// Appropriate use of Context API
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
async function login(credentials) {
const userData = await apiLogin(credentials);
setUser(userData);
}
function logout() {
setUser(null);
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
Zustand
The simplest and lightest global state management library. Almost no boilerplate.
Installation
npm install zustand
Basic Usage
import { create } from 'zustand';
// Create store
const useCounterStore = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Use in component
function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
Selective Subscription (Optimization)
// ✅ Subscribe only to needed values — no re-render when other values change
function CountDisplay() {
const count = useCounterStore(state => state.count);
return <p>{count}</p>;
}
function CountActions() {
const increment = useCounterStore(state => state.increment);
return <button onClick={increment}>+</button>;
}
Async Actions
const useUserStore = create((set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const data = await fetch('/api/users').then(r => r.json());
set({ users: data, loading: false });
} catch (err) {
set({ error: err.message, loading: false });
}
},
addUser: (user) => {
set(state => ({ users: [...state.users, user] }));
},
removeUser: (id) => {
set(state => ({ users: state.users.filter(u => u.id !== id) }));
},
}));
Zustand + Immer (Easier Immutability)
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useStore = create(immer((set) => ({
todos: [],
addTodo: (text) => set(state => {
state.todos.push({ id: Date.now(), text, done: false });
}),
toggleTodo: (id) => set(state => {
const todo = state.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}),
})));
Jotai
Atomic State Management— manages state as small atomic units (atoms), subscribing only to the atoms you need. Inspired by Recoil but more concise.
Installation
npm install jotai
Basic Usage
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// Define atoms
const countAtom = atom(0);
const textAtom = atom('');
const darkModeAtom = atom(false);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
// Read-only (re-render optimization)
function CountDisplay() {
const count = useAtomValue(countAtom);
return <p>Current count: {count}</p>;
}
// Write-only (no re-render)
function IncrementButton() {
const setCount = useSetAtom(countAtom);
return <button onClick={() => setCount(c => c + 1)}>Increment</button>;
}
Derived Atoms
// Base atoms
const priceAtom = atom(10000);
const quantityAtom = atom(1);
// Derived atom (read-only)
const totalAtom = atom(get => get(priceAtom) * get(quantityAtom));
// Writable derived atom
const discountedTotalAtom = atom(
get => get(totalAtom) * 0.9, // Read: apply 10% discount
(get, set, discount) => { // Write: adjust quantity
const price = get(priceAtom);
set(quantityAtom, Math.floor(discount / price));
}
);
function PriceCalculator() {
const [price, setPrice] = useAtom(priceAtom);
const [quantity, setQuantity] = useAtom(quantityAtom);
const total = useAtomValue(totalAtom);
return (
<div>
<input type="number" value={price} onChange={e => setPrice(Number(e.target.value))} />
<input type="number" value={quantity} onChange={e => setQuantity(Number(e.target.value))} />
<p>Total: ${total.toLocaleString()}</p>
</div>
);
}
Recoil
A state management library developed by Facebook that maximizes React's Concurrent Mode capabilities.
Installation
npm install recoil
Basic Usage
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
// Add RecoilRoot at app root
function App() {
return (
<RecoilRoot>
<TodoApp />
</RecoilRoot>
);
}
// Define Atom
const todoListAtom = atom({
key: 'todoList', // Unique key (must be unique across the entire app)
default: [],
});
const filterAtom = atom({
key: 'todoFilter',
default: 'all', // 'all' | 'active' | 'completed'
});
// Selector (derived state)
const filteredTodosSelector = selector({
key: 'filteredTodos',
get: ({ get }) => {
const todos = get(todoListAtom);
const filter = get(filterAtom);
switch (filter) {
case 'active': return todos.filter(t => !t.done);
case 'completed': return todos.filter(t => t.done);
default: return todos;
}
},
});
// Usage
function TodoList() {
const filteredTodos = useRecoilValue(filteredTodosSelector);
const setTodos = useSetRecoilState(todoListAtom);
function toggleTodo(id) {
setTodos(todos => todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
));
}
return (
<ul>
{filteredTodos.map(todo => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.text}
</li>
))}
</ul>
);
}
Comparison of Three Libraries and Selection Criteria
| Item | Zustand | Jotai | Recoil |
|---|---|---|---|
| Bundle Size | ~1KB | ~3KB | ~21KB |
| Boilerplate | Very little | Little | Medium |
| Learning Curve | Low | Low | Medium |
| DevTools | Redux DevTools integration | Jotai DevTools | Recoil DevTools |
| Atomic Updates | ❌ | ✅ | ✅ |
| SSR Support | ✅ | ✅ | Limited |
| Maintenance | Pmndrs team | Pmndrs team | Meta |
| Recommended When | Simple, fast adoption | Fine-grained optimization | React ecosystem integration |
Selection Guide:
- Want quick adoption → Zustand
- Per-component re-render optimization is important → Jotai
- Many complex derived states → Recoil or Jotai
- Large team project → Zustand + Immer or Redux Toolkit
Practical Example: Shopping Cart (Zustand)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Cart store that automatically saves to localStorage
const useCartStore = create(
persist(
(set, get) => ({
items: [],
addItem: (product) => {
const items = get().items;
const existing = items.find(i => i.id === product.id);
if (existing) {
set({
items: items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
});
} else {
set({ items: [...items, { ...product, quantity: 1 }] });
}
},
removeItem: (id) =>
set(state => ({ items: state.items.filter(i => i.id !== id) })),
updateQuantity: (id, quantity) =>
set(state => ({
items: quantity <= 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, quantity } : i),
})),
clearCart: () => set({ items: [] }),
get total() {
return get().items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
get itemCount() {
return get().items.reduce((sum, i) => sum + i.quantity, 0);
},
}),
{ name: 'cart-storage' } // localStorage key
)
);
// Cart icon
function CartIcon() {
const itemCount = useCartStore(state => state.itemCount);
return (
<button>
🛒 <span>{itemCount}</span>
</button>
);
}
// Product card
function ProductCard({ product }) {
const addItem = useCartStore(state => state.addItem);
return (
<div>
<h3>{product.name}</h3>
<p>${product.price.toLocaleString()}</p>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
);
}
Pro Tips
1. Zustand's Shallow Comparison
import { shallow } from 'zustand/shallow';
// Use shallow comparison when selecting multiple values to prevent unnecessary re-renders
function UserInfo() {
const { name, email } = useUserStore(
state => ({ name: state.name, email: state.email }),
shallow
);
return <p>{name} ({email})</p>;
}
2. Store Separation Principle
Instead of a single massive store, separate stores by concern.
// ✅ Separated stores
const useAuthStore = create(...);
const useCartStore = create(...);
const useUIStore = create(...);
3. Delegate Server State to TanStack Query Instead of State Management
Data fetched from the server (user lists, product lists, etc.) is best managed with TanStack Query or SWR rather than state management libraries.