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>
);
}childrenis whatever you put between the component's opening and closing tags.React.ReactNodeaccepts strings, numbers, elements, fragments, ornull-- 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
childrenprop 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) ordefaultValue(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
useContextreturnsnull, 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
setThemedo not re-render whenthemechanges. - Wrap setter in
useMemooruseCallbackso 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 --
getDerivedStateFromErrorhas 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 incomponentDidCatch.
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)mountsnodeinsidecontainer, 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; otherwisedocumentthrows 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 withuse).- 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
useTransitionoruseActionStateto 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 --
useCallbackgives it a stable identity somemocan 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/useMemoentirely -- 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