react-patterns
affaan-m/ecc
React 18/19 patterns: hooks, Server/Client boundaries, Suspense, forms, data fetching, and accessible composition.
What is react-patterns?
Reference guide for idiomatic React 18/19 component patterns. Use when writing or reviewing React components, designing state shape, or migrating to Server Components and modern form APIs.
- Enforce render as pure function of props and state; avoid derived state in useEffect
- Apply hooks discipline: top-level only, cleanup subscriptions, use functional updaters
- Decide state location via decision tree: useState, lifting, Context, external stores, or server-state libraries
- Implement Server/Client component boundaries with proper prop serialization and Server Actions
- Structure Suspense boundaries close to data sources and pair with Error Boundaries for resilience
- Build forms with React 19 actions, controlled inputs, or form libraries for complex multi-step workflows
How to install react-patterns
npx skills add null --skill react-patternsHow to use react-patterns
- 1.Consult the state location decision tree when choosing where to store state (useState, lifting, Context, external store, or server-state library)
- 2.Apply hooks discipline: keep hooks at top level, clean up subscriptions, use functional updaters when new state depends on old
- 3.Use Server Components by default in Next.js; mark interactive components with 'use client' and pass serializable props across boundaries
- 4.Place Suspense boundaries close to data sources (not at route root) and pair with Error Boundary for progressive content reveal
- 5.Choose data-fetching tool from the matrix: RSC await fetch() for per-request data, TanStack Query for client cache + mutations, SWR for lightweight revalidation
- 6.Build forms with React 19 useActionState and form actions for new code; use React Hook Form or TanStack Form for multi-step or dynamic field workflows
- 7.Memoize (React.memo, useMemo, useCallback) only after profiler confirms re-renders are frequent, props stable, and render expensive
- 8.Compose with children slots, named slots, compound components, or render props; prefer hooks over render props for cleaner code
Use cases
- Writing new React function components or refactoring class components to hooks
- Reviewing JSX/TSX files for state management anti-patterns and composition issues
- Designing component trees and deciding between local state, lifting, Context, and external stores
- Migrating to Next.js App Router with Server Components and Client Components
- Implementing forms with React 19 useActionState or complex multi-step workflows with React Hook Form
- React developers writing or reviewing components in React 18/19 codebases
- Next.js App Router users adopting Server Components and Client Components
- Teams migrating from class components or older useEffect-heavy patterns
- Frontend engineers designing state architecture and component composition
- Developers building forms, data-fetching layers, and performance-critical UIs
react-patterns FAQ
Use Context for low-frequency reads (theme, auth, locale) shared across distant branches. Use an external store (Zustand, Jotai, Redux Toolkit) for high-frequency updates shared across the tree. Most pages do not need either — lift state to the nearest common ancestor first.
Derive values during render (e.g., `const total = items.reduce(...)`) instead of storing them in useState + useEffect. Derived state in useEffect adds a render cycle, can desync, and obscures data flow.
Server Components (default in Next.js App Router) are async, never ship JS, and fetch data server-side. Client Components ('use client') are interactive, run in the browser, and can use hooks. Pass serializable props from Server to Client; invoke Server Actions from Client via form actions or event handlers.
Use Suspense for data-dependent content that should block rendering until ready; place boundaries close to the data source for progressive reveal. Use loading states (spinners, skeletons) for user-initiated actions (form submission, button clicks) where Suspense is not appropriate.
No. Memoize (React.memo, useMemo, useCallback) only when a profiler proves re-renders are frequent, props are usually the same, and render is expensive. Memoization adds an equality check on every render; if props differ most renders, it is pure overhead.
Full instructions (SKILL.md)
Source of truth, from affaan-m/ecc.
name: react-patterns description: React 18/19 patterns including hooks discipline, server/client component boundaries, Suspense + error boundaries, form actions, data fetching, state management decision trees, and accessibility-first composition. Use when writing or reviewing React components. metadata: origin: ECC
React Patterns
Idiomatic React 18/19 patterns for building robust, accessible, performant component trees.
When to Activate
- Writing or modifying React function components, custom hooks, or component trees
- Reviewing JSX/TSX files
- Designing state shape or component composition
- Migrating class components or older
forwardRef/useEffect-heavy code - Choosing between local state, lifted state, context, and external stores
- Working with Server Components / Client Components (Next.js App Router, RSC)
- Implementing forms with React 19 actions or controlled inputs
- Wiring data fetching with TanStack Query / SWR / RSC
Core Principles
1. Render is a Pure Function of Props and State
// Good: derive during render
function Cart({ items }: { items: CartItem[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <span>{formatMoney(total)}</span>;
}
// Bad: derived state stored separately
function Cart({ items }: { items: CartItem[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
return <span>{formatMoney(total)}</span>;
}
Derived state in useEffect adds a render cycle, can desync, and obscures the data flow.
2. Side Effects Outside Render
Effects, mutations, network calls, and subscriptions live in event handlers or useEffect — never in the render body.
3. Composition Over Inheritance
React has no inheritance model for components. Compose with children, render props, or component props.
Hooks Discipline
See rules/react/hooks.md for the full ruleset. Highlights:
- Top-level only, never conditional
- Cleanup every subscription, interval, listener
- Functional updater (
setX(prev => prev + 1)) when new state depends on old - Default position: do not memoize — add
useMemo/useCallbackonly when a profiler or a dependency chain proves it matters - Extract a custom hook only when the same hook sequence appears in 2+ components
State Location Decision Tree
Used by one component?
-> useState inside it
Used by parent + a few descendants?
-> lift to nearest common ancestor
Used across distant branches AND low-frequency reads (theme, auth, locale)?
-> React Context
High-frequency updates shared across the tree?
-> external store (Zustand, Jotai, Redux Toolkit)
Derived from a server?
-> server-state library (TanStack Query, SWR, RSC fetch)
Most pages do not need context or a global store. Resist abstraction until duplicated lifting becomes painful.
Server / Client Components (RSC)
// Server Component - default, async, never ships JS for itself
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } });
if (!product) notFound();
return <ProductView product={product} />;
}
// Client Component - opt in with "use client"
"use client";
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToCart(productId))}
>
{pending ? "Adding..." : "Add to cart"}
</button>
);
}
Boundaries:
- Server -> Client: pass serializable props or
children - Client -> Server: invoke Server Actions via
<form action={...}>or imperatively from event handlers - Never
importa Server Component from a Client Component file — compose them viachildreninstead
Suspense + Error Boundaries
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={id} />
</Suspense>
</ErrorBoundary>
- Place Suspense boundaries close to the data, not at the route root — progressively reveal content
- Error Boundary remains a class API; use
react-error-boundaryfor a hook-friendly wrapper - A boundary catches errors thrown during render, lifecycle, and constructors of its children — NOT in event handlers or async code
Forms
React 19 form actions (preferred for new code)
"use client";
import { useActionState } from "react";
const initial = { error: null as string | null };
async function updateUserAction(_prev: typeof initial, formData: FormData) {
"use server";
const parsed = UserSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: "Invalid input" };
await db.user.update({ where: { id: parsed.data.id }, data: parsed.data });
return { error: null };
}
export function UserForm() {
const [state, formAction, pending] = useActionState(updateUserAction, initial);
return (
<form action={formAction}>
<input name="name" required />
<button type="submit" disabled={pending}>Save</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
Controlled inputs
Use controlled when the value drives other UI, formats on every keystroke, or implements real-time validation.
Complex forms
For multi-step forms, dynamic field arrays, or cross-field validation: use a library (React Hook Form, TanStack Form). Roll-your-own state management for forms past trivial complexity is a maintenance trap.
Data Fetching Decision Matrix
| Need | Tool |
|---|---|
| Per-request data in Next.js App Router | RSC await fetch() |
| Client-side cache + mutations + invalidation | TanStack Query |
| Lightweight client cache + revalidation | SWR |
| Real-time subscriptions | Server-Sent Events, WebSockets, or the lib's subscription API |
| One-off fire-and-forget | fetch() in an event handler |
Avoid useEffect + fetch for application data — race conditions, no cache, no retry, no Suspense integration.
Composition Recipes
Slot via children
<Layout>
<Header />
<Main>{content}</Main>
</Layout>
Named slots
<Page header={<Nav />} sidebar={<Filters />}>
<Results />
</Page>
Compound components (shared state via Context)
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="profile"><Profile /></Tabs.Panel>
<Tabs.Panel value="settings"><Settings /></Tabs.Panel>
</Tabs>
Render prop / function-as-child
Useful when the parent needs to pass parameters to the rendered output:
<DataLoader id={id}>
{({ data, isLoading }) => isLoading ? <Spinner /> : <UserCard user={data} />}
</DataLoader>
Modern alternative: a hook (useData(id)) returning the same shape — usually cleaner.
Performance
When React.memo Actually Helps
Wrap a component in React.memo only when:
- It re-renders frequently
- Its props are usually the same between renders
- Its render is measurably expensive
React.memo adds an equality check on every render. If props differ on most renders, the check is pure overhead.
Avoiding Render Cascades
- Lift state down rather than up where possible
- Split context: one context per concern, so a change to
themeContextdoes not re-render auth consumers - Use
useSyncExternalStorefor external state libraries — required for safe concurrent rendering
Lists
- Provide stable
keyprops (database id, not array index) - Virtualize long lists with
@tanstack/react-virtualorreact-windowonce visible item count exceeds ~50 with non-trivial rows
Accessibility-First Composition
- Always render semantic HTML (
<button>,<a>,<nav>,<main>) before reaching forroleattributes - Every interactive element must be reachable by keyboard
- Form inputs need labels —
<label htmlFor>oraria-labelif visually labeled by an icon - Manage focus on route changes and modal open/close
- Run
axein component tests (see skills/react-testing) - Cross-link: skills/accessibility/SKILL.md covers WCAG criteria and pattern libraries
Routing
This skill is router-agnostic. The patterns above work with React Router, TanStack Router, Next.js App Router, Remix Router. Router-specific patterns (loaders, actions, nested layouts) follow the router's documentation — those are framework concerns layered on top of React core.
Out of Scope (Pointer Sections)
- Next.js specifics: App Router data loading, Route Handlers, Middleware, Parallel Routes — separate concern, use Next.js docs
- React Native: Platform-specific patterns differ enough to warrant a separate
react-native-patternsskill (not present yet) - Remix: Loader/action conventions overlap with RSC but follow Remix docs
Related
- Rules: rules/react/ — coding-style, hooks, patterns, security, testing
- Skills: react-performance for the Vercel-derived performance ruleset, frontend-patterns for cross-framework UI concerns, accessibility, angular-developer for framework comparison
- Agents:
react-reviewerfor code review,react-build-resolverfor build/bundler errors - Commands:
/react-review,/react-build,/react-test
Examples
Custom hook for debounced search
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const debounced = useDebounce(query, 300);
const { data } = useQuery({
queryKey: ["search", debounced],
queryFn: () => searchApi(debounced),
enabled: debounced.length > 0,
});
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results items={data ?? []} />
</>
);
}
Optimistic UI with React 19 useOptimistic
"use client";
import { useOptimistic } from "react";
export function MessageList({ messages }: { messages: Message[] }) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, newMessage],
);
async function send(formData: FormData) {
const text = String(formData.get("text"));
addOptimistic({ id: "pending", text, sender: "me" });
await saveMessage(text);
}
return (
<>
<ul>{optimistic.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
<form action={send}>
<input name="text" />
<button type="submit">Send</button>
</form>
</>
);
}
Splitting context to avoid render cascades
// Two contexts: one rarely changes, one frequently
const ThemeContext = createContext<Theme>("light");
const NotificationsContext = createContext<Notification[]>([]);
// A component that only consumes ThemeContext does NOT re-render when notifications change
Related skills
More from affaan-m/ecc and the wider catalog.
react-performance
70+ React/Next.js performance rules organized by priority—eliminate waterfalls, optimize bundles, and improve Core Web Vitals.
react-testing
React component testing with React Testing Library, Vitest/Jest, MSW mocking, and accessibility assertions.
recsys-pipeline-architect
Design composable recommendation and ranking pipelines using the six-stage Source→Hydrator→Filter→Scorer→Selector→SideEffect framework.
recursive-decision-ledger
Recursive decision-making with explicit evidence trails and promotion gates for high-dimensional search and stochastic optimization.
redis-patterns
Redis patterns for caching, rate limiting, locks, sessions, and pub/sub in production.
regex-vs-llm-structured-text
Choose regex for structured text (95%+ accuracy), add LLM only for low-confidence edge cases to cut costs by ~95%.