React — Components, Hooks, State, Styling
Modern React component, state, effect, and styling patterns. Baseline is React 19.2 + React Compiler v1.0 (stable since October 2025) and Tailwind v4.
Quick Reference — When to Load What
| Working on… | Read |
|---|---|
| Starting a new app, page, or screen | SHADCN.md + TAILWIND-TOKENS.md first — check what the repo already has |
| Splitting components, custom hooks, file size, prop drilling | COMPONENT-DESIGN.md |
| useEffect — whether you even need one, cleanup, dependency arrays | USE-EFFECT.md |
Re-renders, memoization, React Compiler, list keys, <Activity> |
PERFORMANCE.md |
| Loading skeletons, empty states, Suspense + error boundary pairing | LOADING-STATES.md |
| Client state stores — Zustand, selectors, middleware, slices | ZUSTAND.md |
| Styling — colors, spacing, design tokens, no arbitrary values | TAILWIND-TOKENS.md |
| Forms — React Hook Form, Zod, field errors, submission state | FORMS.md |
shadcn components — Base UI vs Radix, cva variants, cn(), where files go |
SHADCN.md |
Critical Gotchas (Always-Inline)
These are the highest-leverage React 19 mistakes. Memorize them.
1. ref Is a Normal Prop — No forwardRef
// BAD: forwardRef is deprecated in React 19
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input ref={ref} {...props} />
));
// GOOD: ref is just a prop
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
2. <Context> Is the Provider — No .Provider
const ThemeContext = createContext<Theme>("light");
// BAD: .Provider is deprecated
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
// GOOD: render the context itself
<ThemeContext value={theme}>{children}</ThemeContext>
3. use() Reads Context and Promises — Conditionally
Unlike useContext, use() works inside if blocks and loops, and unwraps promises with Suspense.
import { use } from "react";
function Panel({ show }: { show: boolean }) {
if (!show) return null;
const theme = use(ThemeContext); // legal inside a conditional
return <hr className={theme} />;
}
4. Derive State During Render — Don't Sync It in an Effect
// BAD: extra state + an Effect that lags one render behind
const [filtered, setFiltered] = useState(items);
useEffect(() => setFiltered(items.filter(i => i.active)), [items]);
// GOOD: compute during render — no state, no Effect
const filtered = items.filter(i => i.active);
If a value can be computed from props or other state, it is not state. See USE-EFFECT.md.
5. Never Hand-Roll Submission State — RHF by Default, Actions for the Simple Case
The rule is that isSubmitting and per-field errors are never useState. Two
things satisfy it, and which one is not a preference:
React Hook Form + zodResolver is the default. shadcn's <Form> is React
Hook Form, so any form using it is already RHF. See FORMS.md.
const form = useForm({ resolver: zodResolver(Schema), defaultValues: { username: "" } });
// form.formState.isSubmitting — never a useState flag
useActionState for a form with one submit, one error, and no per-field
feedback — a search box, a confirm button. No dependency, uncontrolled inputs.
const [error, submitAction, isPending] = useActionState(
async (_prev: string | null, formData: FormData) => {
const result = await createPost({ title: formData.get("title") as string });
return result.error ?? null;
},
null,
);
return (
<form action={submitAction}>
<input name="title" required />
<button type="submit" disabled={isPending}>{isPending ? "Posting…" : "Create"}</button>
{error && <p className="text-destructive">{error}</p>}
</form>
);
Never both on one form — handleSubmit and <form action> both own submission.
6. useFormStatus Reads Form State Without Prop Drilling
A reusable submit button reads its parent <form>'s pending state directly.
import { useFormStatus } from "react-dom";
// GOOD: no isPending prop threaded down
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? "Saving…" : children}</button>;
}
useFormStatus must be called from a component rendered inside the <form>.
7. useOptimistic for In-Flight UI
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state: Message[], text: string) => [...state, { id: crypto.randomUUID(), text, sending: true }],
);
// call addOptimistic(text) inside the action; React reverts automatically on settle
8. Manual useMemo / useCallback / React.memo Is an Anti-Pattern
React Compiler v1.0 is stable and auto-memoizes. Hand-written memo is now boilerplate.
// BAD: noise the compiler already handles
const UserCard = React.memo(({ user }: { user: User }) => {
const name = useMemo(() => formatName(user.name), [user.name]);
const => selectUser(user.id), [user.id]);
return <button
});
// GOOD: write it plainly — the compiler memoizes
function UserCard({ user }: { user: User }) {
return <button => selectUser(user.id)}>{formatName(user.name)}</button>;
}
Three narrow exceptions remain (third-party reference equality, measured-expensive computation, no-compiler codebases). See PERFORMANCE.md.
9. Stable Keys — Never Index Keys for Dynamic Lists
Index keys corrupt state when the list reorders, inserts, or deletes.
// BAD: index key
{todos.map((todo, i) => <TodoItem key={i} todo={todo} />)}
// GOOD: stable identity
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
// GOOD: key to remount-and-reset a subtree on identity change
<ProfileForm key={userId} userId={userId} />
10. Don't Initialize State From Props Without Care
// BAD: stale forever — prop changes never update the state
function Editor({ initialText }: { initialText: string }) {
const [text, setText] = useState(initialText);
}
If the copy must reset when the prop changes, use a key (gotcha 9). If the parent owns the value, lift state up instead.
11. Rules of Hooks — Call Them at the Top Level
Hooks are tracked by call order. They must run at the top level of a component or custom hook — never inside conditions, loops, or after an early return.
// BAD: hook after an early return — call order changes when `user` is null
function Profile({ user }: { user: User | null }) {
if (!user) return null;
const [tab, setTab] = useState("home"); // skipped on some renders
}
// GOOD: all hooks first, unconditionally; branch afterwards
function Profile({ user }: { user: User | null }) {
const [tab, setTab] = useState("home");
if (!user) return null;
}
use() is the only hook that may be called conditionally (gotcha 3). Every other hook — useState, useEffect, useReducer, custom hooks — is top-level only. See USE-EFFECT.md for the full Effect catalog.
When to Use What
| Decision | Use |
|---|---|
| Single value, toggle, form field | useState |
| Multiple related values updated together | useReducer |
| Value computable from props/state | derive during render — not state |
| Read context | use(Context) — never useContext |
| Global, rarely-changing value (theme, auth, locale) | Context |
| Pass UI down 2-3 levels | children / slot props — not Context |
| Form submission | <form action> + useActionState |
| Submit button pending state | useFormStatus |
| Optimistic UI during an action | useOptimistic |
| Non-urgent state update | startTransition |
| Reset a subtree on identity change | key prop |
| Component, hook, or file too large | split — see COMPONENT-DESIGN.md |
| Data fetching | TanStack Query, or use() + Suspense — not a raw Effect |
TypeScript Integration
// Extend HTML element props
interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> {
variant?: "primary" | "secondary" | "destructive";
isLoading?: boolean;
}
// Generic components
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
getKey: (item: T) => string;
}
function List<T>({ items, renderItem, getKey }: ListProps<T>) {
return <ul>{items.map(item => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>;
}
// Event types
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {};
// Children: always React.ReactNode
interface CardProps { title: string; children: React.ReactNode; }
// Discriminated-union props — make impossible prop combinations unrepresentable
type AlertProps =
| { variant: "inline"; onDismiss?: never }
| { variant: "toast"; onDismiss: () => void }; // a toast REQUIRES onDismiss
For general type design — discriminated unions, generics, satisfies, type guards — see the typescript-best-practices skill. This section covers only React-specific typing; hook typing (useState/useReducer/useRef, custom-hook returns, nullable Context) is in COMPONENT-DESIGN.md.
Error Boundaries
Granular, per-section — never one app-wide catch.
import { ErrorBoundary } from "react-error-boundary";
function Dashboard() {
return (
<div>
<ErrorBoundary FallbackComponent={ErrorFallback}><AnalyticsChart /></ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}><RecentOrders /></ErrorBoundary>
</div>
);
}
Project Structure
One folder per domain. Inside it, every file lives in the folder named for its kind. The only file at a feature's root is index.ts.
src/
features/destinos/
components/ DestinoCard.tsx DestinoCard.test.tsx
hooks/ useGlobe.ts useGlobe.test.ts # not query hooks
api/ destinos.ts destinos.test.ts # fetch + parse, no React
queries/ destinos.ts # queryOptions, useQuery, useMutation
schemas/ destino.ts # zod schemas + their z.infer types
types/ destino.ts # types with no schema
utils/ monthFromUrl.ts monthFromUrl.test.ts
test/ fixtures.ts # fixtures shared by this feature's tests
index.ts # public API only
shared/ same kind folders, for code 2+ features use
routes/ or pages/ # wherever the router wants them; thin
BAD features/search/useDismiss.ts hook at the feature root
BAD features/legal/components.test.ts one test file for many components
BAD src/components/DestinoTable.tsx domain component outside its feature
GOOD features/search/hooks/useDismiss.ts
GOOD features/legal/components/LegalPage.test.tsx
A test sits next to the file it tests and carries its name; in a project that already keeps tests in __tests__/, follow it. A test of a whole flow goes in src/test/. Create a kind folder when its first file arrives, not before. Import another feature through its index.ts only. Colocate first; move to shared/ when 2+ features need it.
Rules
- Always use function components — no class components.
- Always treat
refas a normal prop; never writeforwardRefin new code. - Always render
<Context value={…}>directly — never<Context.Provider>. - Always use
use()to read context — neveruseContext. - Always derive state during render when a value is computable; don't sync it in an Effect.
- Always use
<form action>+useActionStatefor submissions;useFormStatusfor nested submit buttons. - Never add
useMemo/useCallback/React.memoin new code — the React Compiler handles memoization. See PERFORMANCE.md. - Never use an array index as a key for a dynamic list; use a stable id.
- Never initialize state from a prop expecting it to update — use a
keyreset or lift state up. - Never call hooks conditionally, in loops, or after an early return — top level only.
use()is the sole exception. - Prefer
children/ slot props over Context to avoid prop drilling. - Prefer granular per-section error boundaries; never rely on data-fetching Effects.
- Always handle the loading / error / empty trio in data-driven components — see LOADING-STATES.md.
- Always put a feature file in its kind folder (
components/ hooks/ api/ queries/ schemas/ types/ utils/); never at the feature root, never in a top-levelsrc/components/.
Reference Files
For deeper guidance, load the file matching what you're working on:
- COMPONENT-DESIGN.md — read before writing a new component or hook, and when splitting one or a file is getting large. The question is "how should this be structured?", not "is this file already too big?". Covers the single-responsibility hard limits (≤200 lines/file, ≤30 lines/function, ≤50-line JSX return, ≤5-6 props, ≤3 nesting levels), split vs don't-split signals, one-concern-per-hook plus hook naming, composition vs Context vs prop drilling, early returns, file organization, the complexity smell test, and the server/client component boundary note.
- USE-EFFECT.md — read when writing or reviewing a
useEffect. Covers the full "You Might Not Need an Effect" catalog (transforming data, resetting state viakey, event handling, chained Effects, notifying parents, passing data to a parent, app init, fetching), the decision checklist, when Effects are correct plus cleanup and race-condition (ignore/AbortController) patterns, dependency-array discipline, anduseSyncExternalStore. - PERFORMANCE.md — read when chasing re-renders or memoization. Covers React Compiler v1.0 (delete manual memo; the three narrow exceptions; ESLint enforcement), keys and
startTransition,useDeferredValuewith its React 19 initial-value arg,<Activity mode>for pre-render and keep-alive, and resource preloading (preload/preinit/prefetchDNS/preconnect). - LOADING-STATES.md — read when a component fetches data or shows async UI. Covers the loading / error / empty trio every data component must handle, skeleton screens vs spinners (and avoiding layout shift), building a reusable token-styled
Skeletonprimitive,<Suspense>+ error-boundary pairing and boundary placement, empty-state design, and accessibility (aria-busy,aria-live/role="status", focus management). - ZUSTAND.md — read when client/UI state outgrows
useStateand Context. Covers typed curried store creation, atomic selectors anduseShallowto prevent re-renders, exporting custom hooks over the raw store, event-driven actions,persist/immer/devtoolsmiddleware and composition order, the slices pattern, transient updates, and the rule that server state belongs in a query library, not a store. - FORMS.md — read when building or reviewing a form. Covers React Hook Form with
zodResolverand shadcn's<Form>, why a transforming schema needsuseForm<z.input<S>, unknown, z.output<S>>instead ofz.infer,defaultValuesand the controlled/uncontrolled flip, readingformStateinstead of hand-rolled flags, putting server rejections on the field withsetError, the Zod 3 → 4 differences, and the narrow case whereuseActionStateis the better call. - SHADCN.md — read before writing any UI in a repo that has a
components/ui/directory, including the first screen of a new app, and when adding or editing a shadcn component. The question is "does this repo already have a design system I must use?", not "am I editing a shadcn file?". Covers Base UI as the default since July 2026 and how to tell which primitive a file uses, migrating one component at a time, why you editcomponents/ui/*instead of wrapping it,cvavariants withVariantPropsrather than boolean props,cn()and whytailwind-mergematters for overrides,data-slotfor reaching into a child, theFieldfamily for form layout, and what belongs incomponents/ui/versuscomponents/. - TAILWIND-TOKENS.md — read before writing any
className, including the first one in a new app — picking colors, spacing, radius, or typography. The question is "what tokens does this repo already define?", not "am I restyling something?". Covers Tailwind v4 CSS-first@themeconfig and namespaces, semantic color tokens (never rawred-500),@theme inline, the--color-*: initialnamespace reset for hard palette lockdown, the semantic spacing scale, the ban on arbitrary values (p-[13px],text-[#3a3a3a]) and the narrow exceptions, plus ESLint enforcement tooling.