React Patterns Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Prefer Named Slots Over One children: Overloading
childrenfor multi-region layouts loses placement control; expose namedReactNodeprops —({ header, sidebar, footer }: { header: ReactNode; sidebar: ReactNode; footer: ReactNode })— so consumers can fill each slot independently and you keep TypeScript on their side. - Pick ReactNode, ReactElement, or ComponentType:
ReactNodeaccepts strings/null/arrays (best for slots and children),ReactElementnarrows to a single JSX element, andComponentType<P>is a component you can instantiate — choose intentionally because they type different composition styles. - Build Compound Components on Context: A compound root owns state and publishes it via context so
Root.Item,Root.Trigger, andRoot.Contentwork at any JSX depth — guard the consumer hook with a thrown error when used outside the root instead of returning a silentnull. - Mark Compound Roots as "use client": Compound components rely on
createContextanduseState, so the root file must be a Client Component in Next.js — and use two-level context (AccordionContext+ per-itemItemContext) to scope identity to each item. - Split State and Actions Contexts: Put state in one context and memoized actions in another so buttons that only dispatch never re-render on state changes;
useReducer'sdispatchis already React-stable and needs no extra memoization. - Never Inline Object Context Values: Passing a fresh object literal as the context
valueon every parent render defeats memoization and re-renders every consumer; wrap the value inuseMemokeyed on the actual state, or split contexts so updater-only consumers are immune. - Scope Providers Narrowly: Mount a provider on the smallest subtree that actually needs it; a provider at the root blasts re-renders across unrelated pages and makes lazy code-splitting less effective.
- Support Both Controlled and Uncontrolled: Library components should detect controlled mode via
value !== undefinedand fall back to internal state —const [inner, setInner] = useState(defaultValue); const val = value !== undefined ? value : inner— wrap the decision in a reusableuseControllableStatehook and never switch modes after mount. - Don't Debounce State Updates for Controlled Inputs: Debouncing the
onChangethat sets state freezes the input because the DOM value lags behind the user; debounce side effects (search, autosave) while always updating controlled state synchronously. - Error Boundaries Need getDerivedStateFromError: Implement
getDerivedStateFromError(pure render-phase state derivation) for the fallback switch andcomponentDidCatch(post-commit) for logging to Sentry; there is no hook equivalent, so use a class orreact-error-boundary. - Reset Boundaries via resetKeys + key: Use
resetKeysso the boundary auto-recovers when a route param changes, and put akeyon the boundary's children so clearinghasErrorforces a remount — simply resetting state leaves the subtree in its corrupted state. - Know What Error Boundaries Miss: They do not catch errors in event handlers, async code, SSR, or the boundary itself; wrap those in explicit
try/catch, and in Next.js rely on the route-levelerror.tsxconvention witherror.digestfor server errors. - Call HOCs at Module Scope: Calling
withAuth(Component)inside another component's body creates a brand-new component type every render and destroys state; apply HOCs once at module scope —const ProtectedPage = withAuth(DashboardPage)— and always set a meaningfuldisplayName. - Forward Refs and Statics Through HOCs: HOCs drop refs and static methods by default, so wrap with
React.forwardRefinternally and copy statics withhoist-non-react-statics— otherwise consumers lose imperative handles and attached constants. - Profile Before You Memoize: Premature
memo/useMemo/useCallbackadds comparison overhead that can slow fast components; use React DevTools Profiler to identify real hot spots and remember inlineonClick={() => …}orstyle={{…}}defeats memo via fresh references. - useTransition Owns, useDeferredValue Receives: Use
useTransitionwhen you own the state setter and want to mark the update non-urgent; useuseDeferredValueto lag a value you receive as a prop, and compare it with the current value to show a subtle stale indicator. - Virtualize Large Lists: Tens of thousands of rows cannot be rescued by
memo— reach forreact-virtual/react-windowor CSScontent-visibility: auto, and keep stablekeys (IDs, not indices) so rows are not remounted on scroll. - createPortal Keeps the React Tree: Events bubble through the React tree (not the DOM tree) across portals, so clicks inside a modal still hit React ancestors — call
stopPropagationwhen you need a clean break, and trap focus plus save/restore it for real modals. - Guard Portals for SSR:
document.bodycrashes server rendering, so gate the portal with auseEffect-setmountedflag —const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), [])— or mark the component"use client"in Next.js, and create a dedicated container node instead of piling up empty divs onbody. - Beware Inline Render Props: An inline
children={(state) => <X />}creates a new function reference on every render and breaksReact.memoon the parent; hoist the function or accept that memo will not apply here, and remember hooks cannot be called inside a render-prop function. - Export Prop Collections: Headless components should return spreadable prop objects that bundle event handlers plus ARIA attributes —
<button {...triggerProps}>Open</button> <div {...contentProps}>…</div>— consumers cannot forget accessibility wiring because it ships with the collection. - Model State as Discriminated Unions: Replace
isLoading/isError/hasDataboolean soup with a singlestatustagged union so impossible combinations are unrepresentable; each state carries only the data valid for that state. - Keep Reducers Pure: Under Strict Mode and concurrent rendering React may call a reducer multiple times, so never fetch, write, or log inside it — and always end with
return statefor unknown events so unhandled transitions don't produceundefined. - Create Suspense Promises Outside Render: Creating a promise in the component body triggers an infinite suspend loop because
use(promise)treats a new reference as a new pending request; hoist promise creation into a parent, a cache, or an event handler. - Pair Suspense With Error Boundary and Transition: A rejected promise without an ancestor error boundary unmounts the whole tree; also use
useTransitionwhen replacing Suspense-driven UI so fast fetches don't flash a skeleton, and nest boundaries so secondary content streams in after the critical shell.