React Hooks Performance: How to Avoid Unnecessary Re-renders
Source: Dev.to
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. A component re-renders when: Its state changes — calling setState re-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 useContext consumer re-renders when the context value updates. Every optimization technique targets one or more of these triggers. 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 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 (
{filtered.map((p) => - {p.name}
)}
); }
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 ; }
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 ; }
// After: stable reference, MemoizedList skips re-renders function SearchPage() { const [query, setQuery] = useState(""); const handleSelect = useCallback((id: string) => { console.log(“Selected:”, id); }, []); return ; }
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([]); const [count, setCount] = useState(0);
// Good: derived value const [items, setItems] = useState([]); const count = items.length;
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 }
ReactUse hooks use three key patterns: Refs for callbacks. Hooks like useThrottleFn store your callback via useLatest. The wrapper calls the latest version through the ref — no stale closures, no need for useCallback.
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. Before — API call on every keystroke: function Search() { const [query, setQuery] = useState(""); const [results, setResults] = useState([]);
useEffect(() => {
if (query) {
fetch(/api/search?q=${query}).then((r) => r.json()).then(setResults);
}
}, [query]);
return (
setQuery(e.target.value)} />
); }
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([]);
useEffect(() => {
if (debouncedQuery) {
fetch(/api/search?q=${debouncedQuery}).then((r) => r.json()).then(setResults);
}
}, [debouncedQuery]);
return (
setQuery(e.target.value)} />
); }
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. Memoizing everything. Adds overhead without measurable benefit for trivial computations. useCallback
without React.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. 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. 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. 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. 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. Get started with ReactUse →