React Design Patterns: The Art of Component Abstraction
How you split components, manage state, and reuse logic is determined by design patterns. This article explains common React patterns: container/presentational separation, custom Hooks, Render Props, Compound Components, and the Provider pattern, along with the problems each one solves.
Key Points
- Topic Focus: React、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Anyone can write React, but once a project grows, it can fall apart: components grow to 300 lines, state is scattered across ten files, and the same fetching logic gets copied five times. The difference is often not React itself, but the level of abstraction: what you put into a component, what you extract, what you share, and what you isolate. That is what design patterns are meant to solve.
The patterns below are not clever tricks. They are code-organization conventions distilled from a decade of community pitfalls. Each maps to a specific class of problem; remembering the problem matters more than remembering the name.
1. Why Design Patterns Matter
React itself gives you only three things: components, props, and hooks. How you use them is up to you. That freedom is an advantage in small projects and a disaster in medium-to-large ones.
You have certainly seen these typical symptoms:
- A component calls APIs, manages state, renders UI, and handles animations all at once. Changing one line requires reading five hundred.
- The same fetch-user-information + handle-loading logic is written separately in
Header,Profile, andSettings. When a backend field changes, all three places break together. - Two sibling components need to share state, so you can only put it into their common ancestor, which grows fatter and fatter.
- A form library uses
<Form><Field><Submit>. You want users to have complete layout freedom, but do not want them to manage every field'svalue/onChange.
Design patterns are the standard answers to these problems.
- Container/presentational separation solves unclear component responsibilities.
- Custom Hooks solve logic reuse.
- Compound Components solve flexible composition + internal state sharing.
- The Provider pattern solves state sharing across levels.
- Inversion of control solves imperative parent-child communication.
Once you understand the problems, patterns are just names. Let's break them down one by one.
2. Container and Presentational Component Separation (Smart / Dumb)
This is the oldest pattern and one of the most commonly misunderstood. Its core idea is simple: separate what something does from how it is rendered.
- Container components (Smart): manage data fetching, state, side effects, and business logic. They write almost no UI and are responsible only for feeding data to child components.
- Presentational components (Dumb): pure UI. All data comes through props; they do not make requests or store global state. They are reusable, testable, and suitable for Storybook.
The benefit is that UI components become dumb, so you can reuse them with different data sources. Logic components can be tested independently without mocking rendering.
// 展示组件:纯 UI,不知道数据从哪来
type UserCardProps = {
name: string;
avatar: string;
loading?: boolean;
};
function UserCard({ name, avatar, loading }: UserCardProps) {
if (loading) return <div className="h-16 animate-pulse rounded bg-gray-200" />;
return (
<div className="flex items-center gap-3">
<img src={avatar} alt={name} className="h-12 w-12 rounded-full" />
<span className="font-medium">{name}</span>
</div>
);
}
// 容器组件:管数据,把结果交给展示组件
import { useEffect, useState } from "react";
function UserCardContainer({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
return user
? <UserCard name={user.name} avatar={user.avatar} />
: <UserCard name="" avatar="" loading />;
}
My practical advice is this: do not treat it as dogma. If a component is used only once and its logic is just twenty lines, forcing it into two files is a burden. Separation becomes valuable only when a component truly becomes complex or its UI genuinely needs reuse. With Server Components, this pattern has changed form too: the container can be a Server Component that queries data directly, with presentational components beneath it. The idea has not changed; the boundary has moved downward.
3. Custom Hooks: The First Choice for Logic Reuse
The React team has a frequently repeated line: 90% of the problems people tried to solve with HOCs and Render Props should be solved with Hooks.
Hooks are currently the best solution for logic reuse in React. They package state + side effects into a function that any component can call, while keeping state isolated between components because every call is an independent instance.
Common scenarios include analytics, forms, debouncing, requests, and subscriptions to external stores. Below is a useFetch that is genuinely ready for production:
import { useEffect, useRef, useState } from "react";
type State<T> = {
data: T | null;
error: Error | null;
loading: boolean;
};
export function useFetch<T>(url: string, options?: RequestInit): State<T> {
const [state, setState] = useState<State<T>>({
data: null,
error: null,
loading: true,
});
// 防止组件卸载后还 setState,以及 url 变化时竞态
const aborted = useRef(false);
useEffect(() => {
aborted.current = false;
setState((s) => ({ ...s, loading: true, error: null }));
fetch(url, options)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data) => {
if (!aborted.current) setState({ data, error: null, loading: false });
})
.catch((error) => {
if (!aborted.current)
setState({ data: null, error, loading: false });
});
return () => {
aborted.current = true;
};
}, [url]);
return state;
}
// 用法:干净、可复用、状态隔离
function Profile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <UserCard name={user!.name} avatar={user!.avatar} />;
}
A hidden advantage of Hooks is this: they do not hijack your component tree. HOCs wrap components layer by layer, appear in DevTools as a pile of withX(withY(withZ(...))), and can cause prop conflicts. Hooks are flat: use one in a component when you need it, and delete it when you do not. For new reusable logic, start with a Hook by default.
4. Render Props: Mostly Replaced by Hooks, but Still Useful in Some Cases
Render Props means using a function-valued prop, usually called render or children, to dynamically decide what to render. The parent component provides data or logic, and the child is responsible only for calling that function.
import { useState, type ReactNode } from "react";
// 鼠标位置追踪器:逻辑在这里,渲染由使用者决定
type MouseTrackerProps = {
children: (pos: { x: number; y: number }) => ReactNode;
};
function MouseTracker({ children }: MouseTrackerProps) {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
{children(pos)}
</div>
);
}
// 用法:渲染完全由调用方控制
<MouseTracker>
{({ x, y }) => <div>当前坐标:{x}, {y}</div>}
</MouseTracker>
To be candid: 80% of this pattern's legacy uses should be rewritten with Hooks. The example above is a standard useMousePosition. Hooks are lighter, composable, and do not produce nested hell.
So when does Render Props still have value? When you need to keep state in the child component but want the parent to control the rendering structure. For example, an animation library's <Motion> may expose current progress, or a virtual list may expose currently visible items. Libraries such as React Motion and downshift still retain Render Props APIs because the boundary of I manage state while you manage rendering is more natural than a Hook in these cases. Learn to recognize it, but do not reach for it first.
5. Compound Components: The Essence of Compositional APIs
This is the pattern I consider the most elegant, and it is also the core approach used by Radix UI, shadcn/ui, and Headless UI.
Here is the problem: you want to create a Tabs component. The most straightforward API is configuration-based:
<Tabs items={[...]} activeIndex={0} onChange={...} />
It works, but the layout is fixed. Inserting a button between tabs, changing ordering, or customizing a particular panel's style becomes difficult.
Compound Components solve this by splitting a component into a group of small composable components. They share state internally through Context while hiding it completely from users:
import {
createContext,
useContext,
useState,
type ReactNode,
} from "react";
// 1) 内部 Context:把状态藏起来,只暴露给子组件
type TabsCtx = { value: string; setValue: (v: string) => void };
const TabsContext = createContext<TabsCtx | null>(null);
const useTabs = () => {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs 子组件必须放在 <Tabs> 内");
return ctx;
};
// 2) 容器:持有状态,提供 Context
function Tabs({
default_value,
children,
}: {
default_value: string;
children: ReactNode;
}) {
const [value, setValue] = useState(default_value);
return (
<TabsContext.Provider value={{ value, setValue }}>
{children}
</TabsContext.Provider>
);
}
// 3) 各子组件:从 Context 读状态,不知道彼此存在
function TabsList({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>;
}
function TabsTrigger({ value, children }: { value: string; children: ReactNode }) {
const { value: active, setValue } = useTabs();
return (
<button
role="tab"
aria-selected={active === value}
onClick={() => setValue(value)}
className={active === value ? "border-b-2 border-blue-500" : ""}
>
{children}
</button>
);
}
function TabsPanel({ value, children }: { value: string; children: ReactNode }) {
const { value: active } = useTabs();
if (active !== value) return null;
return <div role="tabpanel">{children}</div>;
}
// 4) 暴露组合式 API
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Panel = TabsPanel;
The API users receive looks like this:
<Tabs default_value="overview">
<Tabs.List>
<Tabs.Trigger value="overview">概览</Tabs.Trigger>
<Tabs.Trigger value="settings">设置</Tabs.Trigger>
{/* 想插个按钮?随便插 */}
<button className="ml-auto">导出</button>
</Tabs.List>
<Tabs.Panel value="overview"><Overview /></Tabs.Panel>
<Tabs.Panel value="settings"><Settings /></Tabs.Panel>
</Tabs>
The key point is that users completely control the JSX structure, but do not need to manage state, such as which tab is active or switching logic. State is shared internally through Context and remains a black box externally. This is the most valuable capability of Headless libraries: they give you behavior while leaving styling and structure to you. Once you have written an API like this, you will never want to go back to a configuration-based one.
6. The Provider Pattern: Context + Provider
When passing data across a component tree, props drilling, or passing props through every layer, is an anti-pattern. The Provider pattern uses Context to attach state to a node in the tree so every descendant can access it.
Typical uses include themes, the current user, i18n, editor instances, and Toast queues.
import {
createContext,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark";
type ThemeCtx = { theme: Theme; toggle: () => void };
const ThemeContext = createContext<ThemeCtx | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
// 关键:useMemo 稳定 value,避免每次重渲染所有消费者
const value = useMemo<ThemeCtx>(
() => ({
theme,
toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
}),
[theme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme 必须在 <ThemeProvider> 内调用");
return ctx;
}
// 任何深度后代都能直接拿
function Toolbar() {
const { theme, toggle } = useTheme();
return <button onClick={toggle}>当前:{theme}</button>;
}
The Provider performance trap is this: when a Context value changes, every component that calls useContext on it rerenders, whether or not the field it reads changed. A common mistake is writing value={{ a, b }} directly, which creates a new object each time and makes the entire tree rerender unnecessarily. Two principles apply:
- Provider values must be memoized.
- Split Context by frequency of change. Put frequently changing state, such as mouse position, and infrequently changing state, such as user information, in separate Contexts. Otherwise, they cause each other to rerender unnecessarily.
Do not use Context as though it were Redux. It is suited to low-frequency, global, read-heavy, write-light state. For high-frequency updates, I recommend a state library such as Zustand / Jotai or local state instead.
7. Inversion of Control: forwardRef + useImperativeHandle
React is declarative, but sometimes you need to command a child component: focus an input, scroll a list to the bottom, or play a player. In those cases, use forwardRef to expose a ref and useImperativeHandle to precisely control which methods are exposed.
import {
forwardRef,
useImperativeHandle,
useRef,
type ImperativeHandle,
} from "react";
// 子组件:定义自己对外暴露的"指令集"
type FancyInputHandle = {
focus: () => void;
clear: () => void;
getValue: () => string;
};
const FancyInput = forwardRef<FancyInputHandle, { placeholder?: string }>(
({ placeholder }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = "";
},
getValue: () => inputRef.current?.value ?? "",
}));
return <input ref={inputRef} placeholder={placeholder} />;
},
);
FancyInput.displayName = "FancyInput";
// 父组件:像调方法一样命令子组件
function Form() {
const inputRef = useRef<FancyInputHandle>(null);
return (
<>
<FancyInput ref={inputRef} placeholder="搜索..." />
<button onClick={() => inputRef.current?.focus()}>聚焦</button>
<button onClick={() => inputRef.current?.clear()}>清空</button>
</>
);
}
Note that beginning with React 19, function components can receive ref directly as a prop, so forwardRef is being deemphasized. But the idea behind useImperativeHandle, exposing a limited interface, will not change: always expose only the smallest set, and design the ref as the component's public API, rather than handing over all internal state without restraint.
Summary
Here are several points worth remembering:
- Logic reuse -> Hook. This is the default option. HOCs can almost go into a museum.
- UI composition + internal state sharing -> Compound Components. This is the killer pattern for building component libraries.
- Global/regional state -> Context + Provider. Remember to memoize the value and split it by update frequency.
- Imperative control of child components ->
useImperativeHandle. Expose only the smallest interface. - Prefer simplicity. Do not apply container/presentational separation or Render Props preemptively. Patterns are cures for complexity, not preemptive shackles.
The essence of React design patterns is not forcing code into a particular shape, but separating the changing parts from the stable parts. State, side effects, layout, and styling each have their own boundaries. Once the boundaries are clear, patterns emerge on their own.
Over-abstraction is worse than no abstraction. Before you want to use a pattern, first ask yourself: has my problem truly reached the level of complexity that requires it?
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.