Week 10: Global State — Context, Zustand & Redux Toolkit

Weeks 8–9 solved server state. This week is about client state that several unrelated components need — a theme, the current user, a shopping cart open across the whole app — the exact problem Week 1's "lifting state up" starts to strain under once the tree gets deep. Three tools, in increasing order of how much structure they add.

Module 7 of 13 Week 10 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Share state across a component tree with the Context API
  • Set up and use a lightweight global store with Zustand
  • Choose the right tool for a given app's state needs, and justify it

1. The Context API

Context lets a value skip explicit prop-drilling through every intermediate component — any descendant of a Provider can read the value directly, no matter how deep it's nested.

ThemeContext.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';

type Theme = 'light' | 'dark';

interface ThemeContextValue {
  theme: Theme;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
  const toggleTheme = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within a ThemeProvider');
  return context;
}
usage — any depth, no prop drilling
function App() {
  return (
    <ThemeProvider>
      <Layout />
    </ThemeProvider>
  );
}

function DeeplyNestedButton() {
  const { theme, toggleTheme } = useTheme(); // works no matter how deep this is
  return <button onClick={toggleTheme}>Current: {theme}</button>;
}

Wrapping the context in a custom hook (useTheme, following Week 4's pattern) that throws when used outside its provider turns a silent null bug into an immediate, clear error — worth doing for every context you create.

2. Where Context Stops Scaling

Context has a specific, well-known performance characteristic: every component consuming a context re-renders whenever that context's value changes, regardless of whether the specific field that component reads actually changed.

the problem
interface AppContextValue {
  theme: Theme;
  user: User;
  notifications: Notification[];
}

// Any component reading ONLY `theme` still re-renders when `notifications` changes,
// because they all come from the same context value.

For infrequently-changing values (a theme, the logged-in user), this is rarely a real problem. It becomes one for state that changes often and is read by many components — exactly the profile of a shopping cart, a notification feed, or complex form state across a large app. Context also has no built-in devtools, no time-travel debugging, and no structured pattern for handling async updates — it's a distribution mechanism, not a full state management solution.

Context is not for server state

It's tempting to stuff API data into Context, but that reinvents — badly — everything Weeks 8–9 already solved: caching, staleness, deduplication, mutation handling. Context is for genuinely client-owned state (UI preferences, auth session, cart contents); server data belongs in TanStack Query.

3. Zustand

Zustand is a minimal global state library: no Provider wrapper required, no boilerplate action types — a store is just a hook.

terminal
npm install zustand
useCartStore.ts
import { create } from 'zustand';

interface CartItem {
  id: number;
  name: string;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: number) => void;
}

export const useCartStore = create<CartState>((set) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, { ...item, quantity: 1 }] })),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
}));
usage — no Provider, no context boilerplate
function CartBadge() {
  const itemCount = useCartStore((state) => state.items.length); // selects ONE slice
  return <span>{itemCount} items</span>;
}

function AddToCartButton({ product }: { product: Product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>Add to cart</button>;
}

That selector argument — useCartStore((state) => state.items.length) — is what solves Context's re-render problem: a component only re-renders when the specific slice it selected changes, not on every store update. CartBadge re-renders when items.length changes; it does not re-render if some unrelated field elsewhere in the store changes.

4. Redux Toolkit

Redux Toolkit is the modern, official way to write Redux — it's Week 4's useReducer pattern, generalized to the whole app: one central store, actions describing what happened, reducers computing the next state. createSlice is the main API, generating action creators and a reducer from one object.

terminal
npm install @reduxjs/toolkit react-redux
cartSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: number;
  name: string;
  quantity: number;
}

const cartSlice = createSlice({
  name: 'cart',
  initialState: [] as CartItem[],
  reducers: {
    addItem: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
      state.push({ ...action.payload, quantity: 1 }); // Immer lets you "mutate" safely here
    },
    removeItem: (state, action: PayloadAction<number>) => {
      return state.filter((item) => item.id !== action.payload.id);
    },
  },
});

export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;
store.ts
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';

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

export type RootState = ReturnType<typeof store.getState>;
usage
import { useSelector, useDispatch } from 'react-redux';
import { addItem } from './cartSlice';
import type { RootState } from './store';

function CartBadge() {
  const itemCount = useSelector((state: RootState) => state.cart.length);
  return <span>{itemCount} items</span>;
}

function AddToCartButton({ product }: { product: Product }) {
  const dispatch = useDispatch();
  return <button onClick={() => dispatch(addItem(product))}>Add to cart</button>;
}

Notice state.push(...) directly inside addItem's reducer — this looks like it violates Week 4's "reducers must be pure, never mutate" rule. Redux Toolkit uses a library called Immer under the hood, which lets you write code that looks like direct mutation but actually produces a new, immutable state object behind the scenes — real immutability, ergonomic syntax.

5. Choosing Between Them

None of these three is strictly "better" — each fits a different point on the scale of how much state you're managing and how often it changes:

  • Context — a handful of infrequently-changing values (theme, locale, auth session) read by many components. Zero dependencies, built into React.
  • Zustand — genuine app-wide state (cart, UI state, feature flags) that changes reasonably often, where you want fine-grained re-renders without Redux's ceremony. The default recommendation for most apps that outgrow Context.
  • Redux Toolkit — large, complex apps with substantial shared state, especially where the ecosystem matters: time-travel debugging, the Redux DevTools, middleware for complex async flows, or an existing team convention already built around it.
The question underneath all three

Before reaching for any of them, ask whether the state is genuinely global at all — a huge amount of "I need global state" is actually solvable by lifting state up one or two levels (Week 1) or is server state that belongs in TanStack Query (Weeks 8–9). Reach for these tools once state is truly shared across unrelated parts of the tree.

6. Hands-on Exercise

Hands-on

Build the same cart feature two ways, and compare

Directly experience the tradeoffs between Context and Zustand by implementing the same small feature with both.

Requirements:

  1. Build a simple product grid with an "Add to cart" button per product, and a cart badge in a header component several levels above the grid in the tree.
  2. Implement the cart with the Context API (Section 1): a CartProvider, a useCart hook, addItem/removeItem.
  3. Add a console.log inside an unrelated component that also consumes CartContext for an unrelated reason (e.g. reads only theme from a combined context) and confirm it re-renders on every cart change, even though it doesn't display cart data.
  4. Reimplement the exact same feature with Zustand (Section 3) instead, using a selector in the unrelated component that only reads the unrelated value, and confirm — via the same console log — that it no longer re-renders when the cart changes.
Hint

For step 3 to actually demonstrate the problem, both theme and the cart items need to live in the same context value — if you split them into two separate contexts, you've already worked around the exact limitation this exercise is meant to show.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

If a context's value has three fields and only one changes, which consuming components re-render?

All of them — every component calling useContext on that context re-renders whenever the context's value changes at all, regardless of which specific field it actually reads. Context has no built-in mechanism to subscribe to just one slice of its value.

Q2

How does useCartStore((state) => state.items.length) avoid the re-render problem Context has?

The selector function tells Zustand exactly which slice of the store this component cares about, and Zustand only triggers a re-render for that component when the selected value itself actually changes — not on every store update. A component selecting only items.length is untouched by a change to some unrelated field in the same store.

Q3

In Redux Toolkit, why is writing state.push(newItem) inside a reducer safe, when Week 4 taught that reducers must never mutate their input?

Redux Toolkit wraps every createSlice reducer with Immer, a library that intercepts what looks like direct mutation and produces a real, new immutable state object behind the scenes. The rule from Week 4 still holds underneath — the actual state object is never mutated — Immer just gives you more convenient syntax for expressing the update.

Q4

Why shouldn't API data fetched in Week 8 be stored in a Zustand store or Context instead of TanStack Query?

None of the client state tools in this lesson provide caching by query key, staleness tracking, automatic background refetching, or deduplication of in-flight requests — all the server-state-specific behavior TanStack Query provides for free. Storing server data in a plain global store means re-implementing those problems by hand, which is exactly what Weeks 8–9 exist to avoid.