Choosing state management solution
Scope: Context, Zustand, Jotai, Redux Toolkit, when to use each, state patterns Lines: ~300 Last Updated: 2025-10-18
Activate this skill when:
Local State - Component-specific
const [count, setCount] = useState(0);
Shared State - Multiple components
const theme = useContext(ThemeContext);
Global State - Application-wide
const user = useStore(state => state.user);
Server State - Data from API
const { data: posts } = useSWR('/api/posts');
State needed by single component? → useState
↓
State needed by 2-3 nearby components? → Lift state up or useContext
↓
State needed across app (5+ components)? → Zustand or Jotai
↓
Complex state logic (reducers, middleware)? → Redux Toolkit
↓
Server data (API, caching, revalidation)? → SWR or React Query
| Library | Complexity | Bundle Size | DevTools | Best For | |---------|-----------|-------------|----------|----------| | Context | Low | 0kb (built-in) | No | Small apps, theming | | Zustand | Low | 1.2kb | Yes | Most apps, simple global state | | Jotai | Medium | 3kb | Yes | Atomic state, derived values | | Redux Toolkit | High | 8kb | Excellent | Large apps, complex logic |
// contexts/ThemeContext.tsx
import { createContext, useContext, useState } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<{
theme: Theme;
setTheme: (theme: Theme) => void;
} | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}
// Usage
function App() {
return (
<ThemeProvider>
<Header />
<Main />
</ThemeProvider>
);
}
function Header() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle: {theme}
</button>
);
}
// Split context to prevent unnecessary re-renders
const UserContext = createContext<User | null>(null);
const UserActionsContext = createContext<{
login: (email: string, password: string) => void;
logout: () => void;
} | null>(null);
function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// Memoize actions to prevent re-renders
const actions = useMemo(() => ({
login: async (email: string, password: string) => {
const user = await loginAPI(email, password);
setUser(user);
},
logout: () => setUser(null),
}), []);
return (
<UserContext.Provider value={user}>
<UserActionsContext.Provider value={actions}>
{children}
</UserActionsContext.Provider>
</UserContext.Provider>
);
}
// Components only re-render when user changes (not actions)
function UserProfile() {
const user = useContext(UserContext);
return <div>{user?.name}</div>;
}
// Components using actions don't re-render when user changes
function LogoutButton() {
const { logout } = useContext(UserActionsContext)!;
return <button onClick={logout}>Logout</button>;
}
When to use Context:
When NOT to use Context:
// stores/useStore.ts
import { create } from 'zustand';
interface Todo {
id: string;
title: string;
completed: boolean;
}
interface TodoStore {
todos: Todo[];
addTodo: (title: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
addTodo: (title) =>
set((state) => ({
todos: [...state.todos, { id: crypto.randomUUID(), title, completed: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
deleteTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
}));
// Usage
function TodoList() {
const todos = useTodoStore((state) => state.todos);
const toggleTodo = useTodoStore((state) => state.toggleTodo);
return (
<div>
{todos.map((todo) => (
<div key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.title} - {todo.completed ? '✓' : '○'}
</div>
))}
</div>
);
}
function AddTodo() {
const addTodo = useTodoStore((state) => state.addTodo);
return (
<button onClick={() => addTodo('New Todo')}>
Add Todo
</button>
);
}
Selector optimization - Component only re-renders when selected value changes:
// ❌ Re-renders on any store change
const store = useTodoStore();
// ✅ Only re-renders when todos change
const todos = useTodoStore((state) => state.todos);
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface Store {
nested: {
deeply: {
value: number;
};
};
increment: () => void;
}
const useStore = create<Store>()(
immer((set) => ({
nested: { deeply: { value: 0 } },
// Immer allows "mutation" syntax
increment: () =>
set((state) => {
state.nested.deeply.value += 1;
}),
}))
);
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthStore {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}),
{
name: 'auth-storage', // localStorage key
}
)
);
When to use Zustand:
// stores/atoms.ts
import { atom } from 'jotai';
// Primitive atoms
export const countAtom = atom(0);
export const userAtom = atom<User | null>(null);
// Derived atoms (computed)
export const doubleCountAtom = atom((get) => get(countAtom) * 2);
// Writable derived atoms
export const incrementCountAtom = atom(
(get) => get(countAtom),
(get, set) => set(countAtom, get(countAtom) + 1)
);
// Async atoms
export const postsAtom = atom(async () => {
const res = await fetch('/api/posts');
return res.json();
});
// Usage
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubleCount = useAtomValue(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
import { atomFamily } from 'jotai/utils';
// Create atoms dynamically by ID
const todoAtomFamily = atomFamily((id: string) =>
atom<Todo>({ id, title: '', completed: false })
);
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id));
return (
<div>
<input
value={todo.title}
onChange={(e) => setTodo({ ...todo, title: e.target.value })}
/>
</div>
);
}
import { atomWithStorage } from 'jotai/utils';
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
When to use Jotai:
// store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './todosSlice';
import userReducer from './userSlice';
export const store = configureStore({
reducer: {
todos: todosReducer,
user: userReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// store/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Todo {
id: string;
title: string;
completed: boolean;
}
interface TodosState {
items: Todo[];
loading: boolean;
}
const initialState: TodosState = {
items: [],
loading: false,
};
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.items.push({
id: crypto.randomUUID(),
title: action.payload,
completed: false,
});
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
deleteTodo: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
},
});
export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions;
export default todosSlice.reducer;
// store/userSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUser = createAsyncThunk('user/fetch', async (userId: string) => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
});
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
const userSlice = createSlice({
name: 'user',
initialState: { user: null, loading: false, error: null } as UserState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch user';
});
},
});
export default userSlice.reducer;
// hooks/redux.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from '../store/store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// Usage
function TodoList() {
const todos = useAppSelector((state) => state.todos.items);
const dispatch = useAppDispatch();
return (
<div>
{todos.map((todo) => (
<div key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
{todo.title}
</div>
))}
<button onClick={() => dispatch(addTodo('New Todo'))}>Add</button>
</div>
);
}
When to use Redux Toolkit:
// Zustand
const useStore = create<Store>((set) => ({
todos: [],
toggleTodo: async (id: string) => {
// Optimistic update
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
}));
try {
await fetch(`/api/todos/${id}/toggle`, { method: 'POST' });
} catch (error) {
// Revert on error
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
}));
}
},
}));
interface Store {
data: Data[];
loading: boolean;
error: string | null;
fetch: () => Promise<void>;
}
const useStore = create<Store>((set) => ({
data: [],
loading: false,
error: null,
fetch: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('/api/data');
const data = await res.json();
set({ data, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
}));
// Jotai - automatic memoization
const todosAtom = atom<Todo[]>([]);
const completedTodosAtom = atom((get) =>
get(todosAtom).filter((todo) => todo.completed)
);
const activeTodosAtom = atom((get) =>
get(todosAtom).filter((todo) => !todo.completed)
);
const statsAtom = atom((get) => {
const todos = get(todosAtom);
return {
total: todos.length,
completed: get(completedTodosAtom).length,
active: get(activeTodosAtom).length,
};
});
// Zustand - manual memoization
const useStore = create<Store>((set) => ({
todos: [],
get completedTodos() {
return this.todos.filter((todo) => todo.completed);
},
}));
// Before (Context)
const TodoContext = createContext<{
todos: Todo[];
addTodo: (title: string) => void;
} | null>(null);
function TodoProvider({ children }) {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (title: string) => setTodos([...todos, { title }]);
return <TodoContext.Provider value={{ todos, addTodo }}>{children}</TodoContext.Provider>;
}
// After (Zustand)
const useTodoStore = create<Store>((set) => ({
todos: [],
addTodo: (title) => set((state) => ({ todos: [...state.todos, { title }] })),
}));
// Replace useContext with store hook
const todos = useTodoStore((state) => state.todos);
const addTodo = useTodoStore((state) => state.addTodo);
Local component state → useState
2-3 components nearby → Lift state or Context
App-wide, simple → Zustand
Atomic, derived state → Jotai
Complex, large app → Redux Toolkit
Server data → SWR/React Query
// ❌ Bad: Entire store subscription
const store = useStore();
// ✅ Good: Selective subscription
const todos = useStore((state) => state.todos);
// ✅ Good: Multiple selectors
const addTodo = useStore((state) => state.addTodo);
const deleteTodo = useStore((state) => state.deleteTodo);
// ✅ Good: Memoized selector
const completedCount = useStore(
(state) => state.todos.filter((t) => t.completed).length
);
❌ Storing server state in global store: Use SWR/React Query instead ✅ Keep server state separate from client state
❌ Too much global state: Most state should be local ✅ Only globalize what needs to be shared
❌ Not splitting context: One context causes all consumers to re-render ✅ Split into multiple contexts (data + actions)
❌ Derived state not memoized: Recalculates on every render ✅ Use useMemo, Jotai derived atoms, or Zustand getters
Located in resources/scripts/:
``bash ./analyze_state.py ./src ./analyze_state.py ./src --json ``
``bash ./benchmark_renders.js ./benchmark_renders.js --depth 20 --updates 500 ./benchmark_renders.js --json ``
``bash ./detect_unnecessary_renders.js ./src ./detect_unnecessary_renders.js ./components --json ``
Located in resources/examples/typescript/:
react-component-patterns.md - useState, useContext, custom hooksreact-data-fetching.md - SWR, React Query for server statenextjs-app-router.md - Server Components (minimal client state)frontend-performance.md - Re-render optimizationLast Updated: 2025-10-27 Format Version: 1.0 (Atomic)