UNPKG

opencode-agent-kit

Version:

Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)

342 lines (257 loc) 11.5 kB
--- 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. 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 ```tsx // 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](../../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) ```tsx // 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 ```tsx <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) ```tsx "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` ```tsx <Layout> <Header /> <Main>{content}</Main> </Layout> ``` ### Named slots ```tsx <Page header={<Nav />} sidebar={<Filters />}> <Results /> </Page> ``` ### Compound components (shared state via Context) ```tsx <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: ```tsx <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: 1. It re-renders frequently 2. Its props are usually the same between renders 3. 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 - Run `axe` in component tests (see [skills/react-testing](../react-testing/SKILL.md)) - Cross-link: [skills/accessibility/SKILL.md](../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-patterns` skill (not present yet) - **Remix**: Loader/action conventions overlap with RSC but follow Remix docs ## Related - Rules: [rules/react/](../../rules/react/) — coding-style, hooks, patterns, security, testing - Skills: [react-performance](../react-performance/SKILL.md) for the Vercel-derived performance ruleset, [frontend-patterns](../frontend-patterns/SKILL.md) for cross-framework UI concerns, [accessibility](../accessibility/SKILL.md), [angular-developer](../angular-developer/SKILL.md) for framework comparison - 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 ```tsx 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` ```tsx "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 ```tsx // 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 ```