Skip to content

Patterns and Performance

Most React problems at scale are not about syntax. They are about where state lives, how components are composed, and which parts of the tree re-render. This page covers those, plus the recurring bugs that come from getting them wrong.

The most useful pattern in React is also the simplest: accept children instead of specific props, and let the caller decide what goes inside.

// Rigid: every new use case needs another prop
function Card({ title, body, footerText, showFooter }) { … }
// Flexible: the caller supplies the content
function Card({ children }) {
return <section className="card">{children}</section>;
}

This inverts control. Card owns layout and styling; the caller owns content. Adding a new kind of card no longer means editing Card.

For components with several regions, pass JSX through named props:

Page.jsx
function Page({ header, sidebar, footer, children }) {
return (
<div className="page">
<header>{header}</header>
<div className="page-body">
<nav>{sidebar}</nav>
<main>{children}</main>
</div>
<footer>{footer}</footer>
</div>
);
}
<Page header={<Nav user={user} />} sidebar={<Filters />} footer={<Legal />}>
<Results items={items} />
</Page>

Related components that share implicit state through context, exposed as one namespace:

Tabs.jsx
const TabsContext = createContext(null);
export function Tabs({ defaultTab, children }) {
const [active, setActive] = useState(defaultTab);
const value = useMemo(() => ({ active, setActive }), [active]);
return (
<TabsContext.Provider value={value}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
export function Tab({ id, children }) {
const { active, setActive } = useContext(TabsContext);
return (
<button aria-selected={active === id} onClick={() => setActive(id)}>
{children}
</button>
);
}
export function TabPanel({ id, children }) {
const { active } = useContext(TabsContext);
return active === id ? <div role="tabpanel">{children}</div> : null;
}
<Tabs defaultTab="a">
<Tab id="a">First</Tab>
<Tab id="b">Second</Tab>
<TabPanel id="a"></TabPanel>
<TabPanel id="b"></TabPanel>
</Tabs>

The caller controls markup and order; the components coordinate behind the scenes. This is how most headless UI libraries are shaped.

Pass a function as a prop (often as children) so the component supplies data and the caller supplies the markup:

function MousePosition({ children }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const onMove = (e) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('pointermove', onMove);
return () => window.removeEventListener('pointermove', onMove);
}, []);
return children(pos);
}
<MousePosition>{({ x, y }) => <p>{x}, {y}</p>}</MousePosition>

Custom hooks replaced most render-prop usage — a useMousePosition() hook is simpler. Render props remain useful when the component must also render something itself (list virtualizers, drag-and-drop containers).

Work down this list and stop at the first level that works.

Level Use when Cost
Local (useState) Only one component cares None — always start here
Lifted to a common parent Two or three nearby components share it Re-renders everything under that parent
Context Many components across the tree need it, and it changes rarely (theme, user, locale) Every consumer re-renders on value change
External store (Zustand, Redux Toolkit, Jotai) Global state that changes often, or where components need to subscribe to a slice A dependency and a new mental model
Server-state library (TanStack Query, SWR) The data comes from an API The right tool — server data is not client state

Two rules that prevent most architecture pain:

  • Colocate. Put state as close to where it is used as possible. Moving state up is easy later; untangling global state is not.
  • Server state is not client state. Data fetched from an API is a cache of something you do not own. Putting it in Redux means hand-writing invalidation, staleness, and refetching. Use a query library.

A component re-renders when:

  1. Its own state changes (setState, dispatch) — and the new value is not Object.is-equal to the old.
  2. Its parent re-renders — by default, regardless of whether its props changed.
  3. A context it consumes changes value.

Note what is not on the list: changing a prop does not itself cause a re-render. Props only change because a parent re-rendered, which is case 2.

function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ExpensiveTree /> {/* re-renders on every click */}
</>
);
}

ExpensiveTree takes no props, yet it re-renders every time count changes, because App re-ran and produced a new <ExpensiveTree /> element.

Usually this is fine — re-rendering is cheap and only real DOM differences get committed. It matters when the subtree is genuinely expensive.

If only part of the tree needs the state, push the state into that part.

function App() {
return (
<>
<Counter /> {/* owns count; only this re-renders */}
<ExpensiveTree />
</>
);
}

This is the best fix, and it requires no memoization at all.

Fix 2: pass the expensive part as children

Section titled “Fix 2: pass the expensive part as children”

When state must live in the parent, hand the subtree in from above. The <ExpensiveTree /> element is then created by App, which is not re-rendering — and React skips re-rendering a child whose element is referentially identical to last time.

function App() {
return (
<Container>
<ExpensiveTree /> {/* element created here, unchanged by Container's state */}
</Container>
);
}
function Container({ children }) {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children} {/* same element object as last render → subtree skipped */}
</>
);
}

memo() wraps a component so React skips re-rendering it when its props are shallowly equal to the previous render’s.

import { memo } from 'react';
const ExpensiveTree = memo(function ExpensiveTree({ items, onSelect }) {
});

Shallow means each prop compared with Object.is. Which is why this defeats it:

<ExpensiveTree
items={items.filter(Boolean)} // new array identity every render
onSelect={(id) => select(id)} // new function identity every render
config={{ dense: true }} // new object identity every render
/>

Every one of those props is a fresh value on each render, so the shallow comparison always fails and memo does nothing but add work. To make it effective, stabilize the props:

const visible = useMemo(() => items.filter(Boolean), [items]);
const handleSelect = useCallback((id) => select(id), [select]);
const config = useMemo(() => ({ dense: true }), []);

memo is contagious: making one component fast usually means memoizing every non-primitive prop it receives, all the way up. That maintenance cost is why fixes 1 and 2 come first.

memo accepts a second argument, a custom comparison (prev, next) => boolean returning true to skip the render. Use it rarely; a deep comparison can cost more than the render it prevents.

React DevTools has a Profiler tab: record an interaction and it shows which components rendered, how long each took, and why. Turn on “Highlight updates when components render” in the DevTools settings to see re-renders live.

There is also a <Profiler> component for programmatic measurement:

import { Profiler } from 'react';
<Profiler id="Sidebar" onRender={(id, phase, actualDuration) => log(id, phase, actualDuration)}>
<Sidebar />
</Profiler>

Do this before optimizing. Almost every hand-added useMemo in a typical codebase protects a computation that takes microseconds.

An uncontrolled component keeps its own state internally (in the DOM, or in its own useState) and you read it when you need it. A controlled component takes its value from a prop and reports changes upward.

// Uncontrolled: the DOM holds the value
<input defaultValue="hello" ref={inputRef} />
const value = inputRef.current.value;
// Controlled: React holds the value
<input value={value} onChange={(e) => setValue(e.target.value)} />

Uncontrolled is less code and fine for a simple form submitted all at once — new FormData(e.target) reads every field without any state:

function handleSubmit(e) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
submit(data);
}

Controlled is required when you need live validation, formatting as you type, disabling submit based on content, or syncing two inputs.

The same idea applies to your own components. A <Modal> that owns isOpen is uncontrolled; one that takes isOpen and onClose is controlled. Many libraries support both: an optional value prop makes it controlled, defaultValue makes it uncontrolled.

An error thrown during render, in a lifecycle method, or in a constructor unmounts the entire React tree by default — you get a blank page. An error boundary is a component that catches errors from its subtree and renders a fallback instead.

This is the one feature that still requires a class component; there is no hook equivalent.

ErrorBoundary.jsx
import { Component } from 'react';
class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error }; // render the fallback
}
componentDidCatch(error, info) {
logToService(error, info.componentStack); // report it
}
render() {
if (this.state.error) return this.props.fallback;
return this.props.children;
}
}
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<Dashboard />
</ErrorBoundary>

What boundaries do not catch: errors in event handlers, in setTimeout or other async callbacks, in server rendering, and errors thrown by the boundary itself. Handle those with ordinary try/catch.

Place boundaries at meaningful seams — around each route, each independent widget — so one broken panel does not take down the page. In practice most teams use the react-error-boundary package, which wraps this in a hook-friendly API with reset support, rather than writing the class by hand.

A function created in one render captures that render’s values forever.

// The interval callback captured count === 0 and never sees another value
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);

Fixes: use the functional updater (setCount((c) => c + 1)), add the real dependency so the effect re-subscribes, or store the changing value in a ref that the callback reads.

todos.push(newTodo); setTodos(todos); // same identity → no re-render
user.name = 'Grace'; setUser(user); // same identity → no re-render
items.sort(byName); setItems(items); // sort mutates in place

Always produce a new object or array. See immutability.

  • key={index} on a list that reorders or gets items inserted → state and DOM attach to the wrong items.
  • key={Math.random()} → every item is destroyed and rebuilt on every render; focus and animation die.
  • Missing keys → React warns, and reconciliation falls back to positional matching.
  • Key on the wrong element — it belongs on the outermost element returned from map.
// Two sources of truth that will disagree
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);
// One source of truth
const count = items.length;

If it happens because the user did something, it belongs in the handler, not an effect. See when you don’t need an effect.

function Parent() {
function Row() { … } // new function identity every render
return <Row />; // React unmounts and remounts it each time
}

State inside Row resets on every parent render, inputs lose focus mid-typing, and the DOM is rebuilt constantly. Move the definition to module scope.

An early return before a hook, or a hook inside an if, shifts every following hook to the wrong storage slot. Hooks go at the top, unconditionally. See the rules of hooks.

<MemoizedChild style={{ margin: 8 }} /> // new object every render → memo never hits

Hoist constant objects to module scope, or memoize them.

  • Composition — children, named slots, compound components — is how React replaces inheritance and configuration props.
  • Keep state as low as possible; escalate to lifting, then context, then an external store, only when needed. Server data belongs in a query library.
  • A component re-renders when its own state changes, its parent re-renders, or a consumed context changes.
  • Move state down or pass expensive subtrees as children before reaching for memo.
  • memo compares props shallowly; unstable object, array, and function props silently defeat it.
  • Profile before optimizing; most memoization in real codebases is unnecessary.
  • Controlled components put React in charge of the value; uncontrolled ones read it on demand — pick per use case.
  • Error boundaries are class components that catch render-time errors in their subtree, not handler or async errors.
  • Most React bugs trace back to stale closures, mutated state, or misused keys.