React Fundamentals Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Never Define Components Inside Components: A component declared inside another component gets a fresh identity on every parent render, so React unmounts and remounts it each time — destroying local state and effects; always hoist components to module scope:
function Parent() { function Child() {} /* bad — Child remounts every render */ }. - Treat Props as Immutable: Mutating a prop (e.g.,
props.items.push(x)) quietly mutates the parent's data and breaks React's rendering model; copy into new arrays or objects before modifying and let the parent own the state. - Extend Native Props With ComponentPropsWithoutRef: Type wrapper components with
React.ComponentPropsWithoutRef<"button">so every native attribute stays in sync with the DOM API; re-declaring props manually drifts the moment the DOM spec changes. - Prefer interface for Props:
interfacegives better error messages and supports declaration merging, so use it for component prop shapes; reservetypefor unions, mapped types, and other transformations that interfaces cannot express. - Guard Against 0 in && Conditionals:
{count && <Badge />}renders the literal string "0" whencountis 0 because 0 is falsy but still renderable; usecount > 0 && <Badge />or a ternary to avoid the infamous "0" bug. - Discriminated Unions for Status: Model state as
{ status: "success"; data } | { status: "error"; error }so TypeScript narrows inside each branch; this eliminates optional-chaining noise and makes unreachable branches obvious. - Switching Element Types Resets State: Changing a component type at the same tree position (e.g.,
<Spinner />→<DataTable />, or<input>↔<textarea>) unmounts the old subtree and wipes its state; keep the same type and toggle props when you want state to persist. - Pass Handlers, Not Invocations:
onClick={handleClick}attaches the function;onClick={handleClick()}calls it during render and wires the return value as the handler, which is almost never what you want — wrap in an arrow when you need to inject arguments:onClick={() => handleClick(id)}. - onChange Fires Every Keystroke: React's
onChangemaps to the nativeinputevent, not nativechange, so it fires on every keystroke instead of on blur — useonBlurif you actually want "commit on blur" semantics. - Use currentTarget, Not target:
e.targetis the element the event originated on (which may be a child), whilee.currentTargetis the element the handler is attached to; read attributes you control fromcurrentTargetto avoid surprises when clicking nested elements. - Escape to addEventListener When Needed: JSX handler props do not support
{ passive: false }orwindow/document-level events; for Escape-key listeners, scroll monitors, ortouchmoveneedingpreventDefault, useuseEffect+addEventListeneron a ref and return a cleanup. - Controlled Inputs Need value + onChange: Setting
valuewithout a matchingonChangemakes the input read-only because React pins the DOM value to state; either pair the two —<input value={name} onChange={e => setName(e.target.value)} />— or switch todefaultValuefor an uncontrolled input. - Checkboxes and Radios Use checked: Checkbox and radio inputs consume
checked/defaultChecked, notvalue/defaultValue; mixing them up silently does the wrong thing sincevalueon a checkbox is the submitted token, not the checked state:<input type="checkbox" checked={on} onChange={e => setOn(e.target.checked)} />. - Call useFormStatus in a Child:
useFormStatus()only returns accurate pending info when called from inside a descendant of the<form>; calling it in the same component that renders the form returns stale defaults with no error, so extract the submit button into its own component. - Parse input.value Strings:
e.target.valueis always a string — even for<input type="number">— so wrap it withNumber(...)orparseInt(...)before using it in math or storing typed state:onChange={e => setAge(Number(e.target.value))}. - Use className, Not class: JSX compiles to
React.createElement, so HTML'sclassattribute isclassName,forishtmlFor, and styles take a camelCase object; using the HTML names compiles but warns and can silently drop styling in strict environments. - Objects Are Not Valid Children: Rendering
{user}throws "Objects are not valid as a React child"; render a specific field like{user.name}orJSON.stringify(user)for debug output, and remember that0renders as text butnull/false/undefinedrender nothing. - ReactNode for children, JSX.Element for Returns: Type
childrenasReact.ReactNode(which covers JSX, strings, numbers, arrays,null) and reserveReact.JSX.Elementfor return types that always return a single JSX element. - Keys Unique Among Siblings: A list's
keymust be unique among its siblings (not globally), and it must be stable across renders — useitem.id, notMath.random()orDate.now(), or React will remount every row and destroy its state. - Avoid Index Keys on Dynamic Lists: Using
indexaskeyon lists that reorder, filter, or insert causes React to reuse the wrong DOM nodes, producing stale input values and broken focus; index keys are only safe for truly static lists. - Use key to Force Remount: Changing the
keyon a component is the canonical way to reset all of its internal state —<PlayerProfile key={currentPlayerId} />deliberately remounts when the player changes, which is a feature, not a hack. - Short Fragments Can't Take key: The
<>...</>shorthand does not accept akeyprop; when mapping fragments, importFragmentand use<Fragment key={...}>...</Fragment>to avoid duplicate-key warnings or unkeyed renders. - ref.current Doesn't Trigger Renders: Updating
ref.currentdoes not re-render the component — that is the point; reach foruseRefwhen the UI does not depend on the value (timer IDs, observers, latest-value cache) anduseStatewhen it does. - ref Is a Regular Prop in React 19:
forwardRefis deprecated; just typerefas an ordinary prop withReact.Ref<HTMLInputElement>and destructure it alongside the others — and remember{...props}now spreadsreftoo, so pull it off explicitly when passing through. - Return Cleanup From Ref Callbacks: React 19 ref callbacks can return a cleanup function like
useEffect, so wire observers asref={node => { if (!node) return; const obs = new ResizeObserver(...); obs.observe(node); return () => obs.disconnect(); }}instead of the old null-check dance.