React Best Practices : Build Faster, Safer, Cleaner UIs (Without the Usual Pain)
Why “best practices” matter (even if you’re already shipping)
React doesn’t punish you immediately for messy code—it waits until your app grows. Then you get:
- “Why does this rerender 20 times?”
- “Where does this state even live?”
- “Why does the UI glitch when I navigate?”
- “Why is this component impossible to test?”
The fixes are rarely fancy. They’re usually a handful of disciplined habits.
1) Keep components small… but not anxious
A good component does one job:
- UI component: renders layout, styles, accessibility
- Container/component: orchestrates data fetching, state, side effects
- Hook: reusable logic
âś… Pattern: split data + view
tsx// UsersPage.tsx (container) import { useUsers } from "./useUsers"; import { UsersTable } from "./UsersTable"; export function UsersPage() { const { users, isLoading, error, refetch } = useUsers(); if (isLoading) return <p>Loading users…</p>; if (error) return <ErrorState message={error.message} onRetry={refetch} />; return <UsersTable users={users} />; } function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { return ( <div role="alert"> <p>{message}</p> <button onClick={onRetry}>Try again</button> </div> ); }
tsx// UsersTable.tsx (pure view) type User = { id: string; name: string; email: string }; export function UsersTable({ users }: { users: User[] }) { return ( <table> <thead> <tr><th>Name</th><th>Email</th></tr> </thead> <tbody> {users.map(u => ( <tr key={u.id}> <td>{u.name}</td> <td>{u.email}</td> </tr> ))} </tbody> </table> ); }
2) Put state where it belongs (the “closest common owner” rule)
Before adding global state, try this order:
- Local component state
- Lift state up to nearest shared parent
- Extract a hook used by siblings
- Context for app-wide concerns (theme/auth/feature flags)
- External state library (when it’s truly cross-cutting)
âś… Rule of thumb
If fewer than ~3 unrelated areas need the state, it’s probably not “global.”
3) Prefer derived state over duplicated state
Duplicating state creates bugs because it gets out of sync.
❌ Avoid: storing what you can compute
tsxconst [items, setItems] = useState<CartItem[]>([]); const [total, setTotal] = useState(0); // duplicated state (risky)
âś… Do: derive it
tsxconst [items, setItems] = useState<CartItem[]>([]); const total = useMemo( () => items.reduce((sum, i) => sum + i.price * i.qty, 0), [items] );
4) Side effects: keep them predictable
Use effects for syncing with external systems (network, DOM APIs, subscriptions), not for computing values.
âś… UseEffect checklist
- Put everything you read inside the dependency array (or refactor)
- Clean up subscriptions/timeouts
- Avoid effect chains that bounce state back and forth
tsxuseEffect(() => { const controller = new AbortController(); fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal }) .then(r => r.json()) .then(setResults) .catch(err => { if (err.name !== "AbortError") setError(err); }); return () => controller.abort(); }, [query]);
5) Performance: measure first, then optimize the real bottleneck
Most React “performance fixes” are premature. When you do need them, prioritize:
React.memofor expensive leaf componentsuseMemo/useCallbackto stabilize props only when it helps- List virtualization for long lists
- Avoid rendering work you can defer
âś… Practical React.memo example
tsxtype RowProps = { name: string; onSelect: (name: string) => void }; const Row = React.memo(function Row({ name, onSelect }: RowProps) { return <button onClick={() => onSelect(name)}>{name}</button>; }); export function Rows({ names }: { names: string[] }) { const onSelect = useCallback((name: string) => { console.log("selected", name); }, []); return names.map(n => <Row key={n} name={n} onSelect={onSelect} />); }
Tip: If you don’t have a measurable slowdown, skip memoization. It adds complexity.
6) Data fetching: don’t reinvent caching and retries
If your app fetches data regularly, use a mature query layer (or your framework’s recommended approach). What you want:
- caching
- deduping
- retries
- background refetch
- request cancellation
- stale-while-revalidate patterns
Even if you hand-roll something small, keep a consistent shape:
tstype AsyncState<T> = | { status: "idle" | "loading"; data?: undefined; error?: undefined } | { status: "success"; data: T; error?: undefined } | { status: "error"; data?: undefined; error: Error };
7) Forms: treat them as a system
Forms aren’t “just inputs.” They have:
- validation
- error UI
- submission states
- accessibility rules
- async server errors
âś… Example: accessible field with errors
tsxtype FieldProps = { id: string; label: string; error?: string; } & React.InputHTMLAttributes<HTMLInputElement>; export function TextField({ id, label, error, ...props }: FieldProps) { const errorId = `${id}-error`; return ( <div> <label htmlFor={id}>{label}</label> <input id={id} aria-invalid={Boolean(error)} aria-describedby={error ? errorId : undefined} {...props} /> {error && ( <p id={errorId} role="alert"> {error} </p> )} </div> ); }
8) Accessibility: bake it in, don’t “add it later”
Quick wins that prevent real user pain:
- Use semantic HTML (
button,nav,main,label) - Don’t remove focus outlines (style them instead)
- Use
aria-*only when semantic HTML can’t express it - Use
role="alert"for important errors - Ensure modals trap focus (use a library if needed)
9) TypeScript: let the compiler do the boring work
Best TS habits in React:
- Type props explicitly for reusable components
- Prefer
unknownoverany - Use discriminated unions for UI states
tstype Loadable<T> = | { state: "loading" } | { state: "ready"; data: T } | { state: "error"; error: string }; function renderUser(state: Loadable<{ name: string }>) { switch (state.state) { case "loading": return "Loading…"; case "ready": return state.data.name; case "error": return state.error; } }
10) Testing: test behavior, not implementation
Aim for tests that survive refactors:
- Prefer user-centric tests: “click button → UI updates”
- Mock network at the boundary (e.g., MSW style approaches)
- Don’t test internal state directly unless necessary
A good test fails for the right reason.
A simple “Best Practices” checklist you can paste into PRs
- [ ] Components: one responsibility; logic extracted to hooks when reused
- [ ] State: minimal, colocated, derived state preferred
- [ ] Effects: only for external sync; cleaned up properly
- [ ] Performance: no premature memoization; large lists virtualized
- [ ] Data: errors + loading states handled; cancel/abort where relevant
- [ ] Forms: accessible labels; error messages linked; disabled states
- [ ] A11y: semantic HTML first; keyboard navigation works
- [ ] Types: no
any; good unions for async UI - [ ] Tests: behavior-focused; avoids brittle internals
Closing: the “boring” React code is the best React code
If your components are predictable, your state is minimal, and your side effects are contained, React becomes almost relaxing. And when the app scales, you won’t be untangling a rerender mystery at 2 a.m.