React SME Cookbook
All FAQs
basicsreact-patternsexamplescomposition

React Patterns Basics

11 examples to get you started with React Patterns -- 7 basic and 4 intermediate.

Prerequisites

No extra packages required -- every pattern on this page ships with React. A standard React project (Next.js, Vite, or CRA) with TypeScript is enough.

The patterns below are ways of thinking about components -- how they compose, how they share state, where to put loading/error boundaries. They are not APIs you install; they are reusable shapes you recognize.


Basic Examples

1. Composition with Children

Build layout and wrapper components by accepting children instead of subclassing or configuring.

function Card({ children }: { children: React.ReactNode }) {
  return <div className="border rounded p-4 shadow">{children}</div>;
}
 
// Usage
function Page() {
  return (
    <Card>
      <h2>Title</h2>
      <p>Any content the parent wants to pass in.</p>
    </Card>
  );
}
  • children is whatever you put between the component's opening and closing tags.
  • React.ReactNode accepts strings, numbers, elements, fragments, or null -- the widest "renderable" type.
  • Favor composition over props-for-everything -- it keeps the API small and lets consumers drive layout.
  • When you need named "slots" (header + footer), accept them as named props: <Card header={...} footer={...} />.

Related: Composition Over Inheritance -- slots, compound components, layout patterns | Components (Fundamentals) -- the children prop in depth


2. Controlled vs Uncontrolled

Decide who owns the component's state: the parent (controlled) or the component itself (uncontrolled).

import { useState } from "react";
 
// Controlled: parent owns the value
function ControlledInput({
  value, onChange,
}: {
  value: string;
  onChange: (v: string) => void;
}) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
 
// Uncontrolled: component owns its own state internally
function UncontrolledInput({ defaultValue = "" }: { defaultValue?: string }) {
  const [value, setValue] = useState(defaultValue);
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
  • Controlled lets the parent validate, transform, or reset the value -- the right choice for forms and wizards.
  • Uncontrolled is simpler for self-contained widgets that never need external coordination.
  • Offer both on a reusable component: accept value/onChange (controlled) or defaultValue (uncontrolled), never mix them.
  • Switching modes mid-lifecycle throws a "controlled to uncontrolled" warning -- pick one at mount and stick with it.

Related: Controlled vs Uncontrolled -- API shape, both-modes components, edge cases | Forms: Controlled vs Uncontrolled -- the form-specific side


3. Render Props

Share behavior by passing a function as a child or prop, returning JSX based on internal state.

import { useState } from "react";
 
function Toggle({
  render,
}: {
  render: (state: { on: boolean; toggle: () => void }) => React.ReactNode;
}) {
  const [on, setOn] = useState(false);
  return <>{render({ on, toggle: () => setOn((v) => !v) })}</>;
}
 
// Usage
function App() {
  return (
    <Toggle
      render={({ on, toggle }) => (
        <button onClick={toggle}>{on ? "ON" : "OFF"}</button>
      )}
    />
  );
}
  • The render prop is a function; the shared component calls it with state and helpers.
  • Consumers decide what to render -- one component, many presentations.
  • Custom hooks cover most cases render props used to solve, but render props still shine for rendering-specific APIs (positioning, measurement, motion libraries).
  • Name the prop render, children, or something descriptive (renderItem); be consistent within a codebase.

Related: Render Props -- when to still use them, hooks alternative | Higher-Order Components -- a sibling indirection pattern


4. Compound Components

Design multi-part components that share implicit state through context -- the parent coordinates, the children compose.

import { createContext, useContext, useState } from "react";
 
const TabsCtx = createContext<{ active: string; setActive: (v: string) => void; } | null>(null);
 
function Tabs({ defaultValue, children }: { defaultValue: string; children: React.ReactNode }) {
  const [active, setActive] = useState(defaultValue);
  return <TabsCtx.Provider value={{ active, setActive }}>{children}</TabsCtx.Provider>;
}
 
function Tab({ value, children }: { value: string; children: React.ReactNode }) {
  const ctx = useContext(TabsCtx)!;
  return (
    <button onClick={() => ctx.setActive(value)} aria-pressed={ctx.active === value}>
      {children}
    </button>
  );
}
 
function Panel({ value, children }: { value: string; children: React.ReactNode }) {
  const ctx = useContext(TabsCtx)!;
  return ctx.active === value ? <div>{children}</div> : null;
}
 
// Usage
// <Tabs defaultValue="a">
//   <Tab value="a">A</Tab><Tab value="b">B</Tab>
//   <Panel value="a">Content A</Panel><Panel value="b">Content B</Panel>
// </Tabs>
  • The parent owns state; children read via context -- the consumer never threads props manually.
  • Export the sub-components under the parent's namespace (Tabs.Tab, Tabs.Panel) for a clean API.
  • Enforce the parent boundary: throw a helpful error if useContext returns null, so misuse fails loudly.
  • This is the backbone of shadcn and Radix UI primitives -- learn once, recognize everywhere.

Related: Compound Components -- deep dive, TypeScript typing | Context Patterns -- context best practices


5. Context Pattern

Share data across a subtree without prop drilling; split contexts by update frequency to avoid over-rendering.

import { createContext, useContext, useMemo, useState } from "react";
 
type Theme = "light" | "dark";
const ThemeCtx = createContext<Theme>("light");
const SetThemeCtx = createContext<(t: Theme) => void>(() => {});
 
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const set = useMemo(() => setTheme, []);
  return (
    <ThemeCtx.Provider value={theme}>
      <SetThemeCtx.Provider value={set}>{children}</SetThemeCtx.Provider>
    </ThemeCtx.Provider>
  );
}
 
function ThemeToggle() {
  const theme = useContext(ThemeCtx);
  const setTheme = useContext(SetThemeCtx);
  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      {theme}
    </button>
  );
}
  • Split read and write contexts -- components that only call setTheme do not re-render when theme changes.
  • Wrap setter in useMemo or useCallback so the write context's value stays referentially stable.
  • Never put frequently-changing values (mouse position, scroll) in context -- every consumer re-renders.
  • For server state (user, cart, theme across tabs), reach for Zustand or a URL-synced approach instead.

Related: Context Patterns -- splitting, optimization, server components | useContext -- the underlying hook | Context vs. Zustand -- when to use each


6. Error Boundary

Catch render-time errors in a subtree and render a fallback instead of a white screen.

"use client";
import { Component, type ReactNode } from "react";
 
interface State { error: Error | null; }
 
class ErrorBoundary extends Component<{ children: ReactNode }, State> {
  state: State = { error: null };
 
  static getDerivedStateFromError(error: Error): State {
    return { error };
  }
 
  componentDidCatch(error: Error) {
    console.error("UI error:", error);
  }
 
  render() {
    if (this.state.error) {
      return <p role="alert">Something went wrong: {this.state.error.message}</p>;
    }
    return this.props.children;
  }
}
 
// Usage
// <ErrorBoundary><BuggyChart /></ErrorBoundary>
  • Error boundaries must be class components -- getDerivedStateFromError has no hook equivalent yet.
  • They catch errors during render, lifecycle methods, and constructors -- not event handlers, async code, or server components.
  • Place them at meaningful boundaries: per-route, per-panel, per-widget. One big top-level boundary hides too much.
  • For production apps, use react-error-boundary (lighter, hooks-friendly API) and log to Sentry/Datadog in componentDidCatch.

Related: Error Boundaries -- react-error-boundary, Next.js error.tsx, logging | Suspense -- the loading counterpart


7. Portal

Render children into a DOM node outside the parent's tree -- escape overflow: hidden and z-index traps.

"use client";
import { createPortal } from "react-dom";
 
function Modal({
  open, onClose, children,
}: {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  if (!open || typeof document === "undefined") return null;
 
  return createPortal(
    <div
      onClick={onClose}
      style={{
        position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)",
        display: "grid", placeItems: "center",
      }}
    >
      <div onClick={(e) => e.stopPropagation()} style={{ background: "white", padding: 24 }}>
        {children}
      </div>
    </div>,
    document.body,
  );
}
  • createPortal(node, container) mounts node inside container, but events still bubble to the React parent.
  • Perfect for modals, tooltips, and dropdowns -- they render at the top of the DOM regardless of where you wrote them.
  • Guard with typeof document === "undefined" so it is safe in SSR; otherwise document throws on the server.
  • Manage focus: trap it in the modal on open, restore it to the opener on close -- accessibility depends on it.

Related: React Portals -- focus trap, accessibility, z-index recipes | Modal Component -- production modal patterns


Intermediate Examples

8. Suspense for Async UI

Declaratively show a fallback while an async child resolves -- no isLoading prop passing.

"use client";
import { Suspense, use } from "react";
 
interface User { id: number; name: string; }
 
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  return <h2>{user.name}</h2>;
}
 
export default function UserPage({
  userPromise,
}: {
  userPromise: Promise<User>;
}) {
  return (
    <Suspense fallback={<p>Loading user...</p>}>
      <UserCard userPromise={userPromise} />
    </Suspense>
  );
}
  • <Suspense fallback={...}> catches child components that "suspend" (Server Components awaiting data, Client Components reading a promise with use).
  • The fallback renders immediately; the real content replaces it when the async work finishes.
  • Create the promise in a parent (often a Server Component) and pass it down -- do not re-create it every render.
  • Combine with an <ErrorBoundary> so rejected promises show an error UI rather than propagating up the tree.

Related: Suspense Boundaries -- placement, streaming, edge cases | use hook -- reading promises and context | Next.js Streaming -- Suspense in Server Components


9. Higher-Order Component

Wrap a component to inject shared behavior -- auth gating, logging, feature flags.

"use client";
import { useEffect } from "react";
 
function withLogging<P extends object>(
  Component: React.ComponentType<P>,
  label: string
) {
  return function LoggedComponent(props: P) {
    useEffect(() => {
      console.log(`[${label}] mounted`);
      return () => console.log(`[${label}] unmounted`);
    }, []);
    return <Component {...props} />;
  };
}
 
// Usage
function Dashboard({ userId }: { userId: string }) {
  return <p>Dashboard for {userId}</p>;
}
 
const LoggedDashboard = withLogging(Dashboard, "Dashboard");
  • An HOC is a function that takes a component and returns a new one with extra behavior.
  • Use generics (<P extends object>) so the wrapped component keeps its original prop types.
  • Custom hooks usually replace HOCs in modern React -- reach for HOCs only when you need to inject JSX or wrap class components.
  • Name the returned component (function LoggedComponent(...)) so React DevTools shows something useful.

Related: Higher-Order Components -- typing, gotchas, when to prefer hooks | Render Props -- the other "indirection" pattern


10. State Machine for UI Logic

Model complex UI states as explicit transitions -- no more "impossible" prop combinations.

import { useReducer } from "react";
 
type Status =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "success"; data: string }
  | { kind: "error"; message: string };
 
type Event =
  | { type: "FETCH" }
  | { type: "RESOLVE"; data: string }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };
 
function reducer(state: Status, event: Event): Status {
  switch (state.kind) {
    case "idle":    return event.type === "FETCH" ? { kind: "loading" } : state;
    case "loading": return event.type === "RESOLVE" ? { kind: "success", data: event.data }
                  : event.type === "REJECT" ? { kind: "error", message: event.message }
                  : state;
    case "success":
    case "error":   return event.type === "RESET" ? { kind: "idle" } : state;
  }
}
 
export default function DataPanel() {
  const [state, dispatch] = useReducer(reducer, { kind: "idle" } as Status);
 
  return (
    <div>
      {state.kind === "idle" && <button onClick={() => dispatch({ type: "FETCH" })}>Load</button>}
      {state.kind === "loading" && <p>Loading...</p>}
      {state.kind === "success" && <p>Got: {state.data}</p>}
      {state.kind === "error" && <p>Error: {state.message}</p>}
    </div>
  );
}
  • A discriminated union makes invalid combinations (loading: true, error: "...", data: "x", isLoading: true) impossible.
  • The reducer is the single place where transitions live -- trivial to unit-test and to draw as a diagram.
  • For larger state charts (hierarchical states, guards, side effects), graduate to XState; the small version here covers most UIs.
  • Pair with useTransition or useActionState to trigger state changes from async work.

Related: State Machines for UI Logic -- deeper examples, XState integration | useReducer -- the hook behind it


11. Performance: React.memo + Stable Props

Prevent a child from re-rendering when its parent re-renders but its props have not changed.

import { memo, useCallback, useState } from "react";
 
const Row = memo(function Row({
  label, onSelect,
}: {
  label: string;
  onSelect: (label: string) => void;
}) {
  return <li onClick={() => onSelect(label)}>{label}</li>;
});
 
export default function List({ items }: { items: string[] }) {
  const [selected, setSelected] = useState<string | null>(null);
 
  // Stable identity -- useCallback keeps memo'd rows from re-rendering
  const onSelect = useCallback((label: string) => setSelected(label), []);
 
  return (
    <>
      <p>Selected: {selected ?? "none"}</p>
      <ul>
        {items.map((item) => (
          <Row key={item} label={item} onSelect={onSelect} />
        ))}
      </ul>
    </>
  );
}
  • memo(Component) skips re-renders when props are referentially equal to the previous render.
  • An inline arrow function would create a new reference every render -- useCallback gives it a stable identity so memo can actually short-circuit.
  • Measure first: the React DevTools Profiler tells you which components are expensive before you reach for memo.
  • With the React Compiler (React 19+), you can often delete memo/useCallback/useMemo entirely -- the compiler handles it.

Related: React Performance Optimization -- profiling, keys, list virtualization | Re-renders -- what triggers renders and how to cut them | React Compiler -- auto-memoization