Skip to content

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?