React 19 engineering: components, Server Components, hooks, Zustand, TanStack Query, forms, performance, testing. Use when building, reviewing, or debugging...
---
name: react
slug: react
version: 1.0.5
description: >-
React 19 engineering: components, Server Components, hooks, Zustand, TanStack Query, forms, performance, testing.
Use when building, reviewing, or debugging React apps.
homepage: https://clawic.com/skills/react
changelog: Deeper patterns, pitfalls, and performance guidance
metadata:
clawdbot:
emoji: ⚛️
displayName: React
configPaths: ["~/react/"]
---
# React
Production-grade React 19 engineering: architecture, state, Server Components, performance, and the traps that separate senior code from generated code. Project decisions persist in `~/react/memory.md` (see Setup).
## When To Use
- Building React components, features, or app architecture
- Choosing or wiring state (useState, Context, Zustand, TanStack Query)
- Working with React 19: Server Components, use(), Actions, React Compiler
- Debugging rerenders, infinite loops, stale closures, hydration errors
- Reviewing AI-generated React for the failure patterns listed here
- Not for routing/deployment specifics — that is the nextjs skill
## Quick Reference
| Situation | Play |
|----------|------|
| Data comes from an API | TanStack Query — never useState + useEffect fetch |
| UI state shared across components | Zustand slice, read via selectors only |
| State used by one component | useState, colocated; lift at the second consumer, not before |
| Rarely-changing globals (theme, locale, auth identity) | Context with memoized value; split state and dispatch contexts |
| Form with validation | React Hook Form + Zod (uncontrolled: keystrokes don't rerender); server mutation → useActionState |
| Typing lags in a filtered view | useDeferredValue around the value you receive; startTransition around state you set |
| List with thousands of rows | Virtualize (tanstack-virtual) — DOM node count is the cost, not data size |
| Component must reset when identity changes | Change its `key`: `<Profile key={userId} />` — remount beats manual state clearing |
| Multi-step flow / wizard | One discriminated-union status field, not a pile of booleans |
| A crash blanks the whole app | react-error-boundary per route and per risky feature |
| Anything else / unsure | useState colocated, named export; extract when a second consumer appears |
## Core Rules
1. **Server state ≠ client state.** API data lives in TanStack Query (a cache with a lifecycle); UI state in useState/Zustand. Mixing them means hand-rebuilding dedupe, caching, and invalidation — badly.
2. **No effect without an external system.** Derivable from props/state → compute in render: `const total = items.reduce(...)`. Before writing useEffect ask "what outside React am I synchronizing with?" No answer → no effect.
3. **`'use client'` marks a boundary, not a component.** Everything that file imports joins the client bundle. Put it on leaf interactive components; pass Server Components through as `children`.
4. **Stable keys — and keys as a tool.** `key={item.id}` for lists; deliberately changing a key is the sanctioned way to reset component state.
5. **Named exports only.** `export function UserCard` — rename refactors stay safe, imports stay greppable.
6. **Memoize by evidence.** React Compiler on: write no memo/useMemo/useCallback by hand — the compiler bails out on components that break the Rules of React, it doesn't fix them. Compiler off: memoize only after the Profiler shows the same component committing repeatedly with unchanged props.
7. **`strict: true` plus `noUncheckedIndexedAccess`.** With it, `array[0]` is `T | undefined` — the "impossible" runtime crash becomes a compile error.
8. **Split at ~50 lines of JSX or ~300 lines per file** (house default, not dogma): past that, the extraction candidates are already visible in the markup.
## Component Rules
```tsx
export function UserCard({ user, onEdit }: UserCardProps) {
// 1. Hooks first, unconditional
const [isOpen, setIsOpen] = useState(false)
// 2. Derived values in render — never in an effect
const fullName = `${user.firstName} ${user.lastName}`
// 3. Handlers
const handleEdit = () => onEdit(user.id)
// 4. Early returns AFTER all hooks
if (!user.isVisible) return null
// 5. JSX
return (...)
}
```
Export the props interface. Early returns must come after every hook call — a conditional return above a hook changes hook order between renders and crashes.
## State Management
```
Is it from an API?
├─ YES → TanStack Query (not Redux, not Zustand, not Context)
└─ NO → Shared across components?
├─ YES → Zustand (changes often) or Context (rarely changes)
└─ NO → useState
```
### TanStack Query
```tsx
// Query key factory — one source of truth, no key typos
export const userKeys = {
all: ['users'] as const,
detail: (id: string) => [...userKeys.all, id] as const,
}
export function useUser(id: string) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 5 * 60 * 1000,
})
}
```
- **staleTime defaults to 0**: every mount and window refocus refetches. Set it per resource: staleTime = how stale the user can tolerate that data. Keep gcTime (default 5 min) ≥ staleTime or cached data is dropped while still considered fresh.
- **After a mutation**: `invalidateQueries` when the server may have changed things you didn't send; `setQueryData` when the response returns the updated entity — saves a round trip.
- **Paginated tables**: `placeholderData: keepPreviousData` — old page stays visible while the next loads, no flicker.
### Zustand
```tsx
export const useUIStore = create<UIState>()((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}))
// Read with selectors — bare useUIStore() rerenders on EVERY store change
const isOpen = useUIStore((s) => s.sidebarOpen)
// Selector returning a fresh object/array → infinite rerenders in v5.
// Wrap in useShallow:
const { a, b } = useUIStore(useShallow((s) => ({ a: s.a, b: s.b })))
```
One store per concern (ui, session, cart) — a god-store recreates Redux without the devtools.
### Context
Every value change rerenders every consumer, no selectors. Memoize the value object (`useMemo(() => ({ user, login }), [user])`) or a new object each parent render defeats the purpose. For state + updater, split into two contexts so setters-only consumers never rerender.
## React 19
### Server Components
```tsx
// Server Component (default in App Router) — zero JS shipped
async function ProductList() {
const products = await db.products.findMany()
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<ProductCard product={p} /> {/* server */}
<AddToCartButton id={p.id} /> {/* client leaf */}
</li>
))}
</ul>
)
}
```
- Props crossing the server→client boundary must be serializable: primitives, plain objects/arrays, Date, Map, Set, promises, JSX. Not functions (except Server Actions), not class instances.
- A Server Component can't be imported by a client file — but it can be passed *through* one as `children`. That's how you keep a client-side layout shell around server-rendered content.
- Pass a promise down and `use(promise)` in a client child under Suspense: the fetch starts on the server, the client streams in.
### Hooks & API Changes
```tsx
const comments = use(commentsPromise) // suspends until resolved
```
- `use()` may be called conditionally — the one exception to hook rules. Never create the promise inside a client render: a new promise each render suspends forever. Create it in the parent (server) or cache it.
- `ref` is a normal prop on function components — `forwardRef` is no longer needed.
- `useOptimistic(current, reducer)`: render the optimistic value instantly, React reverts automatically if the action throws.
### Actions
```tsx
'use client'
function Form() {
const [state, action, pending] = useActionState(submitAction, {})
return (
<form action={action}>
<input name="email" disabled={pending} />
<button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
)
}
```
`useFormStatus` (from react-dom) reads pending state from any child of the form — no prop drilling into submit buttons.
## Performance
Order of operations — measure before touching code: React DevTools Profiler, find components that commit often with unchanged props, then:
| Priority | Technique | When |
|----------|-----------|------|
| P0 | Route-based code splitting (`lazy` + Suspense) | Always — users pay only for the route they visit |
| P0 | Parallel data loading (`Promise.all`, prefetch) | Any page with 2+ independent fetches — waterfalls dominate load time |
| P1 | Virtualize long lists | Thousands of rows or hundreds of complex rows — profile, DOM nodes are the cost |
| P1 | useDeferredValue / startTransition | Typing or dragging feels janky because a heavy subtree rerenders per keystroke |
| P2 | memo / useMemo by Profiler evidence | Only without React Compiler (→ Core Rule 6) |
## Traps
| Trap | Why it fails | Do instead |
|------|--------------|------------|
| `{count && <X />}` | `0` is falsy but renderable — the page shows a literal 0 | `{count > 0 && <X />}` |
| `key={index}` on lists that reorder/filter | State and DOM stick to the position, not the item — inputs show the wrong values | `key={item.id}` |
| Object/array literal in effect deps | Fresh reference every render → effect fires every render | Depend on primitives, or memoize upstream |
| Mutating state then setState | Same reference — React skips the rerender | New reference: `setArray([...array, item])` |
| Fetch in effect without cancellation | Slow response for the old value overwrites fast response for the new one | `AbortController` in cleanup (below) |
| "My effect runs twice" → deleting StrictMode | Double-mount is dev-only and deliberate: it exposes missing cleanup | Fix the cleanup; keep StrictMode |
| Expecting error boundaries to catch handler/async errors | Boundaries only catch render and lifecycle errors | try/catch in handlers; `onError` in mutations |
| Hydration mismatch | `Date.now()`, `Math.random()`, locale formatting, `typeof window` branches render differently on server vs client | Move to an effect, or `suppressHydrationWarning` on that single element |
| setState during render | Render → setState → render, infinite loop | Compute derived values inline; setState in handlers/effects |
| `useEffect(async () => …)` | Effect must return cleanup or nothing, not a promise | Define an async fn inside and call it |
```tsx
// Cancellation — the race-condition fix
useEffect(() => {
const controller = new AbortController()
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => { if (e.name !== 'AbortError') setError(e) })
return () => controller.abort()
}, [url])
```
## AI Mistakes to Avoid
Patterns generated code gets wrong — check these when reviewing:
| Mistake | Correct pattern |
|---------|-----------------|
| useEffect + useState to derive a value | Compute inline in render (→ Core Rule 2) |
| Fetching in useEffect | TanStack Query, or promise + use() under Suspense |
| Redux/Context for API data | TanStack Query — server state is a cache, not app state |
| Default exports | Named exports (→ Core Rule 5) |
| useCallback/useMemo on everything | Compiler on: none by hand; off: Profiler evidence first (→ Core Rule 6) |
| Generic abstraction on first use (`<DataTable>` for one table) | Write it concrete; abstract at the second real consumer |
| Giant components | Split at ~50 JSX / ~300 file lines (→ Core Rule 8) |
| No error boundaries anywhere | Route level + each risky feature |
| Scattered `any` to silence strict mode | Fix the type; `unknown` + narrowing when truly dynamic |
## Where Experts Disagree
- **Memoize-by-default vs profile-first.** The React Compiler settles it for new code (write none). For pre-compiler codebases: libraries and design systems memoize exports defensively (unknown consumers); apps profile first.
- **RSC-everything vs SPA.** Content and commerce sites win with Server Components (payload, SEO). Dashboard-dense apps behind a login with constant interactivity lose little by staying SPA (Vite) — don't pay the RSC mental tax for zero SEO benefit.
- **Zustand vs Context for shared state.** Context is fine while values change rarely; the crossover is update frequency, not app size. Frequent updates through Context force consumer-tree rerenders that selectors avoid.
- **Feature folders vs type folders.** Type folders (`components/`, `hooks/`) survive only in small apps; beyond a handful of features, colocation by feature keeps change surface local. Default to feature folders (→ setup.md).
## Setup
See `setup.md` for first-time configuration. Project decisions and encountered traps persist in `~/react/memory.md` (template: `memory-template.md`).
## Related Skills
More Clawic skills, get them at https://clawic.com/skills (install if the user confirms):
- **frontend-design-ultimate** — Build complete UIs with React + Tailwind
- **typescript** — TypeScript patterns and strict configuration
- **nextjs** — Next.js App Router and deployment
- **testing** — Testing React components with Testing Library
## Feedback
- If useful, star it: https://clawic.com/skills/react
- Latest version: https://clawic.com/skills/react
Part of [Clawic](https://clawic.com), the verified skill library. Get this skill: https://clawic.com/skills/react.
don't have the plugin yet? install it then click "run inline in claude" again.