Skip to main content

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.

Vue comparison

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.

Vue comparison

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>
</>
);
}
Vue comparison

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 valueState has multiple related fields
Updates are simple assignmentsUpdates have complex rules/logic
Few state transitionsMany 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>
);
};
Vue comparison

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>
));
danger

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.

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>
</>
);
}
Vue comparison

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)

LibraryUse case
ZustandSimple global state (like Pinia for React)
Redux ToolkitLarge apps with complex state, time-travel debugging
TanStack QueryServer state (caching, refetching, synchronization)
JotaiAtomic 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} />;

React 19 documentation


Self preparation QA

Be prepared to explain topics like these:

  1. 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.
  2. Why do we create a custom hook (like useAuth) that wraps useContext instead of using useContext(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) The if (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 silent undefined values throughout the app.
  3. When should you use useReducer instead of useState? Give an example of state that is too complex for useState. — Use useReducer when 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. With useState, you would have a single complex setter with many conditionals. With useReducer, each action is a distinct, testable case in the reducer.
  4. 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.
  5. What is the purpose of useMemo and useCallback? Why does the lecture warn against premature optimization with these hooks?useMemo caches a computed value; useCallback caches 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.
  6. 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 componentDidCatch and getDerivedStateFromError lifecycle 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, the error.tsx file convention eliminates most cases where you would write error boundaries manually.
  7. The use hook 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. use breaks 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.
  8. 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 by create() — 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.