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
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):
// 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 { 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.
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
function ThemedButton() {
const theme = use(ThemeContext);
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} />;