The options landscape
| Library | Bundle size | Boilerplate | DevTools | Re-render control | Best for |
|---|---|---|---|---|---|
| Context API | 0kb (built-in) | Minimal | None | Poor | Low-frequency state, theme, auth |
| Zustand | ~1kb | Minimal | Redux DevTools | Excellent (selector-based) | Most app state |
| Redux Toolkit | ~11kb | Moderate | Excellent | Good (with selectors) | Large teams, complex async, strict patterns |
| Jotai / Recoil | ~3-6kb | Low | Good | Excellent (atomic) | Fine-grained, derived state |
Context API โ what it is and what itโs not
Context is a dependency injection mechanism, not a state management library. It lets you pass values through the component tree without prop drilling.
The critical performance issue:
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark');
const [fontSize, setFontSize] = useState(16);
// Every re-render of ThemeProvider creates a new context value object
// โ ALL consumers re-render, even if only fontSize changed
return (
<ThemeContext.Provider value={{ theme, setTheme, fontSize, setFontSize }}>
{children}
</ThemeContext.Provider>
);
}
Any setState in the Provider causes a new value object, which triggers every consumer to re-render โ regardless of whether the piece of state they care about changed.
Fix 1: Split contexts
// Separate contexts for separate concerns
const ThemeValueContext = createContext(null); // reads
const ThemeActionContext = createContext(null); // setters (never changes)
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark');
const actions = useMemo(() => ({ setTheme }), []); // stable reference
return (
<ThemeActionContext.Provider value={actions}>
<ThemeValueContext.Provider value={theme}>
{children}
</ThemeValueContext.Provider>
</ThemeActionContext.Provider>
);
}
Fix 2: Memoize the value object
const value = useMemo(() => ({ theme, fontSize }), [theme, fontSize]);
// Still re-renders all consumers when either changes โ better, but not granular
Use Context for: auth state, theme, locale, current user โ things that are genuinely global, change infrequently, and are consumed across the entire tree.
Redux Toolkit (RTK)
RTK is the official modern way to write Redux. It eliminates the boilerplate of hand-writing action types, action creators, and immutable reducers.
import { createSlice, configureStore, createAsyncThunk } from '@reduxjs/toolkit';
// createSlice: defines state + reducers + auto-generates action creators
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], status: 'idle', error: null },
reducers: {
addPost(state, action) {
state.items.push(action.payload); // Immer allows "mutations" โ produces new state
},
removePost(state, action) {
state.items = state.items.filter(p => p.id !== action.payload);
},
},
extraReducers(builder) {
builder
.addCase(fetchPosts.pending, (state) => { state.status = 'loading'; })
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
// Async thunk โ standardized async action
const fetchPosts = createAsyncThunk('posts/fetchAll', async (userId) => {
const res = await fetch(`/api/users/${userId}/posts`);
return res.json();
});
// Store setup
const store = configureStore({
reducer: { posts: postsSlice.reducer },
});
// Auto-generated actions
const { addPost, removePost } = postsSlice.actions;
// In components โ use typed hooks
const posts = useSelector(state => state.posts.items);
const status = useSelector(state => state.posts.status);
const dispatch = useDispatch();
// Selector with Reselect (avoid re-renders for derived data)
import { createSelector } from '@reduxjs/toolkit';
const selectActivePosts = createSelector(
state => state.posts.items,
items => items.filter(p => p.active) // only recomputes when items changes
);
Use RTK when: large team, complex async flows, need Redux DevTools time-travel, existing Redux codebase.
Zustand โ minimal and powerful
Zustand stores are outside Reactโs component tree โ components subscribe to slices of the store and only re-render when their selected slice changes.
import { create } from 'zustand';
// Define the store
const usePostStore = create((set, get) => ({
posts: [],
status: 'idle',
// Actions are just functions that call set()
addPost: (post) => set(state => ({ posts: [...state.posts, post] })),
removePost: (id) => set(state => ({ posts: state.posts.filter(p => p.id !== id) })),
fetchPosts: async (userId) => {
set({ status: 'loading' });
try {
const res = await fetch(`/api/users/${userId}/posts`);
const posts = await res.json();
set({ posts, status: 'succeeded' });
} catch (err) {
set({ status: 'failed' });
}
},
}));
// In component โ selector prevents re-renders for unrelated state
function PostList() {
// Only re-renders when posts array changes โ not when status changes
const posts = usePostStore(state => state.posts);
const fetchPosts = usePostStore(state => state.fetchPosts);
// ...
}
The key difference from Context: a Zustand subscriber only re-renders when the specific slice it selected changes, not when any part of the store changes.
Use Zustand when: you want minimal boilerplate, fine-grained subscriptions, or a simpler alternative to Redux for small-to-medium apps.
The decision tree
Is the state local to one component?
โ useState / useReducer
Is the state shared across a subtree, changes rarely, and is simple?
โ Context (theme, locale, auth)
Do you need fine-grained subscriptions, minimal boilerplate, or no Redux overhead?
โ Zustand
Do you have a large team, need time-travel debugging, complex async patterns, or an existing Redux codebase?
โ Redux Toolkit