React Context, State Management & Patterns
Prop drilling problem
When deeply nested components need data from a distant parent, you must pass props through every intermediate component -- even if they don't use the data. This is called prop drilling.
You solved this in Vue with Pinia. React offers Context (built-in) and libraries like Zustand as alternatives to prop drilling.
React Context
Context provides a way to share values between components without explicitly passing props through every level.
Create a typed context
"use client";
import { createContext, useContext, useState } from "react";
// 1. Define the shape
interface AuthContextType {
userName: string | null;
login: (name: string) => void;
logout: () => void;
}
// 2. Create context with undefined default
const AuthContext = createContext<AuthContextType | undefined>(undefined);
Create a Provider component
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [userName, setUserName] = useState<string | null>(null);
const login = (name: string) => setUserName(name);
const logout = () => setUserName(null);
return (
<AuthContext.Provider value={{ userName, login, logout }}>
{children}
</AuthContext.Provider>
);
}
Create a custom hook (safety check)
Always create a custom hook that throws a helpful error if used outside the provider:
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
Wire up the Provider
Wrap your component tree (typically in layout.tsx). Note: the layout itself stays a Server Component -- a Server Component can render a Client Component as a child. You do not need "use client" on the layout:
// app/layout.tsx
import { AuthProvider } from "@/context/AuthContext";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
Use context in any child component
"use client";
import { useAuth } from "@/context/AuthContext";
export default function UserGreeting() {
const { userName, login, logout } = useAuth();
if (!userName) {
return <button onClick={() => login("Andres")}>Log in</button>;
}
return (
<>
<p>Hello {userName}!</p>
<button onClick={logout}>Log out</button>
</>
);
}
useContext returns the value from the closest Provider above the component in the tree. React automatically re-renders components that read context when its value changes.
Context + Provider is similar to Vue's provide / inject. The custom useAuth() hook is like a Vue composable that wraps inject() with a type-safe check.
useReducer
When state logic becomes complex (multiple related values, complex update rules), useReducer provides a structured alternative to useState.
import { useReducer } from "react";
// State shape
interface CounterState {
count: number;
}
// Action types
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "incrementBy"; payload: number }
| { type: "reset" };
// Reducer function (pure, no side effects)
function counterReducer(
state: CounterState,
action: CounterAction
): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "incrementBy":
return { count: state.count + action.payload };
case "reset":
return { count: 0 };
}
}
export default function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+1</button>
<button onClick={() => dispatch({ type: "decrement" })}>-1</button>
<button onClick={() => dispatch({ type: "incrementBy", payload: 10 })}>
+10
</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</>
);
}
useReducer is similar to Pinia's actions -- centralized state update logic. The dispatch pattern ensures all state changes go through predictable, testable functions.
When to use useReducer vs useState
Use useState when... | Use useReducer when... |
|---|---|
| State is a single value | State has multiple related fields |
| Updates are simple assignments | Updates have complex rules/logic |
| Few state transitions | Many distinct actions |
Context + useReducer
Combining Context with useReducer creates a lightweight state management system -- no external library needed:
import { createContext, useContext, useReducer } from "react";
// State and actions
interface CartState {
items: { id: number; name: string; qty: number }[];
}
type CartAction =
| { type: "add"; payload: { id: number; name: string } }
| { type: "remove"; payload: { id: number } }
| { type: "clear" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "add":
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return {
items: [...state.items, { ...action.payload, qty: 1 }],
};
case "remove":
return {
items: state.items.filter((i) => i.id !== action.payload.id),
};
case "clear":
return { items: [] };
}
}
// Context
interface CartContextType {
state: CartState;
dispatch: React.Dispatch<CartAction>;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
// Provider
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
// Custom hook
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
}
Usage in components:
"use client";
import { useCart } from "@/context/CartContext";
function AddToCartButton({ id, name }: { id: number; name: string }) {
const { dispatch } = useCart();
return (
<button onClick={() => dispatch({ type: "add", payload: { id, name } })}>
Add to Cart
</button>
);
}
function CartSummary() {
const { state, dispatch } = useCart();
return (
<div>
<p>Items: {state.items.length}</p>
<button onClick={() => dispatch({ type: "clear" })}>Clear Cart</button>
</div>
);
}
Performance hooks
useMemo -- memoize expensive computations
Returns a memoized value that only recalculates when dependencies change:
import { useMemo } from "react";
const ExpensiveList = ({ items, filter }: { items: string[]; filter: string }) => {
const filteredItems = useMemo(
() => items.filter((item) => item.includes(filter)),
[items, filter]
);
return (
<ul>
{filteredItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
};
useMemo is React's equivalent of Vue's computed(). Both cache the result and only recompute when dependencies change.
useCallback -- memoize functions
Returns a memoized function reference. Useful when passing callbacks to child components that rely on reference equality:
import { useState, useCallback } from "react";
const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
return <ExpensiveChild onClick={handleClick} />;
};
React.memo -- prevent re-renders
Wraps a component to skip re-rendering when its props haven't changed:
import { memo } from "react";
const RowDisplay = memo(({ gpsSession }: { gpsSession: IGpsSession }) => (
<tr>
<td>{gpsSession.name}</td>
<td>{gpsSession.description}</td>
</tr>
));
Don't optimize prematurely! Only use useMemo, useCallback, and React.memo when you have measured a performance problem. Overusing them adds complexity without benefit.
Error boundaries
Error boundaries catch JavaScript errors in their child component tree and display a fallback UI. This is the only remaining use case for class components in React.
import { Component } from "react";
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error caught by boundary:", error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export default ErrorBoundary;
Usage:
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<MyComponent />
</ErrorBoundary>
In Next.js App Router, the error.tsx file convention handles this automatically for each route segment -- you rarely need to write error boundaries manually. Note that error.tsx files must be Client Components ("use client").
State management libraries
When Context + useReducer isn't enough (very large apps, complex state, performance concerns), consider a dedicated library.
Zustand (recommended for React)
Lightweight, minimal boilerplate, hook-based. The closest React equivalent to Vue's Pinia:
npm install zustand
import { create } from "zustand";
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Usage in any component -- no Provider needed!
function Counter() {
const { count, increment, decrement } = useCounterStore();
return (
<>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</>
);
}
Zustand's create is like Pinia's defineStore. Both define state + actions in one place. The key difference: Zustand doesn't need a Provider wrapper -- stores are globally accessible via hooks.
Other options (brief overview)
| Library | Use case |
|---|---|
| Zustand | Simple global state (like Pinia for React) |
| Redux Toolkit | Large apps with complex state, time-travel debugging |
| TanStack Query | Server state (caching, refetching, synchronization) |
| Jotai | Atomic state (like individual ref() values shared globally) |
React 19 additions
The use hook
Reads the value from a resource (Promise or Context) directly:
import { use } from "react";
// Read a promise (must be created outside the component)
function Comments({ commentsPromise }: { commentsPromise: Promise<string[]> }) {
const comments = use(commentsPromise);
return (
<ul>
{comments.map((comment, i) => (
<li key={i}>{comment}</li>
))}
</ul>
);
}
// Alternative to useContext (assuming ThemeContext is defined elsewhere)
function ThemedButton() {
const theme = use(ThemeContext); // reads from nearest ThemeContext.Provider
return <button className={theme}>Click</button>;
}
Unlike other hooks, use can be called inside conditionals and loops.
ref as a regular prop
In React 19, ref can be passed as a regular prop to function components. The forwardRef wrapper is no longer needed:
// React 19 -- ref is just a prop
function MyInput({ ref, ...props }: { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
// Usage
const inputRef = useRef<HTMLInputElement>(null);
<MyInput ref={inputRef} />;
Self preparation QA
Be prepared to explain topics like these:
- What is prop drilling, and why is it a problem? Context solves it, but what are Context's own drawbacks? — Prop drilling means passing data through multiple intermediate components that do not use it, just to reach a deeply nested child. It makes code harder to maintain and refactor. Context solves this by making values available to any descendant. However, any update to a context value re-renders ALL consumers, even if they only use a part of the context that did not change. For frequently changing values, this causes performance issues — which is why libraries like Zustand exist.
- Why do we create a custom hook (like
useAuth) that wrapsuseContextinstead of usinguseContext(AuthContext)directly? — The custom hook provides two benefits: (1) It encapsulates context access, so consumers do not need to import the raw context object. (2) Theif (context === undefined)check throws a descriptive error if a component tries to use the context outside its Provider, catching a common bug early with a clear message instead of getting silentundefinedvalues throughout the app. - When should you use
useReducerinstead ofuseState? Give an example of state that is too complex foruseState. — UseuseReducerwhen state has multiple related fields that change together, when the next state depends on the previous state in complex ways, or when there are many distinct update actions. Example: a shopping cart with add, remove, update quantity, apply coupon, and clear actions. WithuseState, you would have a single complex setter with many conditionals. WithuseReducer, each action is a distinct, testable case in the reducer. - How does combining Context + useReducer compare to using Pinia in Vue? What would make you reach for Zustand instead? — Context + useReducer gives you a Provider-based state container with dispatch actions, similar to Pinia's stores with actions. Key differences from Pinia: no devtools, no plugin ecosystem, and all context consumers re-render on any state change. Zustand improves on Context by using selector-based subscriptions (components only re-render when their specific slice of state changes), requires no Provider wrapper, and has a simpler API.
- What is the purpose of
useMemoanduseCallback? Why does the lecture warn against premature optimization with these hooks? —useMemocaches a computed value;useCallbackcaches a function reference. Both only recompute when dependencies change. The warning exists because these hooks add memory overhead and code complexity. In most cases, React re-renders are fast enough that memoization adds no visible benefit. Measure first with React DevTools Profiler before adding these hooks. - Why are error boundaries the only remaining use case for class components in React? Why has React not added a hook equivalent? — Error boundaries use the
componentDidCatchandgetDerivedStateFromErrorlifecycle methods that have no hook equivalents. Error boundaries are inherently about catching errors in the render tree (a "parent catches child" pattern), which does not map cleanly to hooks (which operate within a single component). In Next.js App Router, theerror.tsxfile convention eliminates most cases where you would write error boundaries manually. - The
usehook from React 19 can be called inside conditionals, unlike all other hooks. Why is this significant? — All other hooks must be called at the top level because React tracks them by call order.usebreaks this rule because it reads values (from Promises or Context) without creating persistent state slots. When reading a Promise, it integrates with Suspense — the component suspends until the Promise resolves. This enables patterns like "only read this context if a condition is true," which was previously impossible. - How does Zustand's architecture differ from React Context? What does "no Provider needed" mean practically? — Context requires a Provider component wrapping the consuming tree. If you forget the Provider or place it wrong, consumers get
undefined. Zustand stores are module-level singletons created bycreate()— they exist outside the React tree. Any component can import and call the hook directly, with no wrapper components or nesting order concerns. The trade-off is that Zustand stores are global singletons, making them harder to scope to subtrees or test in isolation.