React Development Patterns
React patterns for maintainable, performant user interfaces, covering composition, hooks, async state, data access, performance, forms, and animation. Everything here is framework-agnostic React that works the same inside or outside a meta-framework.
Baseline: React 19 with TypeScript. Server Components, streaming, and route-level caching belong to
nextjs-app-router-patterns, and this skill defers to it on all three.
When to activate
- Building React components: composition, props, rendering, compound components.
- Managing state with
useState,useReducer, Context, or a store such as Zustand. - Implementing client-side data fetching with SWR, React Query, or a typed API client.
- Optimising rendering: memoisation, virtualisation, code splitting.
- Working with forms: validation, controlled inputs, schema parsing.
- Handling client-side routing and navigation.
- Adding animation to a React interface.
When not to activate
- Server Components, streaming, route caching, Server Actions, route handlers. Use
nextjs-app-router-patterns. - Visual direction, typography, composition, and motion direction. Use
frontend-design. - Tokens, theming, and stylesheet architecture. Use
design-system. - WCAG conformance, ARIA semantics, and screen-reader behaviour. Use
web-accessibility. - Angular components, signals, and RxJS. Use
angular. - Profiling and Core Web Vitals work beyond React-level fixes. Use
performance-optimization.
Reference map
| Task | Open |
|---|---|
| Context and reducer stores, custom state hooks, debouncing | references/state-management.md |
| The typed API client, async data hooks, render props loaders | references/data-fetching.md |
| Controlled forms, validation, error boundaries, i18n wiring | references/forms.md |
Animation with motion/react, reduced motion, timing budgets |
references/animation.md |
| Keyboard navigation and focus management in React | references/accessibility.md |
Declare components as named functions
Named declarations hoist, give clearer stack traces and DevTools names, and read consistently across a file. Reserve arrow functions for callbacks and inline handlers.
// PASS: named declaration, name survives into DevTools and stack traces
export function MarketCard({ market }: MarketCardProps) {
return <article className="market-card">{market.name}</article>
}
// FAIL: anonymous arrow assigned to a const, worse traces and no hoisting
export const MarketCard = ({ market }: MarketCardProps) => (
<article className="market-card">{market.name}</article>
)
Compose components, never inherit
Build a family of small pieces that slot into each other. Inheritance in React produces components with a props surface that only grows.
// PASS: composed pieces, each with one job
export function Card({ children, variant = 'default' }: CardProps) {
return <div className={`card card-${variant}`}>{children}</div>
}
export function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>
}
// FAIL: one component branching on flags for every caller
export function Card({ children, isHeader, isBody, isOutlined, isCompact, hasIcon }: EverythingProps) { ... }
Compound components (Tabs, Accordion, Menu) share state through a context created by the parent. The full pattern is in references/state-management.md.
Model async state as one discriminated union
Three loose flags let impossible combinations exist: loading and error and data all set at once. One union value makes
those unrepresentable, and callers switch on state.status.
// PASS: one value, impossible states cannot be constructed
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; reason: Error }
// FAIL: three flags that can contradict each other
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
Route every network call through one typed API client
The base URL, the headers, the auth, and the response typing belong in one module. Components and hooks call the client, which keeps endpoint strings out of the UI and makes an auth or base-URL change a one-file edit.
// PASS: the component asks the client for markets
const markets = await api.markets.list()
// FAIL: a URL, a status check, and an untyped body in a component
const res = await fetch('https://api.example.com/markets')
const markets = await res.json()
The client module itself is in references/data-fetching.md.
Memoise only what you measured
useMemo, useCallback, and React.memo each add a dependency array to maintain and a cache to hold, and a wrong
dependency list is its own class of bug. Reaching for them reflexively on cheap work costs more than it saves. Note
also that Array.prototype.sort mutates, so copy before sorting or you mutate a prop.
// PASS: a measured expensive transform, and a copy before sorting
const sortedMarkets = useMemo(() => [...markets].sort((a, b) => b.volume - a.volume), [markets])
// FAIL: memoising a string concatenation, and sorting the prop in place
const label = useMemo(() => `${market.name} (${market.id})`, [market])
const sorted = markets.sort((a, b) => b.volume - a.volume)
Load heavy modules lazily and virtualise long lists
A charting or 3D library in the entry bundle delays every route that does not use it. A list of thousands of rows renders thousands of DOM nodes the user will never scroll to.
// PASS: heavy component split behind Suspense, long list virtualised
const HeavyChart = lazy(() => import('./HeavyChart'))
const virtualizer = useVirtualizer({ count: markets.length, getScrollElement: () => parentRef.current })
// FAIL: the library ships to every route, and 10,000 cards mount at once
import { HeavyChart } from './HeavyChart'
{markets.map((market) => <MarketCard key={market.id} market={market} />)}
The full virtualisation and code-splitting examples are in references/data-fetching.md.
Send every user-facing string through the i18n layer
A literal in JSX is a string that cannot be translated and cannot be changed without a code edit. Every label, placeholder, button, and message is looked up by key.
// PASS: copy lives in the catalogue
<button type="submit">{t('common.save')}</button>
// FAIL: copy welded into the component
<button type="submit">Save</button>
Doc Comments
Default to none. A doc comment is usually a sign that the code failed to explain itself. Before writing one, extract the unclear block into a well-named component or hook, rename the props so they carry their own meaning, and tighten the types. Do that first and most doc comments have nothing left to say, which is the outcome you want. Code that explains itself cannot go stale, a comment can.
When one is still genuinely needed, the prose is capped at five lines and is usually one. Every tag line is capped at
one line, @param and @returns and @throws alike, and only appears when it genuinely adds something: if the note
does not fit on a single line, shorten it or drop the tag. Four rules decide what goes in.
- Prose. One sentence saying what it does, then only what a caller cannot infer from the signature. Nothing more.
@paramonly when the name and the type do not already convey it, meaning units, nullability, a valid range, or who owns the argument afterwards.@param userId - The user identifieris noise, delete it, and never restate a type TypeScript already declares.@returnsonly when it is non-obvious.@throwsalways, for every error a caller can act on. TypeScript keeps throws out of the signature, so this one is genuinely contract rather than decoration.
Going past the five-line prose cap is allowed only when the contract genuinely cannot be stated in fewer lines, for example a documented state machine, an ordering requirement, or a concurrency guarantee. It is an exception you justify in review, not a budget to spend. The one-line cap on a tag line has no exception at all: shorten it or delete it.
// PASS: one sentence, then only what the signature cannot say
/**
* Subscribes to live price updates for one market.
*
* @returns null until the first message arrives
*/
export function useMarketPrice(marketId: string): Price | null { ... }
// FAIL: restates the signature and the hook name
/**
* Hook for market price.
*
* @param marketId - The market id
* @returns The price
*/
export function useMarketPrice(marketId: string): Price | null { ... }
Related skills
nextjs-app-router-patternsowns Server Components, streaming, route caching, Server Actions, and route handlers. This skill does not cover them.web-accessibilityowns WCAG conformance, ARIA semantics, and screen-reader behaviour. The React mechanics for keyboard and focus are in references/accessibility.md, the requirements are there.frontend-designfor visual direction and motion direction.design-systemfor tokens, theming, and stylesheet architecture.performance-optimizationfor profiling and Core Web Vitals work beyond React-level fixes.e2e-testingfor driving these components in a browser.coding-standardsfor the shared engineering floor these patterns sit on.
Checklist
- Every top-level component is a named function declaration.
- Component families are composed, not configured through a growing flag surface.
- Every async operation exposes one
AsyncStateunion, not separate data, loading, and error flags. - No component or hook calls
fetchdirectly, and every endpoint lives in the typed API client. - Every
useMemo,useCallback, andReact.memotraces back to a measurement. - No sort, splice, or reverse mutates a prop or a state value in place.
- Heavy client-only modules are lazy loaded, and lists past a few hundred rows are virtualised.
- Every user-facing string goes through the i18n layer.
- Every animation respects
prefers-reduced-motion. - No doc comment restates a signature, and every recoverable error is documented with
@throws.