DOMAIN:FRONTEND:REACT_PATTERNS¶
OWNER: floris (Team Alpha), floor (Team Beta)
UPDATED: 2026-03-24
SCOPE: all client projects, React 19+
PREREQUISITE: nextjs-app-router.md (server/client component boundaries)
PRINCIPLE: SERVER_COMPONENTS_FIRST¶
RULE: every component starts as a Server Component. Only add "use client" when interactivity is required.
REASON: zero client JS shipped. Direct data access. Simpler mental model. Better performance.
MENTAL_MODEL:
- Server Components = data fetching, layout, content display, SEO
- Client Components = forms, buttons, dropdowns, modals, charts, animations
- Keep Client Components small and at the leaf level of the component tree
// GOOD: Server Component page, small client interactive leaf
// app/orders/page.tsx (Server Component)
import { db } from "@/lib/db";
import { OrderFilter } from "@/components/orders/order-filter"; // "use client"
import { OrderTable } from "@/components/orders/order-table"; // Server Component
export default async function OrdersPage() {
const orders = await db.query.orders.findMany();
return (
<div>
<h1>Orders</h1>
<OrderFilter /> {/* Small client component for interactivity */}
<OrderTable orders={orders} /> {/* Server component for display */}
</div>
);
}
// BAD: entire page is "use client" because of one filter dropdown
"use client";
import { useState, useEffect } from "react";
export default function OrdersPage() {
const [orders, setOrders] = useState([]);
useEffect(() => {
fetch("/api/orders").then(r => r.json()).then(setOrders);
}, []);
// ... entire page is now client-rendered
}
REACT_19_FEATURES¶
USE_ACTION_STATE¶
PURPOSE: manage form submission state (pending, errors, success) with a single hook.
REPLACES: manual useState for loading + error + success tracking.
"use client";
import { useActionState } from "react";
import { createProject } from "@/lib/actions/create-project";
export function CreateProjectForm() {
const [state, formAction, isPending] = useActionState(createProject, {
error: null,
success: false,
});
return (
<form action={formAction}>
<input name="name" required />
<input name="description" />
{state.error && <p className="text-destructive">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Project"}
</button>
</form>
);
}
// lib/actions/create-project.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createProject(
prevState: { error: string | null; success: boolean },
formData: FormData,
) {
const name = formData.get("name") as string;
if (!name || name.length < 3) {
return { error: "Name must be at least 3 characters", success: false };
}
await db.insert(projects).values({ name });
revalidatePath("/projects");
return { error: null, success: true };
}
NOTE: useActionState is the React 19 name. Previously called useFormState in the canary — do NOT use the old name.
NOTE: the Server Action receives prevState as first arg, formData as second. This is different from a plain form action.
USE_OPTIMISTIC¶
PURPOSE: show immediate UI feedback while a Server Action is in flight.
PATTERN: optimistically update local state, revert automatically if action fails.
"use client";
import { useOptimistic } from "react";
import { toggleTodo } from "@/lib/actions/toggle-todo";
type Todo = { id: string; text: string; done: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state: Todo[], updatedTodo: Todo) =>
state.map((t) => (t.id === updatedTodo.id ? updatedTodo : t)),
);
async function handleToggle(todo: Todo) {
addOptimistic({ ...todo, done: !todo.done });
await toggleTodo(todo.id);
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>
<button onClick={() => handleToggle(todo)}>
{todo.done ? "✓" : "○"} {todo.text}
</button>
</li>
))}
</ul>
);
}
BENEFIT: UI responds instantly. No loading spinners for simple toggles.
CAVEAT: if the Server Action throws, React automatically reverts to the real state.
USE_API¶
PURPOSE: read a Promise or Context in render. Replaces some useEffect patterns.
NOTE: when reading a promise, the component suspends until resolved — must be wrapped in Suspense.
"use client";
import { use } from "react";
export function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspends until resolved
return <div>{user.name}</div>;
}
// Usage in parent:
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={fetchUser(id)} />
</Suspense>
REACT_COMPILER¶
STATUS: production-ready in React 19.
PURPOSE: automatically memoizes components and hooks. Eliminates manual useMemo/useCallback/React.memo.
RULE: do NOT manually add useMemo/useCallback unless profiling shows a specific performance issue that the compiler misses.
RULE: write idiomatic React — the compiler optimizes standard patterns best.
COMPOSITION_OVER_INHERITANCE¶
RULE: React does not use class inheritance for component reuse. Composition is the pattern.
CHILDREN_PROP¶
SIMPLEST_COMPOSITION: pass components as children.
// Card that wraps any content
export function Card({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("rounded-lg border bg-card p-6", className)}>{children}</div>;
}
// Usage
<Card>
<h2>Title</h2>
<p>Content goes here</p>
</Card>
SLOT_PATTERN¶
USE_WHEN: component needs multiple named insertion points.
type PageHeaderProps = {
title: React.ReactNode;
description?: React.ReactNode;
actions?: React.ReactNode;
};
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{title}</h1>
{description && <p className="text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
);
}
// Usage
<PageHeader
title="Projects"
description="Manage your active projects"
actions={<><Button>New</Button><Button variant="outline">Export</Button></>}
/>
COMPOUND_COMPONENTS¶
PURPOSE: group of related components that share implicit state via Context.
USE_WHEN: building complex UI primitives (select, tabs, accordion, data table).
PATTERN: parent provides Context, children consume it. Dot notation for clean API.
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
// 1. Create context
type AccordionContextType = {
openItem: string | null;
toggle: (id: string) => void;
};
const AccordionContext = createContext<AccordionContextType | null>(null);
// 2. Custom hook with safety check
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error("Accordion components must be used within <Accordion>");
}
return context;
}
// 3. Parent provider
function AccordionRoot({ children }: { children: ReactNode }) {
const [openItem, setOpenItem] = useState<string | null>(null);
const toggle = (id: string) => setOpenItem((prev) => (prev === id ? null : id));
return (
<AccordionContext value={{ openItem, toggle }}>
<div className="divide-y">{children}</div>
</AccordionContext>
);
}
// 4. Child components
function AccordionItem({ id, children }: { id: string; children: ReactNode }) {
return <div data-slot="accordion-item">{children}</div>;
}
function AccordionTrigger({ id, children }: { id: string; children: ReactNode }) {
const { openItem, toggle } = useAccordion();
return (
<button
onClick={() => toggle(id)}
aria-expanded={openItem === id}
className="flex w-full items-center justify-between py-4 font-medium"
>
{children}
</button>
);
}
function AccordionContent({ id, children }: { id: string; children: ReactNode }) {
const { openItem } = useAccordion();
if (openItem !== id) return null;
return <div className="pb-4">{children}</div>;
}
// 5. Dot notation export
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
// Usage:
// <Accordion>
// <Accordion.Item id="1">
// <Accordion.Trigger id="1">Section 1</Accordion.Trigger>
// <Accordion.Content id="1">Content 1</Accordion.Content>
// </Accordion.Item>
// </Accordion>
NOTE: in GE projects, prefer shadcn/ui Accordion over custom implementation. Build compound components only when shadcn does not provide what you need.
RENDER_PROPS¶
STATUS: still useful in specific scenarios. Not the default pattern.
USE_WHEN: a component needs to delegate rendering decisions to its consumer while controlling data/logic.
"use client";
type VirtualListProps<T> = {
items: T[];
itemHeight: number;
renderItem: (item: T, index: number) => React.ReactNode;
};
export function VirtualList<T>({ items, itemHeight, renderItem }: VirtualListProps<T>) {
// ... virtualization logic (scroll position, visible range)
const visibleItems = items.slice(startIndex, endIndex);
return (
<div style={{ height: items.length * itemHeight, position: "relative" }}>
{visibleItems.map((item, i) => (
<div key={startIndex + i} style={{ position: "absolute", top: (startIndex + i) * itemHeight }}>
{renderItem(item, startIndex + i)}
</div>
))}
</div>
);
}
PREFER: children or slot props over render props when possible. Render props add indirection.
CUSTOM_HOOKS¶
PURPOSE: extract reusable stateful logic from components.
RULE: prefix with use. Must be called at the top level of a component or another hook.
RULE: custom hooks are CLIENT-ONLY. They use React hooks internally, so the consuming component must be "use client".
PATTERN: ENCAPSULATE_SIDE_EFFECTS¶
// lib/hooks/use-debounced-value.ts
"use client";
import { useState, useEffect } from "react";
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
PATTERN: ENCAPSULATE_BROWSER_API¶
// lib/hooks/use-media-query.ts
"use client";
import { useState, useEffect } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
PATTERN: ENCAPSULATE_DATA_FETCHING_WITH_STATE¶
// lib/hooks/use-async.ts
"use client";
import { useState, useEffect, useCallback } from "react";
type AsyncState<T> = {
data: T | null;
error: Error | null;
loading: boolean;
};
export function useAsync<T>(asyncFn: () => Promise<T>, deps: unknown[] = []): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({ data: null, error: null, loading: true });
const execute = useCallback(async () => {
setState({ data: null, error: null, loading: true });
try {
const data = await asyncFn();
setState({ data, error: null, loading: false });
} catch (error) {
setState({ data: null, error: error as Error, loading: false });
}
}, deps);
useEffect(() => { execute(); }, [execute]);
return state;
}
NOTE: for most data fetching, prefer Server Components or TanStack Query over custom hooks.
ERROR_BOUNDARIES¶
RULE: every route segment should have an error.tsx file.
RULE: wrap risky client components in error boundaries.
NOTE: error.tsx must be a Client Component.
// app/dashboard/error.tsx
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center gap-4 p-8">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-muted-foreground">{error.message}</p>
<button onClick={reset} className="btn btn-primary">
Try again
</button>
</div>
);
}
GRANULAR_ERROR_BOUNDARIES¶
USE_WHEN: a page has multiple independent sections that can fail independently.
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
type Props = { children: ReactNode; fallback: ReactNode };
type State = { hasError: boolean };
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary caught:", error, info);
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
FORM_PATTERNS¶
PROGRESSIVE_ENHANCEMENT¶
PATTERN: forms work without JavaScript via <form action={serverAction}>. Enhanced with useActionState when JS loads.
// Works without JS (progressive enhancement)
<form action={createProject}>
<input name="name" required />
<button type="submit">Create</button>
</form>
ZOD_VALIDATION¶
PATTERN: validate on both client (for UX) and server (for security). Share Zod schema.
// lib/schemas/project.ts
import { z } from "zod";
export const projectSchema = z.object({
name: z.string().min(3, "Name must be at least 3 characters").max(100),
description: z.string().max(500).optional(),
budget: z.number().min(0).max(1_000_000),
});
export type ProjectInput = z.infer<typeof projectSchema>;
NOTE: Zod v4 uses .issues not .errors on ZodError. See memory for this GE-wide rule.
SELF_CHECK¶
BEFORE_WRITING_COMPONENT:
- [ ] is this a Server Component by default?
- [ ] if "use client" — is it the smallest possible boundary?
- [ ] am I composing with children/slots instead of inheritance?
- [ ] am I using useActionState for forms (not manual useState)?
- [ ] am I using useOptimistic for instant feedback?
- [ ] is there an error boundary for this route/section?
- [ ] have I avoided manual useMemo/useCallback (React Compiler handles it)?
- [ ] are custom hooks properly prefixed with "use" and in lib/hooks/?
- [ ] does the Zod schema use .issues not .errors?