React

React State Management — Context, Zustand & Redux Toolkit

Thirdy Gayares
16 min read

🎓 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.

1

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 stateExamplesWhere it should live
Local UI stateModal open, input value, active tabuseState inside the component
Shared UI stateTheme, sidebar collapsed, cart, auth sessionContext, Zustand, or Redux Toolkit
Server stateProducts from the API, user profile, ordersTanStack Query / SWR (a cache, not a store)
URL stateCurrent page, search query, filters, sort orderThe URL — router params & search params
⚠️ The single biggest mistake I see in code reviews: copying server data into a global store. An API response is not client state — it's a cache of someone else's data. Stuffing it into Redux or Zustand means you now hand-roll loading flags, refetching, invalidation, and staleness — everything a server-state library already does for free. Keep this rule and 70% of your "state management problem" disappears before you install anything.

This tutorial focuses on the second row — shared UI state — because that's the bucket where Context, Zustand, and Redux Toolkit actually compete.

2

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.

SearchPage.jsx
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.

💡 Rule of thumb: a state tool earns its place only when lifting state up starts hurting — when the "closest common parent" is 4+ levels away from the components that care, or when totally unrelated branches of the tree (header badge + product grid) need the same live value.
3

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.

👤 Thirdy — this card is deep in the tree, and it reads theme and user from context. Zero props were drilled.
✅ Why the custom hook matters: 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.

4

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

ThemePanel rendered 1×

🛒 CartPanel only reads cartCount: 0

CartPanel rendered 1×

Click Add to cart a few times — watch both counters go up, even though ThemePanel never reads the cart.

⚠️ Two problems compound here. First, one context holding unrelated values means every consumer pays for every change. Second, the Provider's 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):

split-contexts.jsx
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.

5

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. addItem is defined right next to items — 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.

Header (no Provider wrapping any of this!)
🛒 0 items — $0
🎧 Headphones — $79
⌨️ Mechanical Keyboard — $49

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:

store/createDemoStore.ts
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
  );
}
💡 Name the magic: 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.
6

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

ClicksWidget rendered 1×

👤 UsernameWidget selects username: thirdy

UsernameWidget rendered 1×

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 / usernameBoth consumers re-renderOnly the subscribed widget re-renders
Update cart / clicksBoth consumers re-renderOnly the subscribed widget re-renders
How you opt inYou can't — all consumers get everythingThe selector IS the subscription
⚠️ Selector gotcha: select primitives or stable references. 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.
7

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.

1Define a slice: state + reducers together
features/cart/cartSlice.js
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);
2Create the store with configureStore
app/store.js
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
});
3Provide the store once, at the root
main.jsx
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>
);
4Read with useSelector, write with useDispatch
features/cart/CartWidgets.jsx
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>
  );
}
💡 So RTK also has selectors? Yes — 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.
8

The Redux Mental Model: One-Way Data Flow

Everything in Redux follows one loop, and once this clicks, Redux stops feeling ceremonial:

redux-data-flow.txt
  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 changed

The 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 —

✅ Why teams pay the Redux tax: that action log is a complete, replayable history of why your state is what it is. When a bug report says "the cart total went negative," you don't guess — you open DevTools, read the action sequence, and time-travel to the exact dispatch that broke it. No other tool in this post gives you that out of the box.
9

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.

1Redux Toolkit: createAsyncThunk
features/user/userSlice.js
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))
2Zustand: async actions are just async functions
store/useUserStore.js
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 });
    }
  },
}));
3Context: you end up rebuilding the above by hand

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.

⚠️ But remember section 1: if the data comes from your API, the best answer is often none of the above. A server-state library like TanStack Query handles fetching, caching, deduping, refetch-on-focus, and invalidation — and then your "global store" shrinks to genuinely client-side things: theme, session, cart, UI flags. In my production apps, adopting a query library removed more Redux code than any refactor. (That's its own tutorial — see What's Next.)
10

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:

state-decision-tree.txt
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 Toolkit

And the head-to-head comparison for the three shared-UI-state tools:

Context APIZustandRedux Toolkit
Install size0 KB (built-in)~1 KB~13 KB (RTK + react-redux)
BoilerplateLow–medium (providers, memo)Minimal (one create call)Medium (slices, store, Provider)
Provider neededYes, per contextNoYes, one at the root
Selective re-rendersNo — all consumers re-renderYes — selectorsYes — useSelector
DevToolsReact DevTools onlyVia devtools middlewareFirst-class, time-travel
Async storyDIY in the ProviderAsync actionscreateAsyncThunk / RTK Query
Learning curveYou already know itGentleSteepest of the three
Sweet spotTheme, locale, auth sessionMost apps' client stateLarge teams, audit-heavy apps
💡 My honest default in 2026: Context for the 2–3 slow-moving app-wide values, Zustand for everything else client-side, and a query library for server data. I reach for Redux Toolkit when the team is big enough that enforced structure and an action audit trail beat Zustand's freedom — that's an organizational decision, not a technical one.
11

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.

1Zustand: the persist middleware (one wrapper)
store/useCartStore.js
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.
2Redux Toolkit: subscribe + preloadedState (or redux-persist)
app/store.js
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.
3Context: persist inside the Provider
ThemeProvider.jsx
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>;
}
⚠️ SSR gotcha (Next.js users): 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.
12

Common Mistakes

⚠️ Mistake 1 — Everything-global syndrome. Moving a modal's 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.
⚠️ Mistake 2 — Server state in the store. The section 1 rule, restated because it's the one people break: 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.
⚠️ Mistake 3 — One giant context. <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.
⚠️ Mistake 4 — Mutating state outside Immer's protection. Inside 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] })).
⚠️ Mistake 5 — Selecting the whole store. 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.
13

Best Practices

Classify before you build: local / shared / server / URL — the bucket picks the tool for you
Keep state as local as possible — promote it to a store only when components far apart need it
One store/slice per feature (useCartStore, cartSlice) — never one god-store object for the whole app's UI
Always subscribe through selectors — narrow, primitive-returning selectors are your re-render budget
Put updates next to the state — actions in the store/slice, not scattered setState calls across components
Memoize context values and split contexts by concern when you do use Context
Persist the minimum — small UI preferences and the cart, never server data or secrets
Prefer boring: if Context + a query library covers it, you don't need a store yet — add one when the pain is real
14

Practice 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 a selectCartCount-style selector — compare the render counter
  • Round 3 — Redux Toolkit: a cartSlice with itemAdded/itemRemoved/qtyChanged, and watch your action history in Redux DevTools
  • Bonus: persist the cart to localStorage in 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.

🚀 What's Next:

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.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.