Blog

Blog · Best Practices

React Best Practices in 2026: 15 Rules for Modern React

Up-to-date React best practices for the era of the React Compiler and React 19 — derive state instead of syncing it, correct keys, data fetching, error boundaries, accessibility and TypeScript — each with why and a code example.

XCODX Team · 8 min read

React changed a lot in the last two years. The React Compiler now handles memoization for you, React 19 added the use() hook and Server Components, and class components are firmly legacy. Writing good React in 2026 means unlearning some old habits. Here are 15 modern React best practices, each with the reasoning and a code example.

1. Use function components and hooks exclusively

Class components are effectively legacy — the entire modern API surface (hooks, use(), Server Components, the React Compiler) targets function components. The one place you still need a class is a native error boundary, and you should wrap that once in a library rather than writing your own.

// ❌ legacy
class Greeting extends React.Component {
  render() { return <h1>Hi {this.props.name}</h1>; }
}

// ✅ modern
function Greeting({ name }) {
  return <h1>Hi {name}</h1>;
}

2. Follow the Rules of Hooks

React tracks hook state by call order, so calling a hook inside a condition, loop or early return desyncs that order and corrupts state. Always call hooks at the top level of a component; put conditions and early returns *after* every hook. The eslint-plugin-react-hooks rule catches violations at lint time.

// ❌ conditional hook — order changes between renders
function Profile({ id }) {
  if (!id) return null;
  const [user, setUser] = useState(null);
}

// ✅ hooks first, branch after
function Profile({ id }) {
  const [user, setUser] = useState(null);
  if (!id) return null;
}

3. Derive state during render — don't sync it with useEffect

Computing a value from props or state inside an Effect causes a wasted second render and risks infinite loops. Anything you can *calculate* from existing state is not state — compute it inline during render. Reserve Effects for synchronizing with systems *outside* React (network, DOM, subscriptions).

// ❌ redundant state + effect
const [total, setTotal] = useState(0);
useEffect(() => { setTotal(items.reduce((s, i) => s + i.price, 0)); }, [items]);

// ✅ derive during render
const total = items.reduce((s, i) => s + i.price, 0);

4. Let the React Compiler handle memoization

React Compiler 1.0 (stable since late 2025) auto-memoizes components and values at build time by analyzing the whole component, so manual useMemo/useCallback/React.memo becomes noise it can do more accurately. Write plain code and let the compiler optimize. Note it removes the *performance* reason for memoization, not the *semantic* need for correct keys and stable identity.

// ❌ manual memo boilerplate (pre-compiler reflex)
const sorted = useMemo(() => [...rows].toSorted(cmp), [rows]);
const onClick = useCallback(() => select(id), [id]);

// ✅ with the compiler enabled, just write plain code
const sorted = [...rows].toSorted(cmp);
const onClick = () => select(id);

5. Give list items stable, unique keys — never the array index

Keys are how reconciliation matches elements across renders. Index keys make React reuse the wrong DOM node and state when items are inserted, removed or reordered — producing stale inputs, misplaced focus and wasted remounts. Use a stable id from your data. Index keys are acceptable only for static lists that never reorder and hold no per-item state.

// ❌ index key — breaks on reorder/insert/delete
{todos.map((todo, i) => <TodoRow key={i} todo={todo} />)}

// ✅ stable identity from the data
{todos.map((todo) => <TodoRow key={todo.id} todo={todo} />)}

6. Colocate state; lift it only as far as it is shared

State kept close to where it is used is easier to reason about and re-renders less of the tree. Prematurely lifting everything to the top bloats parents and re-renders unrelated subtrees. Lift state only when two or more siblings must share it.

// ❌ modal open-state hoisted to App for no reason
function App() {
  const [isOpen, setIsOpen] = useState(false); // only Sidebar cares
  return <Sidebar isOpen={isOpen} setIsOpen={setIsOpen} />;
}

// ✅ keep it where it is used
function Sidebar() {
  const [isOpen, setIsOpen] = useState(false);
}

7. Prefer composition over prop drilling; use Context sparingly

Passing props through many intermediate layers couples components that do not use the data. Component composition (passing JSX as children) removes the drilling without Context’s re-render cost. Context is for truly global, low-frequency data (theme, auth) — not a state manager, since every consumer re-renders when the value changes.

// ❌ drilling `user` through Layout that ignores it
<Layout user={user}><Page user={user} /></Layout>

// ✅ composition — Layout just renders children
<Layout><Page user={user} /></Layout>

8. Choose controlled or uncontrolled inputs — and don't switch

Controlled inputs (value driven by state) give you per-keystroke validation; uncontrolled inputs (read via ref/FormData) are simpler for plain forms. Flipping between them — usually by passing value={undefined} then a string — triggers React’s "changing an uncontrolled input to controlled" warning and loses cursor state. Seed controlled state with '', never undefined.

// ✅ controlled — never undefined
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />

// ✅ uncontrolled — read on submit
<form onSubmit={(e) => {
  const data = new FormData(e.currentTarget);
  console.log(data.get("name"));
}}>
  <input name="name" defaultValue="" />
</form>

9. Fetch data with a loader or library, not raw useEffect

Hand-rolled useEffect fetching leaks race conditions, duplicate requests and missing caching. Framework loaders (Next.js Server Components, React Router loaders) or TanStack Query/SWR handle deduplication, caching, revalidation and cancellation. In React 19, the use() hook unwraps a promise directly under Suspense. If you must use an Effect, guard stale responses with an AbortController in cleanup.

// ❌ fragile effect fetch — race on fast prop changes
useEffect(() => {
  fetch(`/api/user/${id}`).then(r => r.json()).then(setUser);
}, [id]);

// ✅ cached, deduped, cancellable
const { data, isPending, error } = useQuery({
  queryKey: ["user", id],
  queryFn: () => fetchUser(id),
});

10. Always clean up Effects

Effects that add listeners, intervals or subscriptions without a cleanup leak memory and fire callbacks on unmounted components. The returned cleanup also runs before the effect re-runs, preventing duplicate subscriptions — including under Strict Mode’s intentional double-invoke in development.

useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

11. Wrap risky subtrees in error boundaries

An unhandled render error propagates up and unmounts the whole tree (a white screen). An error boundary catches it and shows a fallback, isolating the blast radius. Native boundaries still require a class in 2026, so use react-error-boundary for a hooks-friendly API. Boundaries only catch render/lifecycle errors — handle event-handler and async errors with try/catch.

import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary
  fallbackRender={({ error }) => <p role="alert">Something broke: {error.message}</p>}
  onReset={() => refetch()}
>
  <Dashboard />
</ErrorBoundary>

12. Build accessibility in from the start

Semantic HTML gives you keyboard and screen-reader support for free; <div onClick> gives you neither. Correct labels, roles and focus management are far cheaper to add now than to retrofit. Reach for ARIA only when no native element fits, manage focus after route changes and when opening dialogs, and lint with eslint-plugin-jsx-a11y.

// ❌ inaccessible: no keyboard, no role, no label
<div onClick={save}>Save</div>
<input onChange={onChange} />

// ✅ semantic + labeled
<button type="button" onClick={save}>Save</button>
<label htmlFor="email">Email</label>
<input id="email" type="email" onChange={onChange} />

13. Virtualize large lists

Rendering thousands of DOM nodes tanks scroll performance and memory. Virtualization renders only the rows in the viewport (plus a small overscan); TanStack Virtual is the current headless standard. Don’t virtualize short lists (under ~100 rows), and manage focus yourself since off-screen rows unmount.

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 40,
});
// render only rowVirtualizer.getVirtualItems()

14. Code-split routes with lazy + Suspense

Shipping the whole app in one bundle delays first paint. lazy() + dynamic import() splits chunks so a route or panel downloads on demand, with <Suspense> showing a fallback during load. Put the boundary at a natural loading unit (route, modal, tab), not around a leaf.

const Settings = lazy(() => import("./Settings"));

<Suspense fallback={<Spinner />}>
  <Settings />
</Suspense>

15. Type component props with TypeScript

Typed props catch wrong or missing props at compile time, document the component’s contract and power autocomplete. Model props as a plain type or interface — avoid React.FC (it complicates generics and historically implied children). In React 19, ref is a regular prop, so no forwardRef gymnastics.

type ButtonProps = {
  label: string;
  onClick: () => void;
  variant?: "primary" | "ghost";
};

function Button({ label, onClick, variant = "primary" }: ButtonProps) {
  return <button className={variant} onClick={onClick}>{label}</button>;
}

React best practices checklist

  • Function components + hooks only; follow the Rules of Hooks (lint enforces it).
  • Derive values during render; reserve Effects for external systems and always clean them up.
  • Let the React Compiler memoize — but keep correct, stable key props.
  • Colocate state; prefer composition over prop drilling; use Context sparingly.
  • Fetch with a loader/library (or use() + Suspense), not raw useEffect.
  • Wrap risky subtrees in an error boundary; code-split routes with lazy + Suspense.
  • Build accessibility in from the start; virtualize large lists.
  • Type props with TypeScript (skip React.FC).

Frequently asked questions

Do I still need useMemo and useCallback in 2026?
Much less often. The React Compiler (stable since late 2025) auto-memoizes at build time, so manual useMemo/useCallback/React.memo is mostly redundant. Keep them only where a specific semantic need for stable identity remains, and always keep correct key props — the compiler does not replace those.
Why shouldn't I use the array index as a key?
Index keys make React reuse the wrong element and its state when the list is reordered, inserted into, or filtered — causing stale inputs and misplaced focus. Use a stable unique id from your data. Index keys are only safe for static lists that never change order and hold no per-item state.
Should I fetch data in useEffect?
Prefer a framework loader (Server Components, React Router loaders) or a library like TanStack Query/SWR, which handle caching, deduplication and cancellation. In React 19 you can also unwrap a promise with the use() hook under Suspense. Raw useEffect fetching is error-prone (races, duplicate requests).
Is it still worth learning class components?
Only enough to read legacy code. All new React is function components and hooks. The single remaining use for a class is a native error boundary — and even then, wrap it once with react-error-boundary instead of writing your own.

Try these patterns in a live React sandbox: open XCODX Studio, which compiles JSX in the browser with an instant preview and no setup. New to React vs the alternatives? See React vs Vue and React vs Angular.