React Context vs Zustand vs Jotai — A Visual Guide
• React, State Management
When UI updates are bigger than the change
Most React slowness is just too many components waking up at once. Here’s the same event—a notification added to a user profile—handled three ways. Each box below is a simulated component with a render counter; the buttons trigger the update. Context broadcasts to every consumer; Zustand and Jotai update only what depends on the changed data.
Context (baseline)
All consumers rerender when the provider’s value changes — even if they only read a different field.
<UserContext.Provider value={{ user, setUser }}>
<App>
<Navbar /> // reads user.role
<Settings /> // reads user.role
<NotificationBell /> // reads user.notifications
</App>
</UserContext.Provider>
How a notification changes Context:
// inside App
function pushNotification(n) {
// New object identity → every consumer of UserContext rerenders
setUser(u => ({
...u,
notifications: [...u.notifications, n]
}));
}
App (provider)
Navbar (reads role)
Settings (reads role)
Bell (reads notifications)
Simulated Context: any change to user (role or notifications) rerenders all consumers.
Zustand (selectors)
Components subscribe to slices. Only the slice’s subscribers rerender.
// one small store
const useUser = create(set => ({
role: 'member', // Navbar, Settings select this slice
notifications: [], // Bell selects this slice
promote: () => set(s => ({ role: s.role === 'member' ? 'admin' : 'member' })),
push: (n) => set(s => ({ notifications: [...s.notifications, n] }))
}));
// layout
<App>
<Navbar role={useUser(s => s.role)} />
<Settings role={useUser(s => s.role)} />
<Bell count={useUser(s => s.notifications.length)} />
</App>
App
Navbar (role)
Settings (role)
Bell (notifications)
Simulated Zustand: role updates rerender Navbar/Settings; notifications updates rerender Bell.
Jotai (atoms)
Atoms update only dependents. Derived atoms recompute without waking unrelated readers.
// atoms
const roleAtom = atom('member');
const notificationsAtom = atom([]);
const notifCountAtom = atom(get => get(notificationsAtom).length);
// write‑only setter atom for notifications
const pushNotificationAtom = atom(null, (get, set, n) => {
set(notificationsAtom, [...get(notificationsAtom), n]);
});
// layout
<App>
<Navbar role={useAtomValue(roleAtom)} />
<Settings role={useAtomValue(roleAtom)} />
<Bell count={useAtomValue(notifCountAtom)} />
<button onClick={() => useSetAtom(pushNotificationAtom)({ id: Date.now() })}>new notification</button>
<button onClick={() => useSetAtom(roleAtom)(r => r === 'member' ? 'admin' : 'member')}>promote role</button>
</App>
App
Navbar (reads role atom)
Settings (reads role atom)
Bell (derived notif count)
Simulated Jotai: role atom changes rerender role readers; notifications atom changes rerender Bell.
Why atomic writes and slices matter
React reconciliation is fast; the expensive part is how many places you ask React to look. Two ideas keep that number small:
Atomic writes. Update only the thing that changed. In Context you often end up writing a bigger object (e.g., the whole user), which changes identity and wakes everything. With Zustand or Jotai you can write exactly the notification slice or the specific atom.
Slices (or atoms). Subscribe to the smallest possible unit. Reading role should not subscribe you to notifications. Slices and atoms give you that boundary by default; you add work only where it belongs.
In practice this means: isolate cross‑cutting state that changes often (notifications, UI chrome) into its own slice/atom; keep capability objects (auth client, feature APIs) in Context; and let server data live in a cache (React Query) where freshness and background updates are already handled.