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

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.

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 { 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.

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
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} />;

React 19 documentation