Skip to main content

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

ItemZustandJotaiRecoil
Bundle Size~1KB~3KB~21KB
BoilerplateVery littleLittleMedium
Learning CurveLowLowMedium
DevToolsRedux DevTools integrationJotai DevToolsRecoil DevTools
Atomic Updates
SSR SupportLimited
MaintenancePmndrs teamPmndrs teamMeta
Recommended WhenSimple, fast adoptionFine-grained optimizationReact 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.