React is fast by default, so most apps need no special tricks. But once in a while a screen feels sluggish: typing in a box lags, or a large list flickers. This lesson explains why components re-render, and the three tools React gives you to avoid unnecessary work. The most important advice comes first: measure before you optimise.
When does a component render?
A component renders again when:
- its own state changes (a setter is called), or
- its parent renders (all children render again by default), or
- a context it reads changes.
The second point surprises beginners: a child re-renders along with its parent even when its props are exactly the same.
import { useState } from "react";
function Child() {
console.log("Child rendered");
return <p>I am a child with no props.</p>;
}
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Parent count: {count}</button>
<Child />
</div>
);
}
export default App;Parent count: 1
I am a child with no props.
CONSOLE
Child rendered
Child rendered
Child rendered
Child renderedThe log shows Child rendered twice at the start and twice more after one click (StrictMode renders everything twice in development), even though Child takes no props at all. Re-rendering means calling the function again to compute the JSX. React then compares it with the previous result, and updates the real page only where something differs, so the page itself is not rebuilt. For small components this is cheap and nobody notices. It matters when a component is expensive to render (a huge list, heavy calculations) and renders often.
memo: skip a component when props are equal
Wrap a component in memo and React remembers its last result. If the next render gives it props that are shallowly equal (each prop Object.is-equal to the previous), React skips it:
import { memo, useState } from "react";
const Child = memo(function Child({ name }) {
console.log("Child rendered for", name);
return <p>Hello, {name}</p>;
});
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Parent count: {count}</button>
<Child name="Asha" />
</div>
);
}
export default App;Parent count: 1
Hello, Asha
CONSOLE
Child rendered for Asha
Child rendered for AshaNow the click re-renders the parent but not the child: the only log lines are the two from the first render. memo compares name ("Asha" equals "Asha") and skips the work. Remember that a memoised component still re-renders if its own state or a context it uses changes; memo only guards against re-renders caused by the parent.
Why memo seems not to work: new objects and functions
memo compares by identity. Strings and numbers compare by value, but objects, arrays and functions created inside the parent are new on every render, so they never match:
import { memo, useState } from "react";
const Child = memo(function Child({ onSelect }) {
console.log("Child rendered");
return <button onClick={onSelect}>Select</button>;
});
function App() {
const [count, setCount] = useState(0);
function handleSelect() { // a new function on every render of App
console.log("selected");
}
return (
<div>
<button onClick={() => setCount(count + 1)}>Parent count: {count}</button>
<Child onSelect={handleSelect} />
</div>
);
}
export default App;Parent count: 1Select
CONSOLE
Child rendered
Child rendered
Child rendered
Child renderedEven though the child is wrapped in memo, it rendered again after the click (four log lines in total), because handleSelect is a different function each time. This is the situation where useCallback helps.
useCallback: keep the same function
useCallback(fn, dependencies) returns the same function object between renders, until a dependency changes:
import { memo, useCallback, useState } from "react";
const Child = memo(function Child({ onSelect }) {
console.log("Child rendered");
return <button onClick={onSelect}>Select</button>;
});
function App() {
const [count, setCount] = useState(0);
const handleSelect = useCallback(() => {
console.log("selected");
}, []); // no reactive values used, so no dependencies
return (
<div>
<button onClick={() => setCount(count + 1)}>Parent count: {count}</button>
<Child onSelect={handleSelect} />
</div>
);
}
export default App;Parent count: 1Select
CONSOLE
Child rendered
Child renderedNow handleSelect keeps its identity, memo sees equal props, and the child is skipped after the click. useCallback is useful only together with memo on the receiving component (or when the function is a dependency of an effect or another hook). On its own, wrapping every function in useCallback just adds work. The dependency rules are the same as for effects: list every reactive value the function reads.
useMemo: cache a calculated value
useMemo(calculate, dependencies) runs calculate and stores the result. On later renders it returns the stored result unless a dependency changed. Use it for a slow calculation, or to give an object or array a stable identity:
import { useMemo, useState } from "react";
function slowSquareSum(n) {
console.log("calculating for", n);
let total = 0;
for (let i = 1; i <= n * 1000000; i++) total += 1; // pretend this is expensive
return total;
}
function App() {
const [n, setN] = useState(1);
const [other, setOther] = useState(0);
const result = useMemo(() => slowSquareSum(n), [n]);
return (
<div>
<input value={n} onChange={e => setN(Number(e.target.value) || 0)} />
<button className="other" onClick={() => setOther(other + 1)}>Unrelated state: {other}</button>
<p>Result: {result}</p>
</div>
);
}
export default App;Unrelated state: 2
Result: 13000000
CONSOLE
calculating for 1
calculating for 1
calculating for 13
calculating for 13The log shows the calculation ran for the initial value 1 and for 13 (the test typed a 3 after the existing 1), each twice because of StrictMode. Pressing the unrelated button twice did not run it again, because n did not change. Without useMemo, each click would repeat the loop. Notice also that the result is a value, while useCallback caches a function; in fact useCallback(fn, deps) is the same as useMemo(() => fn, deps).
A second use is making an object stable for a child or an effect dependency:
const options = useMemo(() => ({ theme, size }), [theme, size]); // same object until theme or size changesMeasure first
Memoization is not free. Each memo, useMemo and useCallback costs memory and a comparison on every render, and makes code harder to read and easier to get wrong (a missing dependency gives stale values). So do not sprinkle them everywhere. Instead:
- Notice a real problem: typing lags, scrolling stutters, a screen takes a long time.
- Measure with the Profiler tab of the React Developer Tools browser extension. Record an interaction and look at which components render and how long each takes.
- Fix the cause with the cheapest tool, in this order:
- Move state down so that less of the tree re-renders when it changes (the component that owns a fast-changing input should not also own a big list).
- Pass slow children as
children(they are created by a parent above the changing state, so they do not re-render). - Reduce the work: paginate or virtualise long lists, avoid heavy calculations during render.
- Only then reach for
memo,useMemoanduseCallback.
- Measure again to confirm the improvement.
Here is the "move state down" idea. The slow list renders on every keystroke because the input's state lives in the same component:
// Before: typing re-renders SlowList
function App() {
const [text, setText] = useState("");
return (<><input value={text} onChange={e => setText(e.target.value)} /><SlowList /></>);
}
// After: typing only re-renders SearchBox
function SearchBox() {
const [text, setText] = useState("");
return <input value={text} onChange={e => setText(e.target.value)} />;
}
function App() {
return (<><SearchBox /><SlowList /></>);
}The React Compiler
React has a build-time tool called the React Compiler that adds memoization automatically, by analysing your components and applying memo-like caching where it is safe. With it, hand-written useMemo, useCallback and memo are needed much less often, mostly for special cases. It requires components that follow the rules (pure rendering, no mutation of state), which is a good reason to keep to the lessons so far. The Vite template does not enable it by default; see the React documentation to turn it on. Whether or not you use it, the ideas in this lesson explain what it is doing.
Common mistakes
- Wrapping everything in
memo,useMemoanduseCallback"just in case". - Using
useCallbackfor a function passed to a component that is not memoised, which has no effect. - Forgetting a dependency, so a memoised value or function uses stale data.
- Passing a new object, array or inline function to a memoised child and wondering why
memodoes nothing. - Memoising something cheap: the comparison costs about as much as the work.
- Using
useMemofor correctness (it is a performance hint; React may discard cached values). Your code must work without it. - Mutating a memoised object, which hides changes from React.
Exercise
- Add
console.logto a parent and two children, then find out which children re-render when the parent's state changes. Wrap one inmemoand compare. - Make a list of 2,000 items with a search box. Time typing with and without moving the search state into its own component.
- In the
useMemoexample, replace the loop with[...Array(n * 1000)].map(...)and see how large you can makenbefore the page gets sluggish. DoesuseMemostill help whennchanges? - Open the React Developer Tools Profiler on any page of your own project and record one interaction. Which component took longest?
Key points
- By default a component re-renders whenever its parent does, even if its props are the same; this is usually fast enough.
- memo(Component) skips re-rendering when props are shallowly equal to the previous props.
- useMemo caches the result of a calculation and useCallback caches a function between renders, so that they stay the same object until their dependencies change.
- Measure before optimising: most apps need very little memoization, and the React Compiler can add it automatically.