🎓 What You Will Learn
- The four kinds of state: local UI, shared UI, server, and URL state — and why mixing them up causes most state pain
- When local state is enough: useState + lifting state up, before any library
- Context API done right: low-frequency shared state with a provider + custom hook
- Context's re-render problem: watch it happen live with render counters, then fix it
- Zustand: a provider-less store with selectors that give you surgical re-render control
- Redux Toolkit: createSlice, configureStore, and the action → reducer → subscribers mental model
- Async & persistence: where API calls live in each tool, plus localStorage patterns
- A decision tree: so you stop guessing which tool a feature needs
Prerequisites: you should be comfortable with React Hooks and useContext.
The Real Question: What KIND of State Is It?
"Context vs Zustand vs Redux" is the wrong first question. Ang tamang unang tanong: what kind of state am I holding? Most state-management pain comes from putting a value in the wrong bucket — not from picking the "wrong" library.
| Kind of state | Examples | Where it should live |
|---|---|---|
| Local UI state | Modal open, input value, active tab | useState inside the component |
| Shared UI state | Theme, sidebar collapsed, cart, auth session | Context, Zustand, or Redux Toolkit |
| Server state | Products from the API, user profile, orders | TanStack Query / SWR (a cache, not a store) |
| URL state | Current page, search query, filters, sort order | The URL — router params & search params |
This tutorial focuses on the second row — shared UI state — because that's the bucket where Context, Zustand, and Redux Toolkit actually compete.
Start Local: useState + Lifting State Up
Before any store, remember the default: state lives in the component that uses it. When two siblings need the same state, you lift it up to their closest common parent and pass it down as props.
import { useState } from 'react';
export function SearchPage() {
// Lifted state: both children need "query", so their parent owns it
const [query, setQuery] = useState('');
return (
<div>
<SearchInput query={query} onQueryChange={setQuery} />
<SearchResults query={query} />
</div>
);
}
function SearchInput({ query, onQueryChange }) {
return (
<input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search products..."
/>
);
}
function SearchResults({ query }) {
return <p>Showing results for: {query}</p>;
}This is genuinely enough when the components sharing state are close together in the tree (1–2 levels apart) and the state belongs to one feature. A search box and its results, a form and its preview, a tab bar and its panel — walang kailangang library dito. Reaching for a global store here is over-engineering.
Context API for Low-Frequency Shared State
Context is React's built-in answer to prop drilling: provide a value once near the top, read it from any descendant with a hook. It shines for values that are read by many, changed rarely — theme, locale, the logged-in user, feature flags.
The production pattern is always the same trio: a context, a Provider component, and a custom hook wrapper that throws a clear error when the Provider is missing.
theme and user from context. Zero props were drilled.useSettings() hides useContext(SettingsContext) behind a name that reads like an API, and it fails loudly at the exact component that forgot the Provider — instead of a mysterious null crash three components later. Every context in your codebase should ship with one.For a deeper dive into Context itself (multiple contexts, composing providers), see the dedicated useContext tutorial. Here, we care about the question Context can't answer well — and that's next.
Context's Re-render Problem (Watch It Live)
Here's the catch nobody tells beginners: every component that consumes a context re-renders whenever the Provider's value changes — even if the component only reads one field of that value. There is no partial subscription in Context. Wala kang "subscribe to this field only" option.
The demo below has ONE context carrying both theme and cartCount, and two consumers with live render counters. Both consumers are wrapped in memo(), so parent re-renders can't touch them — only the context can. Click Add to cart and watch the ThemePanel counter climb anyway:
🎨 ThemePanel only reads theme: light
🛒 CartPanel only reads cartCount: 0
Click Add to cart a few times — watch both counters go up, even though ThemePanel never reads the cart.
value is a brand-new object on every render — so even a same-value render would notify consumers. This is fine for a theme that changes twice a day. It's a performance cliff for a cart, a form, or anything updating on every click or keystroke.The built-in fix is splitting contexts by concern (and memoizing each value):
import { createContext, useMemo, useState } from 'react';
// ✅ Two contexts — a cart update no longer touches theme consumers
const ThemeContext = createContext(null);
const CartContext = createContext(null);
export function AppProviders({ children }) {
const [theme, setTheme] = useState('light');
const [cartCount, setCartCount] = useState(0);
// Memoized values: the reference only changes when the data changes
const themeValue = useMemo(
() => ({ theme, toggleTheme: () => setTheme(t => (t === 'light' ? 'dark' : 'light')) }),
[theme]
);
const cartValue = useMemo(
() => ({ cartCount, addToCart: () => setCartCount(c => c + 1) }),
[cartCount]
);
return (
<ThemeContext.Provider value={themeValue}>
<CartContext.Provider value={cartValue}>
{children}
</CartContext.Provider>
</ThemeContext.Provider>
);
}Splitting works — pero honest assessment from production: once you're maintaining four nested providers, each with useMemo gymnastics, and you still can't subscribe to a single field of one context... you've outgrown Context. That's exactly the gap Zustand fills.
Zustand: The Lightweight Store
Zustand (German for "state") is a ~1 KB store with a radically small API: you call create() once with your state and actions, and it hands you back a hook. Three things make it feel great in production:
- No Provider. The store lives outside the component tree — import the hook and use it anywhere.
- Selectors. Components subscribe to a slice of state, not the whole store (next section).
- Actions live with state.
addItemis defined right next toitems— one file per feature store.
Full transparency: this blog doesn't have zustand installed, so the live demo below simulates it — a plain module-level store object with getState/setState/subscribe, consumed through React's built-in useSyncExternalStore hook. That's not a cheat, it's the lesson: this is literally how Zustand works under the hood. The 📝 Code tab shows the real Zustand code you'd write in your own project after npm install zustand.
Notice what's missing in the demo: no Provider wrapper, no context, no lifted state. The header badge and the product rows are unrelated components that simply imported the same store. Here's the simulation the demo actually runs, so the magic has a name:
import { useSyncExternalStore } from 'react';
// A hand-rolled external store — the pattern Zustand is built on
function createDemoStore(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
setState: (partial) => {
const next = typeof partial === 'function' ? partial(state) : partial;
state = { ...state, ...next }; // immutable merge, like Zustand's set()
listeners.forEach((listener) => listener()); // notify subscribers
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener); // unsubscribe on unmount
},
};
}
const cartStore = createDemoStore({ items: 0, total: 0 });
// useSyncExternalStore is React's official bridge to stores that
// live OUTSIDE the component tree. Zustand calls this internally.
function useCartStore(selector) {
return useSyncExternalStore(
cartStore.subscribe, // how to be notified
() => selector(cartStore.getState()), // snapshot on the client
() => selector(cartStore.getState()), // snapshot during SSR
);
}useSyncExternalStore is a real React 18 hook shipped exactly for this — safely subscribing components to external stores without tearing (two components briefly showing different snapshots of the same store during concurrent rendering). When you use Zustand, this hook is doing the work; Zustand adds the ergonomics on top.Zustand Selectors: Surgical Re-render Control
This is Zustand's headline feature and the direct answer to section 4's problem. When you write useCartStore((s) => s.items), the component subscribes to the selector's result, not the whole store. On every store update, the selector runs; if its result is unchanged (compared with Object.is), the component skips the re-render entirely.
Same experiment as section 4 — one store, two fields, two widgets with render counters. This time, watch only the subscribed widget move (again simulated with useSyncExternalStore; the Code tab is real Zustand):
🔢 ClicksWidget selects clicks: 0
👤 UsernameWidget selects username: thirdy
Spam Update clicks — only ClicksWidget's counter moves. Compare that with the context demo in section 4!
Compare the two demos side by side and the difference is stark:
| Context (section 4) | Store + selectors (this section) | |
|---|---|---|
| Update theme / username | Both consumers re-render | Only the subscribed widget re-renders |
| Update cart / clicks | Both consumers re-render | Only the subscribed widget re-renders |
| How you opt in | You can't — all consumers get everything | The selector IS the subscription |
useStore((s) => ({ a: s.a, b: s.b })) builds a new object every update, so the equality check always fails and you're back to re-rendering on everything. Either select fields individually, or use Zustand's useShallow helper to compare object contents instead of references.Redux Toolkit: Predictable State at Scale
Redux got famous — then infamous for boilerplate. Redux Toolkit (RTK) is the official, modern way to write Redux, and it deleted most of that boilerplate: createSlice generates your action types and creators, and Immer lets you write "mutations" that are secretly immutable updates. If you see createStore, connect(), or hand-written action-type constants in a tutorial — luma na yun, close the tab.
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [], // [{ id, name, price, qty }]
},
reducers: {
// Looks like mutation — Immer turns it into an immutable update
itemAdded: (state, action) => {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
existing.qty += 1;
} else {
state.items.push({ ...action.payload, qty: 1 });
}
},
itemRemoved: (state, action) => {
state.items = state.items.filter((i) => i.id !== action.payload);
},
cartCleared: (state) => {
state.items = [];
},
},
});
// createSlice generated these action creators for free
export const { itemAdded, itemRemoved, cartCleared } = cartSlice.actions;
export default cartSlice.reducer;
// Selectors live next to the slice they read
export const selectCartItems = (state) => state.cart.items;
export const selectCartCount = (state) =>
state.cart.items.reduce((sum, i) => sum + i.qty, 0);import { configureStore } from '@reduxjs/toolkit';
import cartReducer from '../features/cart/cartSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
// user: userReducer, ← each feature gets its own slice
},
// DevTools + serializability/immutability checks are ON by default
});import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
);import { useSelector, useDispatch } from 'react-redux';
import { itemAdded, selectCartCount } from './cartSlice';
// useSelector works like a Zustand selector: this component
// re-renders only when selectCartCount's result changes.
export function CartBadge() {
const count = useSelector(selectCartCount);
return <span>🛒 {count}</span>;
}
export function AddToCartButton({ product }) {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(itemAdded(product))}>
Add {product.name} to cart
</button>
);
}useSelector gives you the same re-render control as Zustand's selectors. What RTK adds on top is structure: one store, feature slices, every change expressed as a named action you can trace in DevTools, plus built-in dev checks that catch accidental mutation and non-serializable state. That structure costs boilerplate; on a large team it pays for itself.The Redux Mental Model: One-Way Data Flow
Everything in Redux follows one loop, and once this clicks, Redux stops feeling ceremonial:
UI event (click, submit)
│
▼
dispatch(action) ← a plain object: { type, payload }
│
▼
reducer(state, action) ← a PURE function: (old state, action) → new state
│
▼
store holds the new state ← single source of truth
│
▼
subscribers notified ← every useSelector re-runs its selector;
components re-render only if their slice changedThe demo below runs a real reducer function through plain useState (Redux isn't installed here) so you can watch the loop happen: every button press dispatches an action object, the reducer computes the next state, and the action log shows the paper trail — the exact trail Redux DevTools gives you for free.
🏪 Store state: {"value":0}
Action log (newest first) — every state change is a dispatched action:
— nothing dispatched yet —
Async in Each: Where Do API Calls Live?
Sooner or later shared state meets the network. Each tool has an official home for async logic.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUser = createAsyncThunk('user/fetchUser', async (userId) => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to load user');
return res.json();
});
const userSlice = createSlice({
name: 'user',
initialState: { data: null, status: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'succeeded';
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export default userSlice.reducer;
// In a component: dispatch(fetchUser(42))import { create } from 'zustand';
export const useUserStore = create((set) => ({
user: null,
status: 'idle',
error: null,
// No middleware, no thunk concept — set() whenever you have news
fetchUser: async (userId) => {
set({ status: 'loading', error: null });
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to load user');
const user = await res.json();
set({ user, status: 'succeeded' });
} catch (err) {
set({ status: 'failed', error: err.message });
}
},
}));With Context there's no store to put the logic in, so the fetch + loading + error dance lives in the Provider component with useState/useEffect — workable for one endpoint, messy by the fifth.
Decision Tree: Which One, When?
Here's the flow I actually run through when a feature needs state. Start at the top; take the first exit that applies:
Does the data come from your server/API?
├─ YES → TanStack Query / SWR (server state ≠ store state)
└─ NO ↓
Does it belong in the URL? (filters, page, search, tab)
├─ YES → router params / searchParams
└─ NO ↓
Does only ONE component (or a tight parent+children group) use it?
├─ YES → useState (lift it up if siblings share it)
└─ NO ↓
Is it read widely but updated RARELY? (theme, locale, session)
├─ YES → Context + custom hook (memoize the value)
└─ NO ↓
Updated often / read by many / needs selective re-renders?
├─ Small-to-mid app, small team → Zustand
└─ Large app, many devs, need action history,
strict conventions, DevTools time-travel → Redux ToolkitAnd the head-to-head comparison for the three shared-UI-state tools:
| Context API | Zustand | Redux Toolkit | |
|---|---|---|---|
| Install size | 0 KB (built-in) | ~1 KB | ~13 KB (RTK + react-redux) |
| Boilerplate | Low–medium (providers, memo) | Minimal (one create call) | Medium (slices, store, Provider) |
| Provider needed | Yes, per context | No | Yes, one at the root |
| Selective re-renders | No — all consumers re-render | Yes — selectors | Yes — useSelector |
| DevTools | React DevTools only | Via devtools middleware | First-class, time-travel |
| Async story | DIY in the Provider | Async actions | createAsyncThunk / RTK Query |
| Learning curve | You already know it | Gentle | Steepest of the three |
| Sweet spot | Theme, locale, auth session | Most apps' client state | Large teams, audit-heavy apps |
Persisting State (Surviving the Refresh)
A cart that empties on refresh feels broken. All three tools can sync to localStorage — with very different amounts of work.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useCartStore = create(
persist(
(set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}),
{
name: 'cart-storage', // localStorage key
// Persist only what you must — skip derived/transient fields
partialize: (state) => ({ items: state.items }),
}
)
);
// That's it. Writes on change, rehydrates on load.import { configureStore } from '@reduxjs/toolkit';
import cartReducer from '../features/cart/cartSlice';
function loadCart() {
try {
const raw = localStorage.getItem('cart-storage');
return raw ? JSON.parse(raw) : undefined; // undefined → slice initialState
} catch {
return undefined;
}
}
export const store = configureStore({
reducer: { cart: cartReducer },
preloadedState: { cart: loadCart() },
});
// store.subscribe fires after every dispatch — mirror the slice out
store.subscribe(() => {
try {
localStorage.setItem('cart-storage', JSON.stringify(store.getState().cart));
} catch {
// storage full / private mode — fine to ignore for a cart
}
});
// For selective persistence, migrations, etc., use the redux-persist package.import { createContext, useEffect, useMemo, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
// Lazy initializer: read localStorage ONCE, not on every render
const [theme, setTheme] = useState(
() => localStorage.getItem('theme') ?? 'light'
);
// Write-through on change. Dep array: run only when theme changes.
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}localStorage doesn't exist on the server. Reading it during the first render causes hydration mismatches or crashes. Gate it behind useEffect (client-only), and with Zustand's persist use its onRehydrateStorage/hydration helpers to show a consistent first paint. Also: never persist server data or secrets — persist small UI preferences and the cart, hindi yung buong API response.Common Mistakes
isOpen or a form's draft text into the global store because "it's cleaner." Now unrelated components can mutate it, it outlives the screen it belongs to, and every open/close ripples through store subscribers. Local state is a feature, not a code smell — most state should die with its component.products, orders, profile from an API don't belong in Redux/Zustand. You'll hand-write caching, refetching, and staleness logic — badly — and your store becomes a stale mirror of the database. Query libraries exist precisely for this.<AppContext.Provider value={{ theme, user, cart, toasts, modal }}> means a toast popping re-renders every theme consumer (you watched this happen in section 4). Split contexts by concern and memoize each value — or admit it's store-shaped state and use a store.createSlice reducers, state.items.push(x) is safe — Immer wraps it. The same line in a Zustand action, a Context provider, or plain useState is a real mutation: React keeps the old reference, equality checks pass, and the UI silently doesn't update. Outside a slice, always produce new objects: set((s) => ({ items: [...s.items, x] })).useCartStore((s) => s) or a fresh object/array from useSelector re-renders the component on every store change — you bought a store for its selectors and then opted out of them. Select the narrowest primitive you need.Best Practices
useCartStore, cartSlice) — never one god-store object for the whole app's UIsetState calls across componentsPractice Project + What's Next
Cement all of this by building a mini e-commerce cart three times — same UI, three state tools:
- A product grid (hardcode 4–5 products), a header cart badge, and a cart drawer with quantities and a total
- Round 1 — Context: one
CartProvider+useCart()hook; add a render counter to the badge and note when it re-renders - Round 2 — Zustand: delete the Provider, move state into
create(), subscribe the badge with aselectCartCount-style selector — compare the render counter - Round 3 — Redux Toolkit: a
cartSlicewithitemAdded/itemRemoved/qtyChanged, and watch your action history in Redux DevTools - Bonus: persist the cart to
localStoragein rounds 2 and 3 and make it survive a refresh
Building the same feature three ways teaches you more about these tools than any comparison table — including the one above.
- useContext — Sharing State Without Prop Drilling — the deep dive on Context patterns this post built on
- useReducer — Action-Based State — the reducer pattern from section 8, built into React itself
- React Data Fetching — the server-state side of the story: fetching, caching, loading and error states done right
Keep going! 💪 Once "what kind of state is this?" becomes your reflex question, state management stops being a debate about libraries and becomes a five-second routing decision — and your apps get faster and simpler at the same time.