React Performance Patterns: Memoization, Refs & Composition
Write React that scales. Learn when memoization actually helps, model complex state with useReducer, escape the DOM with refs and portals, peek at concurrent features, and avoid the performance traps that trip up experienced teams.
4 sections · ~35 min · 5-question quiz (pass ≥ 70%)
1Memo, useMemo & useCallback: When Optimization Helps
React re-renders a component whenever its state changes or its parent re-renders. Usually that's fast enough. Optimize only when profiling shows a problem.
React.memo wraps a component and skips re-render if props are shallow-equal:
const ExpensiveChart = memo(function ExpensiveChart({ data, onSelect }) {
// heavy rendering...
return <svg>{/* thousands of nodes */}</svg>;
});
useMemo caches a computed value between renders:
const sortedItems = useMemo(
() => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
useCallback caches a function reference so memoized children don't re-render unnecessarily:
const handleSelect = useCallback((id) => {
setSelected(id);
}, []);
When to use: expensive pure computations, passing callbacks to memoized children, or stabilizing dependencies for effects. When NOT to use: everywhere by default — memoization has its own cost (comparing deps, storing caches) and can make code harder to follow. Measure first with React DevTools Profiler.
2useReducer: Structured State for Complex Transitions
When state logic involves multiple sub-values or the next state depends on the previous in non-trivial ways, useReducer centralizes updates in one place:
const initialState = { status: "idle", data: null, error: null };
function reducer(state, action) {
switch (action.type) {
case "fetch/start":
return { ...state, status: "loading", error: null };
case "fetch/success":
return { status: "success", data: action.payload, error: null };
case "fetch/error":
return { status: "error", data: null, error: action.payload };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function DataPanel({ url }) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
dispatch({ type: "fetch/start" });
fetch(url)
.then((r) => r.json())
.then((data) => dispatch({ type: "fetch/success", payload: data }))
.catch((err) => dispatch({ type: "fetch/error", payload: err.message }));
}, [url]);
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <Error message={state.error} />;
return <Table rows={state.data} />;
}
useReducer shines for wizards, shopping carts, and state machines where you want predictable transitions and easy testability (test the reducer as a pure function). Pair with useContext to avoid prop drilling dispatch — the pattern behind many lightweight state libraries.
3Refs, Portals & Imperative Escape Hatches
Refs hold mutable values that persist across renders without triggering re-renders:
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}
Use refs for: DOM measurements, focus management, integrating non-React widgets, and storing interval IDs. Avoid refs for data that should appear on screen — that's state.
Portals render children into a different DOM node while keeping the React tree intact:
import { createPortal } from "react-dom";
function Modal({ open, onClose, children }) {
if (!open) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.getElementById("modal-root")
);
}
Portals solve z-index stacking, overflow clipping, and focus-trap requirements for modals and tooltips. Event bubbling still follows the React component tree, not the DOM tree — clicks inside the portal still bubble to React ancestors.
4Concurrent Features, Composition & Common Pitfalls
React 18+ introduces concurrent rendering — the ability to interrupt, pause, and prioritize updates. useTransition marks state updates as non-urgent:
function SearchPage() {
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value); // urgent: keep input responsive
startTransition(() => {
setFilteredResults(filterHugeList(e.target.value)); // non-urgent
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <span>Updating…</span>}
<ResultsList results={filteredResults} />
</>
);
}
Composition over inheritance — React has no extends Component hierarchy for UI reuse. Prefer children, render props, and compound components:
function Tabs({ children }) { /* context + layout */ }
Tabs.List = TabList;
Tabs.Panel = TabPanel;
// Usage: <Tabs><Tabs.List /><Tabs.Panel /></Tabs>
Common pitfalls:
- Creating objects/arrays inline in JSX props (
style={{ color: "red" }}) breaks memoization — hoist or memoize. - Missing effect dependencies causing stale closures — fix the deps, don't disable eslint.
- Storing derived data in state instead of computing during render.
- Over-using Context for high-frequency updates, re-rendering the entire app.