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.
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;
}
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.
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.
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.
npm install zustand
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) })),
}));
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.
npm install @reduxjs/toolkit react-redux
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;
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: { cart: cartReducer },
});
export type RootState = ReturnType<typeof store.getState>;
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.
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
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:
- 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.
- Implement the cart with the Context API (Section 1): a
CartProvider, auseCarthook,addItem/removeItem. - Add a
console.loginside an unrelated component that also consumesCartContextfor an unrelated reason (e.g. reads onlythemefrom a combined context) and confirm it re-renders on every cart change, even though it doesn't display cart data. - 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.
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?
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?
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?
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?
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.