Scenario Debugging
Unnecessary Re-renders: Finding and Fixing Them
The React Profiler, context updates that re-render every consumer, props that change each time, using memo on purpose and not everywhere, and what the React Compiler changes.
On this page
In one line: a component (a function that returns the piece of screen React should show) re-renders (React calls that function again to work out what the screen should show) when its own state changes, its parent renders, or a context it reads changes. A re-render is normally cheap, so fix it only when the Profiler shows that it costs real time. Prefer structural fixes (move state down, split components) over adding memo, useMemo and useCallback everywhere.
A re-render is not a "bug". It is how React works. State is data that a component remembers between renders. Props are the inputs a parent passes to a child component. A parent is the component that uses another component, which is its child. The skill is to know when it matters, which re-renders are wasted, and the cheapest correct fix.
What causes a component to re-render?
- Its own state changes (
useStateanduseReducerare React hooks that give a component a piece of state; a hook is a React function, whose name starts withuse, that a component calls to use a React feature). - Its parent re-renders. By default, all children re-render too, even if their props did not change.
- A context it reads changes value. (Context is a React feature that shares a value with many components below it without passing it through props one level at a time.)
- A store subscription it uses changes. A store is an object outside React that holds shared state (Redux and Zustand are libraries for this). A component subscribes to the part of the store it selected, and re-renders when that part changes.
- A hook it uses triggers an update (a data-fetching library, a media-query hook).
This is not a cause: props that "look the same". Without memo, React re-renders the child anyway. memo is a React function that wraps a component and makes it skip rendering when its props are the same as last time. It compares the props in a shallow way. Shallow means it only checks if each prop is the same object in memory, not if the contents are equal. An object's identity is which exact object in memory it is. So a new object, array or function on every render has a new identity, and this breaks memo.
Render is not the same as DOM update. A re-render calls your component function and compares the result with the old one (this is called reconciliation). The DOM is the browser's tree of page elements. React only touches the DOM for what really changed. So a "wasted" render costs CPU time for the function call and the comparison. It does not always cost a repaint. It matters when components are expensive, many, or render often.
In development, Strict Mode (a React wrapper component, <StrictMode>, that adds extra checks) renders components twice and runs effects an extra time, on purpose. An effect is code that runs after render, in useEffect. The double run helps to find impure code (code that changes things outside itself while rendering). Do not mistake this for a production problem.
Measure first: React DevTools Profiler
- Open React DevTools → Profiler. React DevTools is a browser extension for inspecting React components, and the Profiler is its tab that records how long each component takes to render. Click Record, do the slow action, then stop.
- In the flame chart (a chart where each bar is a component and its width is its render time), grey components did not render in that commit. A commit is one batch of changes that React applies to the screen. Coloured ones did. The colour and width show the render time.
- Turn on "Record why each component rendered" in the settings. You then see the reason (props changed, state changed, parent rendered, hooks changed).
- Turn on "Highlight updates when components render" in the general settings. This gives a quick picture of what flashes on each action.
- Look for these things:
- Components that render on every keystroke and cost a lot.
- Many commits for one action.
- Components that render with unchanged props.
Slow commits during input make INP (Interaction to Next Paint) worse. INP is the Core Web Vital that measures how fast the page reacts to clicks, taps and key presses. The Profiler works best on a production build with profiling enabled. A development build is slower, and Strict Mode doubles the renders, so its times are too high.
Decide by numbers. If a whole commit takes 3 ms, leave it alone. If typing causes a 120 ms commit, fix it.
Fixes, from cheapest to most invasive
1. Colocate state (move it down)
"Colocate" means to keep things close together. Colocating state means keeping it in the smallest component that needs it. This is the most effective fix, and it adds no hooks.
// ❌ query lives in the page, so every keystroke re-renders the whole pagefunction Dashboard() { const [query, setQuery] = useState(""); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} /> <Results query={query} /> <HeavyCharts /> {/* re-renders on every keystroke for no reason */} <ActivityFeed /> </> );}// ✅ the state (and the re-renders) live in a small componentfunction Dashboard() { return ( <> <SearchBox /> {/* owns query; Results lives inside it or receives it by context/URL */} <HeavyCharts /> {/* no longer affected */} <ActivityFeed /> </> );}2. Lift content up / pass children
children is a special prop that holds the elements written between a component's opening and closing tags. Sometimes a wrapper has state and re-renders. But its children come from a parent that did not re-render. React then reuses those same element objects and skips them:
function Layout({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(false); // changing this re-renders Layout... return <div className={open ? "open" : ""}>{children}</div>; // ...but `children` is the same element object}// <Layout><ExpensiveTree /></Layout> — ExpensiveTree is created by the parent, so it is skipped3. Split components along update boundaries
Separate the part that changes fast (an input, a timer, a hover state) from the part that is slow and expensive. Then each part re-renders on its own.
4. memo with stable props
memo wraps a component and says: "Skip rendering if the props are the same as last time." It does not remember the result of a calculation, only whether the props changed. A simple comparison: a teacher does not mark the same homework twice. If nothing changed, the teacher skips it.
memo only pays off when three things are true. The component is expensive. It renders often. Its props are stable (the same between renders).
const Row = memo(function Row({ item, onSelect }: { item: Item; onSelect: (id: string) => void }) { return <li onClick={() => onSelect(item.id)}>{item.name}</li>;});function List({ items }: { items: Item[] }) { const [selected, setSelected] = useState<string | null>(null); // Stable identity: without useCallback, a new function each render breaks memo(Row) const onSelect = useCallback((id: string) => setSelected(id), []); return <ul>{items.map((it) => <Row key={it.id} item={it} onSelect={onSelect} />)}</ul>;}When memo doesn't help:
- A prop is a new object, array or function on every render. Examples are
style={{...}},options={[…]}andonClick={() => …}. If you did not make it stable,memoalways sees a "new" prop. - The component is cheap. Comparing the props costs about as much as rendering.
- It receives
children. JSX elements are new objects on each render, unless the parent is stable. - The re-render comes from context or its own state.
memodoes not block these.
5. useMemo and useCallback, used deliberately
Both hooks help React remember something between renders. They have different jobs. The difference in one line: useMemo remembers a value, and useCallback remembers a function.
Memoization means remembering the result of work you already did, so you do not repeat it. useMemo is a React hook that memoizes a value: it remembers the value that your function returns. It calculates the value once and gives you the same result again, until its inputs change. Everyday comparison: you write the answer to a hard sum on a sticky note. Next time, you read the note instead of doing the sum again. You write a new note only if the numbers change.
const total = useMemo(() => items.reduce((sum, i) => sum + i.price, 0), [items]);// the sum is only calculated again when `items` changesuseMemo(fn, deps) takes a function that calculates the value and a list of dependencies (the inputs that the calculation uses). It runs the function again only when one of them changes. Use useMemo in two cases:
- A calculation is really expensive.
- You need an object or array to keep the same identity, for a memoised child (a child wrapped in
memo) or for an effect dependency (a value in the list that tellsuseEffectwhen to run again).
useCallback is a React hook that memoizes a function: it remembers the function itself (not the result of calling it). useCallback(fn, deps) takes the function and a list of dependencies. In short, useMemo remembers a value, and useCallback remembers a function. Normally, a new function is created every time a component renders. useCallback gives you the same function again until its inputs change. Everyday comparison: instead of writing a new copy of a phone number each day, you keep one card in your wallet. It is useful when you pass a callback to a child wrapped in memo, or when the function is a hook dependency.
const onSelect = useCallback((id: string) => setSelected(id), []);// the same function is reused, so memo(Row) can skip renderingBoth hooks have a cost: memory, comparing the dependencies, and extra code. If you use them everywhere, the code is harder to read and they rarely help.
6. Fix context re-renders
A Provider is the component that puts a value into a context for everything below it. Every component that reads the context re-renders when the context value changes identity. If one "app state" context changes often, the whole tree under it re-renders.
// ❌ One big, frequently-changing value; new object every render<AppContext.Provider value={{ user, theme, cart, setCart }}>...</AppContext.Provider>// ✅ Split by update frequency, and memoise the valueconst value = useMemo(() => ({ cart, setCart }), [cart]);<UserContext.Provider value={user}> <CartContext.Provider value={value}>...</CartContext.Provider></UserContext.Provider>Even better: for state that changes often (cart, form, live data), use an external store with selectors (Zustand, Redux Toolkit and Jotai are state libraries; useSyncExternalStore is the React hook that lets a component read from an external store). A selector is a small function that picks one piece of the store. A component then subscribes only to the piece it uses. It re-renders only when that piece changes:
const itemCount = useCartStore((s) => s.items.length); // re-renders only when the length changesContext is excellent for values that rarely change (theme, locale, signed-in user). It is a poor fit for updates that happen very often. See Frontend Depth, Lesson 03.
7. Reduce what each render does
- Do not calculate heavy derived data (data worked out from other data) in render without memoisation. Prepare it in advance.
- Avoid creating large objects, or running
sort/filterover huge arrays, on each render. - Virtualise long lists. Virtualisation keeps only the rows visible on screen in the DOM, so only they render. See Lesson 07.
- Use stable, unique
keys. Akeyis a special prop that tells React which list item is which between renders. Unstable keys (Math.random(), or the array index on lists that can change order) cause remounts and lost state. A remount means React destroys the component and builds a new one.
8. Defer non-urgent updates
useDeferredValue (a hook that gives you a copy of a value that may update a little later) and startTransition (a function that marks a state update as non-urgent) keep the input fast while the expensive children catch up. See Lesson 01. For fast events, debounce the state that starts heavy work. Debounce means you wait until the user pauses.
9. The React Compiler
The React Compiler is a tool that runs when you build the app (a build step turns your source code into the files the browser runs). It reached stable version 1.0 in October 2025, and you turn it on with a build plugin (in Next.js, with the reactCompiler option in next.config). It adds memoisation to components and values for you. So where it is on, much of the manual memo/useMemo/useCallback work is not needed.
The compiler has limits:
- It does not replace good structure (colocated state, small components, split contexts).
- It cannot fix expensive work that really has to happen.
Check which version and setup your project uses. Do not add manual memoisation "just in case".
Scenario: typing in a search box re-renders the whole page. Fix it.
- Profile it. The Profiler shows the page, a chart, a table and a sidebar re-rendering on each keystroke. The table renders 500 rows (45 ms per commit).
- Move the state down. Put the
querystate insideSearchBox. Let the results get it through a small store, a URL search param, or a callback that updates only the results component. - Defer the expensive part. Use
useDeferredValue(query)for the results list. Or debounce at 200 ms for server requests (debounce means waiting until the user stops typing for 200 ms before sending). - Memoise the table rows (
memowith stablekeys and callbacks) and virtualise the list. - Memoise the filter calculation (
useMemoon[rows, deferredQuery]). Prepare the lowercase search text in advance. - Profile again. The commit on typing now touches only the input and the visible results.
Debugging "why did this render?" in code
// Quick, temporary helper (useRef holds a value that survives renders without causing a re-render)function useWhyDidYouRender(name: string, props: Record<string, unknown>) { const prev = useRef(props); useEffect(() => { const changed = Object.entries(props).filter(([k, v]) => prev.current[k] !== v).map(([k]) => k); if (changed.length) console.log(`[${name}] re-rendered because:`, changed); prev.current = props; });}Remove it afterwards. The "why did this render" feature in the React Profiler is usually enough.
Common mistakes
- Optimising without profiling.
memoon components that get a new object, array or function prop on every render.useMemo/useCallbackon everything. It adds noise and gives no gain.- One giant context that is updated often and re-renders the whole tree.
- State too high in the tree. Fix it by colocation.
- Index keys on dynamic lists. State can jump between rows. See Frontend Depth, Lesson 00.
- Panicking about the double render of Strict Mode in development.
- Storing derived data in state (copying props into state) and syncing it with effects. This causes extra renders. Calculate it during render instead.
Interview questions
What causes a component to re-render?
Its own state changes, or its parent re-renders (children re-render by default, even with identical props). A context value it reads changes, a store slice it subscribes to changes, or a hook inside it triggers an update. Props that "look equal" do not matter, unless the component is wrapped in memo and the props are referentially stable. Referentially stable means each prop is the same object in memory as before (the same identity).
When does memo not help?
It does not help when the props are new on every render (inline objects, arrays, functions, JSX children). It does not help when the component is cheap. It also does not help when the re-render comes from context or internal state, because memo does not block those. To make it pay off, you also need stable references. You get them with useMemo/useCallback, or by restructuring the code.
Scenario: typing in a search box re-renders the whole page. Fix it.
First I profile. Then I colocate the input state in a small component, so only that component re-renders on each keystroke. I defer or debounce the expensive results update (useDeferredValue or debounce). I memoise heavy children with stable props, and I memoise or precompute the filtering. I virtualise large lists. At the end I profile again to check that the commit is small.
Context vs an external store for frequently changing state?
Context re-renders every component that reads it whenever the value changes. This is fine for values that rarely change, like theme or locale. It wastes work for state that changes very often. An external store with selectors (Zustand, Redux Toolkit, useSyncExternalStore) lets each component subscribe to only the piece it needs. It re-renders only when that piece changes.
What is the "children as props" (lift content up) pattern and why does it avoid renders?
A wrapper has state, and it receives children that were created by a parent that does not re-render. Those element objects keep the same identity. So when the wrapper's state changes, React skips re-rendering the children. This keeps the expensive subtree away from the wrapper's updates, without memo.
How do you decide whether a re-render is a problem?
I measure with the React Profiler. I look at the render time, how often it happens in one action, and whether it slows the interaction (INP). Frequent, expensive commits during input or animation are worth fixing. Short renders of small components are not.
Where does the React Compiler fit?
Where it is turned on, it adds memoisation for you when you build the app. So you need less manual memo, useMemo and useCallback. The component structure still matters. Expensive work must still be reduced or deferred. The compiler does not make a heavy calculation or a giant list cheap.
Key takeaways
- Re-renders come from state, parent, context and store. They are cheap, until components are large, many or frequent.
- Profile first (React DevTools Profiler, "why did this render", highlight updates).
- Prefer structure: colocate state, pass children, split components. After that, use
memowith stable props, split contexts, or use selectors. - Use
useMemo/useCallbackon purpose, virtualise long lists, and defer updates that are not urgent. - Don't copy props into state. Work out derived data during render. Keep keys stable.
What's next
Lesson 04: Memory Leaks in Single-Page Apps. Some slowness only appears after the tab has been open for an hour. The cause is listeners, timers and caches that are never released.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Performance: Memoization, Lists, Code Splitting and the React Compiler · React in Depth
- Pagination, Filtering and Sorting · API Design
- Node.js Performance Basics: Event Loop Blocking, Streams, Workers · Backend in Depth
Part of the study guide
Related series