Performance is the concern that separates production-quality React code from tutorial-grade code. Most React applications do not have a rendering problem — but the ones that do can feel sluggish and frustrating. The key is knowing when optimization matters and what tools actually help.
When Does React Re-render?
A component re-renders when:
-
Its state changes — calling
setStatere-renders the component and all children. - Its parent re-renders — even if props haven't changed, children re-render by default.
-
A consumed context changes — any
useContextconsumer re-renders when the context value updates.
Every optimization technique targets one or more of these triggers.
Rule 1: Don't Optimize Prematurely
Wrapping every value in useMemo and every function in useCallback is not optimization — it is overhead. Memoization has a cost: storing previous values, comparing dependencies, managing cached references. If the computation is trivial, memoization costs more than recomputing.
// Don't do this — memoization costs more than the addition
const total = useMemo(() => price + tax, [price, tax]);
// Just compute it
const total = price + tax;
Measure first with React DevTools Profiler. Optimize the slow parts, not everything.
useMemo — When It Actually Helps
useMemo caches a computed value and recalculates only when dependencies change. It helps in two scenarios:
Expensive computations:
function ProductList({ products, filter }: Props) {
const filtered = useMemo(
() => products.filter((p) => p.category === filter),
[products, filter]
);
return (
<ul>
{filtered.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Preserving referential equality for memoized children:
function Dashboard({ data }: Props) {
const chartConfig = useMemo(
() => ({ labels: data.map((d) => d.label), values: data.map((d) => d.value) }),
[data]
);
return <MemoizedChart config={chartConfig} />;
}
useCallback — The Misunderstood Hook
useCallback only matters when the function is passed to a React.memo child or used as a hook dependency. Without React.memo on the child, useCallback does nothing useful.
// Before: new reference every render, MemoizedList always re-renders
function SearchPage() {
const [query, setQuery] = useState("");
const handleSelect = (id: string) => console.log("Selected:", id);
return <MemoizedList onSelect={handleSelect} />;
}
// After: stable reference, MemoizedList skips re-renders
function SearchPage() {
const [query, setQuery] = useState("");
const handleSelect = useCallback((id: string) => {
console.log("Selected:", id);
}, []);
return <MemoizedList onSelect={handleSelect} />;
}
State Structure Matters
Split unrelated state:
// Bad: updating name re-renders components that only read age
const [form, setForm] = useState({ name: "", age: 0 });
// Good: independent updates
const [name, setName] = useState("");
const [age, setAge] = useState(0);
Derive what you can:
// Bad: extra state that must stay in sync
const [items, setItems] = useState<Item[]>([]);
const [count, setCount] = useState(0);
// Good: derived value
const [items, setItems] = useState<Item[]>([]);
const count = items.length;
The useRef Pattern for Stable Callbacks
Store the latest callback in a ref to get a stable function reference that always calls the newest version — without adding the callback to dependency arrays:
import { useLatest } from "@reactuses/core";
function useInterval(callback: () => void, delay: number) {
const callbackRef = useLatest(callback);
useEffect(() => {
const id = setInterval(() => callbackRef.current(), delay);
return () => clearInterval(id);
}, [delay]); // callback is NOT a dependency
}
How ReactUse Hooks Handle Performance Internally
ReactUse hooks use three key patterns:
Refs for callbacks. Hooks like
useThrottleFnstore your callback viauseLatest. The wrapper calls the latest version through the ref — no stale closures, no need foruseCallback.Memoized setup. Expensive initialization (creating throttled functions) is wrapped in
useMemo, running only when configuration changes.Automatic cleanup. Pending timers are cancelled on unmount via
useUnmount.
// Simplified internal pattern of useThrottleFn
function useThrottleFn(fn, wait, options) {
const fnRef = useLatest(fn);
const throttled = useMemo(
() => throttle((...args) => fnRef.current(...args), wait, options),
[wait]
);
useUnmount(() => throttled.cancel());
return { run: throttled, cancel: throttled.cancel, flush: throttled.flush };
}
You don't need to wrap callbacks in useCallback before passing them to ReactUse hooks — the ref pattern handles it.
Practical Example: Optimized Search
Before — API call on every keystroke:
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Item[]>([]);
useEffect(() => {
if (query) {
fetch(`/api/search?q=${query}`).then((r) => r.json()).then(setResults);
}
}, [query]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultList items={results} />
</div>
);
}
After — debounced + memoized, ~90% fewer API calls:
import { useDebounce } from "@reactuses/core";
import { memo, useState, useEffect } from "react";
const MemoizedResultList = memo(ResultList);
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const [results, setResults] = useState<Item[]>([]);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`).then((r) => r.json()).then(setResults);
}
}, [debouncedQuery]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<MemoizedResultList items={results} />
</div>
);
}
React 19 Compiler
The React Compiler aims to automatically insert useMemo and useCallback at build time. When it ships broadly, many manual memoization patterns become unnecessary. However, it does not replace good state design, debouncing, throttling, or ref-based patterns. It automates the mechanical part — the architectural decisions remain yours.
Common Mistakes
- Memoizing everything. Adds overhead without measurable benefit for trivial computations.
-
useCallbackwithoutReact.memo. Stable reference is useless if the child re-renders anyway. - All state in one object. Every field update triggers re-renders for all consumers.
- Wrong dependency arrays. Missing deps cause stale closures; extra deps cause unnecessary recomputation.
-
Inline objects in JSX.
style={{ color: "red" }}creates new references every render, defeating memoization.
Frequently Asked Questions
When should I use useMemo?
Use useMemo when you have measured a slow computation (filtering large arrays, complex transformations) or when you need referential equality for a value passed to a React.memo child. Do not use it for trivial calculations like arithmetic or string concatenation.
Does useCallback improve performance on its own?
No. useCallback only helps when the returned function is passed to a component wrapped in React.memo or used as a dependency in another hook like useEffect. Without those consumers, the stable reference has no effect.
How do I know if re-renders are a problem?
Use the React DevTools Profiler. It shows which components rendered, how long each render took, and what triggered it. Focus on components with render times above 1-2ms or those that re-render frequently with no visible change.
Will the React Compiler make all this unnecessary?
Partially. The compiler automates useMemo and useCallback insertion. It does not automate state structure decisions, debouncing, throttling, or architectural choices about component boundaries.
ReactUse provides 100+ production-ready hooks for React — TypeScript-first, tree-shakable, SSR-compatible.
Top comments (0)