Skip to content
elephantoo

State management: Zustand & Redux Toolkit

Lesson 23 of 30 18 min read

When local state and context are not enough: global stores with Zustand and Redux Toolkit.


You can build surprisingly large apps with useState, useReducer and context. But as apps grow, you may notice prop drilling, context providers that re-render too much, and update logic spread everywhere. State management libraries give you a central store that any component can read from and update, with fine-grained re-renders. This lesson covers the two you're most likely to meet: Zustand (small and simple) and Redux Toolkit (structured and battle-tested).

Do you need a library?#

Sort your state into kinds first:

KindExamplesBest tool
Local UI stateinput text, is a dropdown openuseState / useReducer
Shared UI statetheme, current user, cartcontext, or a store
Server stateposts, lessons, profile from an APITanStack Query, router loaders
URL statefilters, page number, selected tabsearch params

Once server data lives in a data-fetching library and filters live in the URL, many apps have very little global state left. Reach for a store when shared client state is genuinely complex or updated from many places.

Zustand: a store in a few lines#

Terminal
npm install zustand

A store is a hook created with create. It holds state and the functions that update it:

src/stores/cartStore.js
import { create } from "zustand";

export const useCartStore = create((set, get) => ({
  items: [],

  addItem: (product) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === product.id);
      if (existing) {
        return { items: state.items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)) };
      }
      return { items: [...state.items, { ...product, qty: 1 }] };
    }),

  removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),

  clear: () => set({ items: [] }),

  total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}));
  • set merges the object you return into the state (one level deep), so you don't need to spread the rest.
  • get reads the current state inside actions.

Use it in any component — no provider needed:

JSX
import { useCartStore } from "./stores/cartStore";

function AddToCartButton({ product }) {
  const addItem = useCartStore((s) => s.addItem); // functions never change → never re-renders
  return <button onClick={() => addItem(product)}>Add to cart</button>;
}

function CartBadge() {
  const count = useCartStore((s) => s.items.reduce((n, i) => n + i.qty, 0));
  return <span className="badge">🛒 {count}</span>;
}

function CartPage() {
  const items = useCartStore((s) => s.items);
  const removeItem = useCartStore((s) => s.removeItem);
  const total = useCartStore((s) => s.total());

  return (
    <>
      <ul>
        {items.map((i) => (
          <li key={i.id}>
            {i.name} × {i.qty} <button onClick={() => removeItem(i.id)}>Remove</button>
          </li>
        ))}
      </ul>
      <p>Total: ₹{total}</p>
    </>
  );
}

Selectors and re-renders#

The function you pass — (s) => s.items — is a selector. A component re-renders only when the value it selects changes (compared with Object.is). That's the big advantage over a single context value.

If you select several values into a new object, wrap the selector with useShallow, otherwise the new object counts as a change every time:

JSX
import { useShallow } from "zustand/react/shallow";

function CartSummary() {
  const { items, clear } = useCartStore(useShallow((s) => ({ items: s.items, clear: s.clear })));
  return (
    <p>
      {items.length} products <button onClick={clear}>Empty cart</button>
    </p>
  );
}

Persisting to localStorage#

Zustand's persist middleware saves the store and restores it on page load:

JSX
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useSettingsStore = create(
  persist(
    (set) => ({
      theme: "light",
      fontSize: 16,
      setTheme: (theme) => set({ theme }),
      increaseFont: () => set((s) => ({ fontSize: Math.min(s.fontSize + 2, 24) })),
    }),
    { name: "elephantoo-settings" } // the localStorage key
  )
);

You can also read and update a Zustand store outside React — handy in utility code or tests:

JavaScript
useCartStore.getState().addItem({ id: 1, name: "Sticker", price: 50 });
console.log(useCartStore.getState().items.length); // 1

Redux Toolkit: structure for big teams#

Redux keeps all global state in one store, updated only by dispatching actions to reducers — the same pattern as useReducer, at app scale. Modern Redux means Redux Toolkit (RTK), which removes the old boilerplate.

Terminal
npm install @reduxjs/toolkit react-redux

1. Create a slice

A slice bundles a piece of state, its reducers and auto-generated action creators:

src/features/cart/cartSlice.js
import { createSlice } from "@reduxjs/toolkit";

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    itemAdded(state, action) {
      const existing = state.items.find((i) => i.id === action.payload.id);
      if (existing) existing.qty += 1; // looks like mutation – Immer makes it safe
      else state.items.push({ ...action.payload, qty: 1 });
    },
    itemRemoved(state, action) {
      state.items = state.items.filter((i) => i.id !== action.payload);
    },
    cleared(state) {
      state.items = [];
    },
  },
});

export const { itemAdded, itemRemoved, cleared } = cartSlice.actions;
export default cartSlice.reducer;

// Selectors live next to the slice
export const selectCartItems = (state) => state.cart.items;
export const selectCartTotal = (state) => state.cart.items.reduce((sum, i) => sum + i.price * i.qty, 0);

itemAdded(product) creates { type: "cart/itemAdded", payload: product } for you.

2. Configure the store

src/app/store.js
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice";

export const store = configureStore({
  reducer: {
    cart: cartReducer,
  },
});

configureStore also sets up the Redux DevTools extension and development checks that warn about accidental mutations.

3. Provide it and use hooks

src/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>
);
JSX
import { useDispatch, useSelector } from "react-redux";
import { itemAdded, itemRemoved, selectCartItems, selectCartTotal } from "./features/cart/cartSlice";

function Cart() {
  const items = useSelector(selectCartItems);
  const total = useSelector(selectCartTotal);
  const dispatch = useDispatch();

  return (
    <>
      <button onClick={() => dispatch(itemAdded({ id: 1, name: "Sticker", price: 50 }))}>Add sticker</button>
      <ul>
        {items.map((i) => (
          <li key={i.id}>
            {i.name} × {i.qty} <button onClick={() => dispatch(itemRemoved(i.id))}>Remove</button>
          </li>
        ))}
      </ul>
      <p>Total: ₹{total}</p>
    </>
  );
}

useSelector works like Zustand selectors: the component re-renders only when the selected value changes.

Async logic with createAsyncThunk

JSX
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

export const fetchUsers = createAsyncThunk("users/fetch", async () => {
  const res = await fetch("https://jsonplaceholder.typicode.com/users");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
});

const usersSlice = createSlice({
  name: "users",
  initialState: { list: [], status: "idle", error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = "loading";
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.list = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = "failed";
        state.error = action.error.message;
      });
  },
});

For most API data, though, RTK ships RTK Query — a caching data-fetching layer similar to TanStack Query (next lesson).

TypeScript tip#

With TypeScript, derive types from the store and create pre-typed hooks once:

src/app/hooks.ts
import { useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./store"; // export these from store.ts:
// export type RootState = ReturnType<typeof store.getState>;
// export type AppDispatch = typeof store.dispatch;

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

Zustand or Redux Toolkit?#

ZustandRedux Toolkit
SetupOne create call, no providerSlices, store, <Provider>
Size~1 KBLarger
StructureFreeformOpinionated conventions
DevToolsVia devtools middlewareBuilt in, with time-travel
Best forSmall–medium apps, quick winsLarge apps and teams wanting strict patterns

Other options you'll hear about: Jotai (atom-based, like many tiny useStates shared globally) and MobX (observable objects). All are valid — consistency within a project matters more than the choice.

Common mistakes#

  • Putting everything in global state, including form inputs and server data.
  • Selecting the whole store (useCartStore(), or useSelector((s) => s)), so components re-render on every change.
  • Returning a new object from a selector without useShallow (Zustand) or a memoised selector (Redux's createSelector).
  • Mutating state in a Zustand set call — Zustand doesn't use Immer by default, so return new objects.
  • Using createAsyncThunk for every request when a caching library would remove most of the code.

What's next#

Speaking of server state: next you'll learn TanStack Query, the standard tool for fetching, caching and updating API data.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In Zustand, why write useCartStore((s) => s.items) instead of useCartStore()?

  2. 2.In a Redux Toolkit createSlice reducer, why can you write state.items.push(item)?

  3. 3.Which state usually should NOT go into a global client store?

Finished reading?

Mark this lesson complete to track your progress.