Practical Design Patterns for React + TypeScript
Recognize the classic object-oriented and functional design patterns as they actually appear in React codebases, implement each with TypeScript, and judge when a pattern earns its complexity versus when it is overengineering.
Design patterns are not a checklist you apply on purpose — they are names for shapes that good code keeps converging on. In a React codebase you will meet the Adapter, Factory, Strategy, Observer, Repository, Dependency Injection, Provider, Compound Component, Render Prop, State Machine, and Event/Command patterns constantly, usually without anyone announcing them. Knowing the names matters for two reasons: it lets a team talk about a shape in one word instead of re-explaining it every time, and it gives you a way to notice when you have reached for more structure than a problem needs. Every pattern below is shown as real, runnable-shape TypeScript so you can see the mechanics, not just the theory.
Structural patterns: Adapter, Factory, Repository
The Adapter pattern wraps a mismatched interface so the rest of your code can depend on a shape it controls, rather than a shape an external library imposes. In React this shows up constantly at the edge of your app: a third-party date library, a legacy REST client, an analytics SDK — each has its own API, and if you call it directly from fifteen components, upgrading or swapping it later means touching fifteen components. An adapter is a thin module that translates the external shape into your own internal one.
A Factory centralizes the *decision* of which concrete thing to construct, so callers ask for a category ('give me a notification') instead of naming a concrete class or component directly ('give me an EmailNotification'). In React, factories often take the form of a function that returns a configured component, a hook, or a plain object — 'component factories' are common for things like table column definitions or form field renderers where the concrete type varies by data.
The Repository pattern gives your UI a single, storage-agnostic interface for reading and writing domain data, hiding whether the data actually comes from REST, GraphQL, IndexedDB, or an in-memory mock. Components and hooks depend on the repository's interface, never on the fetch or GraphQL client directly, which makes testing trivial (swap in a fake repository) and makes a backend migration a one-file change instead of a codebase-wide search-and-replace.
• Adapter — wraps a foreign API into your own shape at the boundary of your app. • Factory — centralizes 'which concrete thing do we build' behind one function. • Repository — hides the data source (REST/GraphQL/cache/mock) behind one interface. • All three exist to isolate *volatility*: the parts of your system most likely to change should be the parts hidden behind an interface.
Behavioral and structural patterns native to React
Strategy lets you swap an algorithm at runtime by passing it in as data — in React this is usually just 'a function passed as a prop,' which is why React developers often use Strategy without naming it. Observer is the foundation of React's own reactivity: state setters notify subscribers (components) that re-render; custom event emitters, WebSocket handlers, and libraries like Zustand or Redux are all Observer under the hood.
Dependency Injection (DI) means a component receives its collaborators (a service, a config, a logger) from the outside instead of constructing them itself — in React this is done overwhelmingly through Context and props, not through a DI container or decorators as in some other frameworks. The Provider pattern is DI's most common concrete form in React: a component higher in the tree supplies a value that many descendants consume via useContext, without every intermediate component having to thread the value through as props.
Compound Components let a set of components share implicit state through Context while presenting a clean, declarative JSX API to the consumer — think <Tabs><Tabs.List><Tabs.Tab/></Tabs.List><Tabs.Panels>... where the pieces coordinate without the consumer wiring any state by hand. Render Props (and its modern descendant, the custom hook) expose reusable *behavior* while letting the caller control the rendering: a component computes some state or logic and hands it to a function-as-child (or, more idiomatically today, a hook returns the state and the consumer renders however it likes).
• Strategy — swap behavior via a prop-passed function; no class hierarchy needed. • Observer — the mechanism under React's own re-render model and most state libraries. • Dependency Injection / Provider — supply collaborators via Context instead of hardcoding them. • Compound Component — related components share hidden state through Context, exposed as clean JSX. • Render Prop / custom hook — separate reusable logic from how it's rendered.
State Machine and Event/Command patterns
A State Machine models a component or flow as a finite set of named states with explicit, allowed transitions between them, instead of a pile of independent booleans that can combine into nonsensical combinations. It deserves its own full lesson (Module EE) because it is one of the highest-leverage patterns for eliminating an entire category of React bugs: impossible UI states.
The Event/Command pattern separates 'what happened' from 'what should be done about it.' An event (e.g., { type: 'CHECKOUT_SUBMITTED' }) is a fact describing something that already occurred; a command (e.g., submitCheckout()) is an instruction to do something. Reducers (useReducer, Redux) formalize this: components dispatch events/actions, and a single reducer function decides what state transition — the command — results. This decouples the *triggering* of a change from the *logic* of the change, which makes the logic independently testable.
• State Machine — states, events, and explicit transitions instead of ad-hoc booleans. • Event/Command — dispatch a description of what happened; a separate handler decides what to do.
When a pattern helps versus when it is overengineering
Every pattern here trades a small amount of *up-front* structure for a larger amount of *long-term* flexibility and testability. That trade is only worth making when the flexibility will actually be used. A Repository around an API you call from exactly one component, that will never be mocked, tested, or swapped, is pure ceremony — a direct fetch call is simpler and just as correct. A Factory that produces exactly one concrete type is a function wearing a costume.
The signal to introduce a pattern is not 'this looks like it could use a pattern' — it is a concrete forward-looking need: you already know you'll need to mock this in tests, you already have two implementations (not a hypothetical third), or the current code has visibly become hard to change because a shape isn't isolated behind an interface. Retrofit patterns when pain shows up; don't pre-pay for flexibility you're guessing you'll want.
A practical rule of thumb: prefer plain functions and props until you feel real friction (prop drilling six levels deep, a component that keeps growing new booleans, a data source you're manually re-implementing behind three different call sites). Then reach for the specific pattern that removes that specific friction — not a full pattern-catalog rewrite.
• Good sign to introduce a pattern: you have two real implementations, not a hypothetical one. • Good sign to introduce a pattern: tests are painful because a dependency can't be swapped. • Bad sign: you're adding an interface 'just in case' with only one implementation ever planned. • Bad sign: the pattern makes the code harder to read for a reader who doesn't already know the pattern's name.
Shape reference: the patterns side by side
Each of these is a plain function or class — React does not require special syntax for structural patterns. Compound Component, Render Prop, Provider, and State Machine are shown in full in the Examples section below because they depend on JSX and hooks.
// Adapter — normalize a foreign shape into your own
interface AppUser { id: string; displayName: string }
function adaptLegacyUser(raw: { user_id: number; full_name: string }): AppUser {
return { id: String(raw.user_id), displayName: raw.full_name };
}
// Factory — centralize "which concrete thing"
type FieldKind = "text" | "number" | "date";
function createFieldRenderer(kind: FieldKind) {
const renderers: Record<FieldKind, (v: unknown) => string> = {
text: (v) => String(v),
number: (v) => Number(v).toFixed(2),
date: (v) => new Date(v as string).toLocaleDateString(),
};
return renderers[kind];
}
// Strategy — behavior passed in as data
function sortItems<T>(items: T[], strategy: (a: T, b: T) => number): T[] {
return [...items].sort(strategy);
}
// Repository — hide the data source behind one interface
interface UserRepository {
getById(id: string): Promise<AppUser>;
}
class ApiUserRepository implements UserRepository {
async getById(id: string): Promise<AppUser> {
const res = await fetch(`/api/users/${id}`);
return adaptLegacyUser(await res.json());
}
}Code Examples
The component never touches Stripe's field names or its cents-based amount directly. If you switch payment providers later, only adaptStripeCharge (and a sibling adaptOtherProviderCharge) need to change — every component that renders a PaymentResult is untouched.
function adaptStripeCharge(raw: StripeChargeResult): PaymentResult {
createFieldComponent is the factory: it hides the decision of which concrete input to render behind a single FieldKind value, so a form-rendering loop never needs a big if/else chain checking field types.
function createFieldComponent(config: FieldConfig): React.FC<...> {
CheckoutButton depends only on the AnalyticsService interface, never on a concrete implementation. Swapping the real service for a no-op fake in tests, or a different vendor in production, requires changing only the single <AnalyticsProvider> call at the root.
const AnalyticsContext = React.createContext<AnalyticsService | null>(null);
Common mistake: Forgetting the null-check in useAnalytics means a missing provider fails silently later with a confusing runtime error instead of a clear message at the call site.
Tabs, Tabs.List, Tabs.Tab, and Tabs.Panel share state through TabsContext invisibly. The consumer's JSX reads declaratively — no activeTab state, no manual click handlers — while still being free to reorder or omit pieces.
Tabs.List = TabList; Tabs.Tab = Tab; Tabs.Panel = TabPanel;
Both forms decouple 'tracking the mouse' from 'how the position is rendered.' The hook form avoids an extra nesting level in JSX and composes more naturally with other hooks, which is why render props are now mostly seen in older codebases or libraries that must support class components.
function useMousePosition(): { x: number; y: number } {
// Third-party SDK gives us an awkward, inconsistent shape we don't control.
interface StripeChargeResult {
id: string;
amount_captured_cents: number;
status: "succeeded" | "failed" | "pending";
}
// Our app's own, stable shape — everything downstream depends on THIS.
interface PaymentResult {
chargeId: string;
amountDollars: number;
isSuccessful: boolean;
}
function adaptStripeCharge(raw: StripeChargeResult): PaymentResult {
return {
chargeId: raw.id,
amountDollars: raw.amount_captured_cents / 100,
isSuccessful: raw.status === "succeeded",
};
}
function PaymentSummary({ charge }: { charge: StripeChargeResult }) {
const result = adaptStripeCharge(charge);
return (
<p>
{result.isSuccessful ? "Paid" : "Not paid"}: ${result.amountDollars.toFixed(2)}
</p>
);
}type FieldKind = "text" | "select" | "checkbox";
interface FieldConfig {
kind: FieldKind;
label: string;
options?: string[];
}
function createFieldComponent(config: FieldConfig): React.FC<{ value: unknown; onChange: (v: unknown) => void }> {
switch (config.kind) {
case "text":
return ({ value, onChange }) => (
<input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
);
case "select":
return ({ value, onChange }) => (
<select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
{config.options?.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
</select>
);
case "checkbox":
return ({ value, onChange }) => (
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
);
}
}
// Usage: a form driven entirely by config, no per-field JSX branching at call sites.
function DynamicField({ config, value, onChange }: {
config: FieldConfig; value: unknown; onChange: (v: unknown) => void;
}) {
const Field = createFieldComponent(config);
return (
<label>
{config.label}
<Field value={val
…interface AnalyticsService {
track(event: string, props?: Record<string, unknown>): void;
}
const AnalyticsContext = React.createContext<AnalyticsService | null>(null);
function AnalyticsProvider({ service, children }: {
service: AnalyticsService; children: React.ReactNode;
}) {
return (
<AnalyticsContext.Provider value={service}>
{children}
</AnalyticsContext.Provider>
);
}
function useAnalytics(): AnalyticsService {
const service = React.useContext(AnalyticsContext);
if (!service) throw new Error("useAnalytics must be used within AnalyticsProvider");
return service;
}
// Any depth of nesting can call useAnalytics() without props being threaded through.
function CheckoutButton() {
const analytics = useAnalytics();
return (
<button onClick={() => analytics.track("checkout_clicked")}>
Checkout
</button>
);
}
// At the app root, inject the real implementation (or a fake one in tests):
// <AnalyticsProvider service={realAnalyticsService}><App /></AnalyticsProvider>interface TabsContextValue {
activeId: string;
setActiveId: (id: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | null>(null);
function Tabs({ defaultId, children }: { defaultId: string; children: React.ReactNode }) {
const [activeId, setActiveId] = React.useState(defaultId);
return (
<TabsContext.Provider value={{ activeId, setActiveId }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function useTabsContext(): TabsContextValue {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
function TabList({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>;
}
function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const { activeId, setActiveId } = useTabsContext();
return (
<button
role="tab"
aria-selected={activeId === id}
onClick={() => setActiveId(id)}
>
{children}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
const { activeId } = useTabsContext();
return activeId === id ? <div r
…// Classic render-prop form: a component exposes state via a function-as-child.
function MouseTracker({ children }: {
children: (pos: { x: number; y: number }) => React.ReactNode;
}) {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
React.useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return <>{children(pos)}</>;
}
// Usage: <MouseTracker>{(pos) => <p>{pos.x}, {pos.y}</p>}</MouseTracker>
// Modern equivalent: the same reusable logic as a hook — usually preferred today.
function useMousePosition(): { x: number; y: number } {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
React.useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return pos;
}
// Usage: const pos = useMousePosition(); return <p>{pos.x}, {pos.y}</p>;Where each pattern sits in a typical request flow
Structural patterns (Adapter, Factory, Repository) typically live at the data boundary; behavioral patterns (Strategy, Observer, DI/Provider) shape how components get their collaborators; compositional patterns (Compound Component, Render Prop) shape how components share behavior with each other in JSX.
UI Component
|
| useContext(ServiceContext) <-- Dependency Injection / Provider
v
Service Interface <---- Strategy (swap the algorithm passed in)
|
| calls
v
Repository Interface <-- hides HOW data is fetched
|
+---> ApiRepository ----> Adapter ----> fetch() ----> raw API response
+---> MockRepository (used in tests, no network at all)
Meanwhile, inside the component tree:
Compound Component (shared Context state) -- renders via --> Render Prop / Hook
State changes are dispatched as Events -> a reducer (Command) decides the transition
React's own re-render on setState = Observer notifying subscribersImportant Rules
Name the pattern only after you've solved the problem the simple way and found it doesn't scale — don't pattern-match backwards from the catalog.
A Repository, Adapter, or Factory with exactly one real implementation and no test-swapping need is usually unnecessary indirection.
Providers should throw a clear error (not silently return undefined) when consumed outside their tree.
Compound components should still work if the consumer reorders or conditionally omits child pieces — don't rely on child array position.
Prefer a custom hook over a render prop for new code; keep render props only where you must support class-component consumers.
Where this shows up in production
A mid-size SaaS dashboard typically has: a Repository layer around its API client (so the data layer can be mocked in Storybook and tests), a handful of Provider-based services (auth, feature flags, analytics, theming) injected once at the root, two or three Compound Components for genuinely complex composite UI (Tabs, Accordion, a multi-step Wizard), and Strategy functions scattered through anywhere sorting, filtering, or validation logic needs to vary by context. Factories tend to appear specifically around dynamic form or table rendering. If a codebase has significantly more pattern machinery than that relative to its size, it is very often over-engineered rather than well-architected — the patterns should be traceable to a concrete need you can point to.
Wrapping every single API call in its own Repository class 'for consistency,' even for a one-off admin page nobody will ever test or swap.
Reserve Repository for domain data genuinely shared across features, or where you already know you need a test double.
Blanket application of a pattern regardless of need adds a layer of indirection every future reader has to mentally unwrap, without ever paying off.
const service = useContext(ServiceContext)! (non-null assertion) instead of checking for a missing provider.
if (!service) throw new Error('useX must be used within <XProvider>') inside a small custom hook wrapper.
The ! assertion silences TypeScript but not reality — a missing provider will surface as a confusing crash deep inside unrelated code instead of a clear error at the point of misuse.
Building a Compound Component's shared state with array indices from React.Children.map instead of Context.
Share state through a Context created alongside the parent component, as shown in the Tabs example.
Index-based children APIs break the moment a consumer wraps a child in a <Fragment> or conditional — Context-based sharing is resilient to how the JSX tree is actually structured.
Which pattern is React's own component re-rendering on setState fundamentally an example of? A. Factory B. Observer C. Adapter D. Compound Component
B. Observer — State setters notify subscribing components to re-render — this publish/notify relationship is the Observer pattern, even though React never names it that way in its docs.
A junior developer wraps a single, never-to-be-mocked API call in a full Repository class with an interface, in a small internal admin tool. What's the best assessment? A. Correct — every data access should go through a Repository, no exceptions. B. Overengineering — with one implementation and no testing/swapping need, a direct call is simpler and just as correct. C. Incorrect — Repository only applies to GraphQL APIs, not REST. D. Correct, but only if the interface has at least five methods.
B. Overengineering — with one implementation and no testing/swapping need, a direct call is simpler and just as correct. — Patterns pay for themselves when there's a real need for flexibility or testability. A one-off call with no such need doesn't benefit from the extra indirection — it just adds a layer future readers must unwrap.
What is the primary difference between a Render Prop and a custom hook that does the same job? A. Render props can't use useEffect internally, hooks can. B. Hooks require class components; render props don't. C. Both decouple logic from rendering, but the hook form avoids an extra JSX nesting level and composes more naturally with other hooks. D. Render props are faster at runtime than hooks.
C. Both decouple logic from rendering, but the hook form avoids an extra JSX nesting level and composes more naturally with other hooks. — They solve the same problem (share reusable logic without dictating how it's rendered), but hooks are generally preferred in modern React because they avoid the nesting ('wrapper hell') that render props introduce.
You're building a notifications feature that must support in-app toasts, email, and (later) push notifications, and needs to be fully unit-testable without hitting a real network or email service. Sketch the interfaces and pattern(s) you'd use, and explain in one paragraph which pattern from this lesson each piece maps to.
Define a Notifier interface with a send(message, recipient) method (Strategy — the concrete channel is behavior swapped in). A createNotifier(channel: 'toast' | 'email' | 'push') function is a Factory that returns the right concrete Notifier. Components never import a concrete class — they call useNotifier(), which reads from a NotifierContext supplied by a <NotifierProvider notifier={...}> at the root (Dependency Injection / Provider). Tests inject a FakeNotifier that records calls in an array instead of sending anything real.
Think: one interface all notification channels implement (which pattern?), something that decides which concrete channel to construct from a NotificationChannel enum (which pattern?), and how the UI would receive its notifier without importing a concrete implementation directly (which pattern?).