react-patterns

React 18/19 patterns including hooks discipline, server/client component boundaries, Suspense + error boundaries, form actions, data fetching, state management…

INSTALLATION
npx skills add https://github.com/affaan-m/ecc --skill react-patterns
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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/useCallback only 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 import a Server Component from a Client Component file — compose them via children instead

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-boundary for 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

NeedTool
Per-request data in Next.js App RouterRSC await fetch()
Client-side cache + mutations + invalidationTanStack Query
Lightweight client cache + revalidationSWR
Real-time subscriptionsServer-Sent Events, WebSockets, or the lib's subscription API
One-off fire-and-forgetfetch() 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 themeContext does not re-render auth consumers
  • Use useSyncExternalStore for external state libraries — required for safe concurrent rendering

Lists

  • Provide stable key props (database id, not array index)
  • Virtualize long lists with @tanstack/react-virtual or react-window once visible item count exceeds ~50 with non-trivial rows

Accessibility-First Composition

  • Always render semantic HTML (<button>, <a>, <nav>, <main>) before reaching for role attributes
  • Every interactive element must be reachable by keyboard
  • Form inputs need labels — <label htmlFor> or aria-label if visually labeled by an icon
  • Manage focus on route changes and modal open/close

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-patterns skill (not present yet)
  • Remix: Loader/action conventions overlap with RSC but follow Remix docs

Related

  • Rules: rules/react/ — coding-style, hooks, patterns, security, testing
  • Agents: react-reviewer for code review, react-build-resolver for 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
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card