~/priyank$
> Initializing portfolio...
> Loading components... ✓
> Fetching experience... ✓
> Compiling skills... ✓
> Ready.
~/priyank $
~/blog/tech
post.metadata
title: "Why React Re-Renders"
date: 11 Aug 2026
readTime: 22 min read
tags: ["React", "Performance", "Rendering", "JavaScript"]
author: "Priyank Deep Singh"

Why React Re-Renders

A detailed, practical guide to React re-renders: state, props, context, identity, memoization, keys, Strict Mode, and how to debug the renders that make your app sweat.

Priyank Deep Singh
Priyank Deep Singh
22 min read · 11 Aug 2026
Why React Re-Renders

React re-renders because its job is to keep your UI in sync with your data. That sounds peaceful and responsible, like a tiny accountant for pixels. But when your console starts logging render twenty-seven times because you clicked one innocent button, it can feel less like accounting and more like your component tree discovered caffeine.

The good news: re-renders are not mysterious. React is not randomly waking components up at 3 AM to ask if they have thought about their life choices. There are specific triggers, a specific pipeline, and a few common patterns that make re-renders either harmless, useful, or wildly dramatic.

This post is a practical mental model for why React re-renders, what actually happens during a render, when you should care, and how to fix the re-renders that are doing push-ups on your main thread for no good reason.

What We'll Cover
01Render does not mean DOM update02The four big re-render triggers03Why children re-render with parents04State updates, batching, and stale values05Props, identity, and memoization06Context: the polite broadcast system07Keys, remounts, and lost state08Strict Mode, effects, and debugging09The practical optimization checklist

01Render does not mean DOM update

The first trap is the word "render." In React, render usually means "call the component function and calculate what the UI should look like." It does not automatically mean "change the DOM."

Think of render as React asking, "If the app state is this, what should the screen be?" Your component returns JSX, React builds a new description of the UI, then compares it with the previous description. Only if something actually changed does React commit updates to the DOM.

The Re-Render Pipeline
Trigger
State, props, context, or an external store says something changed.
Render
React calls your component functions to compute the next UI snapshot.
Diff
React compares the previous tree with the new tree.
Commit
Only the changed parts are applied to the DOM, then effects run.
Counter.tsxtsx
function Counter() {
console.log('Counter rendered')
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}

Every click schedules a state update. React calls Counter again. That is the render phase. Then React sees the text changed from Count: 0 to Count: 1 and commits that text update to the DOM.

💡 Good to Know
A render is React recalculating UI. A commit is React applying changes. You can have renders that produce no DOM changes, especially when values are equal or memoization bails out.

02The four big re-render triggers

Most React re-renders come from four places. If you can identify which one is happening, the bug stops looking like sorcery and starts looking like normal engineering with slightly more sighing.

1. State changes

When a component's state changes, React re-renders that component. This is the classic trigger. You call setState, React schedules work, and your component function runs again with the new value.

StateTrigger.tsxtsx
function SearchBox() {
const [query, setQuery] = useState('')
// Runs after every query update
console.log('SearchBox rendered')
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
)
}

2. Parent re-renders

When a parent component re-renders, React normally re-renders its children too. This does not mean the DOM changes for every child. It means React calls those child component functions to figure out whether anything changed.

3. Context value changes

Context is a broadcast system. When the provider's value changes, every component that reads that context is eligible to re-render. Context is lovely for shared state. It is also very good at making an entire dashboard blink if you put too much into one provider.

4. External store changes

Libraries like Redux, Zustand, Jotai, TanStack Query, and form libraries can trigger re-renders when their subscribed data changes. Good libraries try to keep this scoped. Bad subscriptions can still turn one tiny update into a full-family reunion.

🎯 Think of it Like This
React re-renders when someone changes the script. State changes are local edits, parent renders are inherited edits, context is a group announcement, and external stores are that one project manager with a megaphone.

03Why children re-render with parents

This is the part that surprises many developers: a child can re-render even when its props did not change. Why? Because React's default behavior is simple and predictable. If a parent renders, React walks into the children and asks them for their latest UI too.

ParentChild.tsxtsx
function Parent() {
const [count, setCount] = useState(0)
console.log('Parent rendered')
return (
<section>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<Child message="I did not change" />
</section>
)
}
function Child({ message }) {
console.log('Child rendered')
return <p>{message}</p>
}

Click the button. The parent state changes, so Parent renders. Because Child is inside Parent, React calls Child again too. The prop is the same string, so the DOM probably does not change, but the function still ran.

Is that bad?

Usually, no. Most component renders are cheap. A function call that returns a few JSX elements is not your villain. The problem starts when a child render performs expensive calculations, creates huge lists, formats large datasets, or causes effects that do more work than they should.

MemoizedChild.tsxtsx
const Child = React.memo(function Child({ message }) {
console.log('Child rendered')
return <p>{message}</p>
})
function Parent() {
const [count, setCount] = useState(0)
return (
<section>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
{/* Child can skip rendering while message is unchanged */}
<Child message="I did not change" />
</section>
)
}
⚠️ Watch Out
Do not wrap everything in React.memo as a reflex. Memoization has its own cost: React has to compare props, and your code becomes harder to reason about. Use it when a component is expensive or re-renders often with the same props.

04State updates, batching, and stale values

State updates are requests, not immediate mutations. Calling setCount does not instantly rewrite the count variable in the currently running function. It tells React, "Please render again with this new value soon." Very polite. Very easy to misunderstand.

StaleState.tsxtsx
function Counter() {
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
return <button onClick={handleClick}>{count}</button>
}

You might expect this to add three. It adds one. Each call sees the same count value from the current render. If count is 0, all three calls are basically saying, "Set it to 1, please." React hears you. It just hears the same thing three times, like a meeting that could have been one sentence.

Use functional updates when the next value depends on the previous value

FunctionalUpdate.tsxtsx
function Counter() {
const [count, setCount] = useState(0)
function handleClick() {
setCount((current) => current + 1)
setCount((current) => current + 1)
setCount((current) => current + 1)
}
return <button onClick={handleClick}>{count}</button>
}

Now React queues three updates, each based on the latest queued value. The count goes up by three. Everyone claps. Somewhere, a stale closure quietly leaves the room.

React batches updates

React groups multiple state updates into a single render when it can. In modern React, batching works across event handlers, promises, timeouts, and many async paths. This is why several setState calls often produce one render instead of one render per call.

BatchedUpdates.tsxtsx
function CheckoutButton() {
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState('idle')
async function submit() {
setLoading(true)
setStatus('saving')
await saveOrder()
// These are batched too in React 18+
setLoading(false)
setStatus('done')
}
return <button onClick={submit}>{status}</button>
}
Pro Tip
Batching is React trying to avoid unnecessary work. It is one of the reasons you can write straightforward state code without manually scheduling every UI update like an air traffic controller.

05Props, identity, and memoization

React compares many things by identity. For primitives like strings and numbers, this is simple. 42 is 42. Beautiful. No notes.

For objects, arrays, and functions, identity means reference. A new object literal creates a new reference. A new inline function creates a new reference. Even if the contents look identical, React sees a different thing.

ObjectIdentity.tsxtsx
const ProductCard = React.memo(function ProductCard({ product, onBuy }) {
console.log('ProductCard rendered')
return <button onClick={onBuy}>Buy {product.name}</button>
})
function ProductPage() {
const [quantity, setQuantity] = useState(1)
return (
<>
<input
value={quantity}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
<ProductCard
product={{ id: 1, name: 'Keyboard' }}
onBuy={() => console.log('buy')}
/>
</>
)
}

ProductCard is memoized, but it still re-renders when quantity changes. Why? Because product and onBuy are recreated on every parent render. New references, new props, memoization defeated. The optimization walked into a glass door.

Stabilize values only when it matters

StableProps.tsxtsx
const product = { id: 1, name: 'Keyboard' }
function ProductPage() {
const [quantity, setQuantity] = useState(1)
const handleBuy = useCallback(() => {
console.log('buy')
}, [])
return (
<>
<input
value={quantity}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
<ProductCard product={product} onBuy={handleBuy} />
</>
)
}

Now product and handleBuy keep the same identity across renders, so React.memo can actually skip the child render when only quantity changes.

useMemo is for expensive values, not emotional support

useMemo is useful when calculating a value is expensive or when a stable reference unlocks memoization in a child. It is not a magic "make fast" sticker. If the calculation is cheap, memoization may cost more than recalculating it.

ExpensiveList.tsxtsx
function ProductTable({ products, filter }) {
const visibleProducts = useMemo(() => {
return products
.filter((product) => product.name.includes(filter))
.sort((a, b) => b.revenue - a.revenue)
}, [products, filter])
return (
<ul>
{visibleProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)
}
💡 Good to Know
Memoization works best when three things are true: the component renders often, the work is expensive, and the inputs are stable enough to reuse previous results.

06Context: the polite broadcast system

Context is wonderful until it is one giant object containing the current user, theme, locale, cart, sidebar state, notification count, and the emotional weather of the engineering team.

When the value passed to a context provider changes, every consumer of that context can re-render. If you put frequently changing data and rarely changing data in the same context, you force components to care about updates they never asked for.

MegaContext.tsxtsx
const AppContext = createContext(null)
function AppProvider({ children }) {
const [theme, setTheme] = useState('dark')
const [cartCount, setCartCount] = useState(0)
return (
<AppContext.Provider value={{ theme, setTheme, cartCount, setCartCount }}>
{children}
</AppContext.Provider>
)
}
function ThemeLabel() {
const { theme } = useContext(AppContext)
console.log('ThemeLabel rendered')
return <span>{theme}</span>
}

If cartCount changes, the provider value object changes. ThemeLabel reads the context, so it can re-render even though it only cares about theme. This is how a shopping cart button accidentally bothers the theme label. Very rude, but technically legal.

Split context by update frequency

SplitContext.tsxtsx
const ThemeContext = createContext(null)
const CartContext = createContext(null)
function AppProvider({ children }) {
const [theme, setTheme] = useState('dark')
const [cartCount, setCartCount] = useState(0)
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<CartContext.Provider value={{ cartCount, setCartCount }}>
{children}
</CartContext.Provider>
</ThemeContext.Provider>
)
}

Now theme consumers re-render when theme changes, and cart consumers re-render when cart changes. The system is quieter. Components receive fewer unnecessary announcements. Everyone can finally hear themselves think.

Memoize provider values when needed

MemoizedProvider.tsxtsx
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark')
const value = useMemo(() => {
return { theme, setTheme }
}, [theme])
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
⚠️ Watch Out
Memoizing a provider value helps with reference stability, but it does not stop consumers from re-rendering when the actual context value they read changes. Split context first. Memoize second.

07Keys, remounts, and lost state

A re-render is not the same as a remount. During a re-render, React calls your component again but keeps its state. During a remount, React destroys the old component instance and creates a new one. State is reset. Effects clean up and run again. Somewhere, an input field loses focus and the user questions your professionalism.

Keys are how React identifies items in a list. Stable keys help React preserve component identity. Unstable keys make React throw away work.

BadKeys.tsxtsx
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
</ul>
)
}

Index keys are fine only for static lists that never reorder, insert, delete, or filter. For real lists, use a stable ID from the data. Otherwise React can confuse one item for another and preserve state in the wrong place. That is how a checkbox checked on one row appears checked on another row, which is the UI equivalent of putting salt in tea.

GoodKeys.tsxtsx
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)
}

Changing a key forces a remount

Sometimes this is useful. If you want to reset a form when the selected user changes, using a key can be a clean, intentional reset.

ResetOnKey.tsxtsx
function UserSettings({ userId }) {
return <SettingsForm key={userId} userId={userId} />
}

When userId changes, React treats SettingsForm as a new component instance. Local form state resets. This is powerful, but use it deliberately. Random keys like Math.random() are not keys. They are tiny chaos machines.

08Strict Mode, effects, and debugging

Strict Mode double renders in development

In development, React Strict Mode intentionally calls some functions twice. This helps reveal impure rendering logic and effect cleanup bugs. It does not happen the same way in production. So if your console logs appear twice locally, React may not be broken. It may be holding a flashlight under the bed to find suspicious side effects.

StrictModeExample.tsxtsx
function Profile({ user }) {
// This should be safe to run more than once.
// Do not fetch, subscribe, or mutate global state here.
console.log('rendering profile')
return <h2>{user.name}</h2>
}

Component render functions should be pure. Given the same props, state, and context, they should return the same JSX without changing the outside world. Fetching data, subscribing to events, writing localStorage, tracking analytics, and manually touching the DOM belong in effects or event handlers, not directly in render.

Effects run after commits

useEffect does not run during render. It runs after React commits changes to the screen. If an effect updates state, it can cause another render.

EffectLoop.tsxtsx
function BadEffect({ items }) {
const [count, setCount] = useState(0)
useEffect(() => {
setCount(items.length)
}, [items])
return <p>{count}</p>
}

This is not always wrong, but it is often unnecessary. If count can be derived from items, derive it during render instead of storing it as separate state.

DerivedValue.tsxtsx
function GoodDerivedValue({ items }) {
const count = items.length
return <p>{count}</p>
}
Pro Tip
If state can be calculated from props or other state during render, you probably do not need another state variable. Duplicated state is where bugs go to open a coworking space.

How to debug re-renders

The best debugging tool is React DevTools Profiler. It shows what rendered, how long it took, and why. Console logs are fine for quick checks, but the Profiler gives you the full crime scene without needing to sprinkle console.log everywhere like seasoning.

WhyDidThisRender.tsxtsx
function useRenderLog(name, props) {
const previous = useRef(props)
useEffect(() => {
const changedProps = Object.entries(props).filter(([key, value]) => {
return previous.current[key] !== value
})
if (changedProps.length > 0) {
console.log(name, 'changed props:', changedProps)
}
previous.current = props
})
}

A helper like this can reveal that a supposedly stable prop is actually a new array, object, or callback every render. But use it temporarily. Debug helpers should not become permanent furniture unless they earn their rent.

09The practical optimization checklist

Here is the part I actually use while working on production React apps. Not every re-render matters. The goal is not "zero renders." The goal is "renders that are predictable, scoped, and cheap."

re-render-checklist.md
  1. First, measure. Use React DevTools Profiler before optimizing. Guessing is fun until you spend two hours memoizing the wrong button.
  2. Keep render pure. Do calculations and return JSX. Put side effects in effects or event handlers.
  3. Move state down. If only one small component needs state, do not store it at the page level and invite every sibling to the render party.
  4. Avoid duplicated derived state. If you can calculate it from existing props/state, calculate it.
  5. Split context by concern. Separate slow-changing values from fast-changing values.
  6. Stabilize expensive props. Use useMemo and useCallback when stable identity actually helps.
  7. Memoize expensive children. Use React.memo for components that render often with unchanged props.
  8. Use stable keys. IDs beat indexes for dynamic lists. Random keys are forbidden unless your goal is sadness.
  9. Virtualize huge lists. If you render thousands of rows, memoization is not enough. Do not render what the user cannot see.

10The mental model to keep

React re-renders when inputs to the UI change. Those inputs are state, props from a rendering parent, context, and subscribed external stores. Rendering is React recalculating the UI. Committing is React applying actual changes. Re-rendering is normal. Remounting is different. Memoization is a tool, not a personality.

If your app feels slow, do not start by fighting every render. Start by finding the expensive ones. Then reduce the amount of state that changes, move state closer to where it is needed, split broad context, stabilize props for memoized children, and virtualize large lists.

The best React code is not code that never re-renders. It is code where re-renders are boring. Predictable. Cheap. The UI updates, the user smiles, and your components do not behave like they found an espresso machine in the server room.

💡 Good to Know
Short version: re-renders are React doing its job. Performance problems happen when too much of the tree re-renders too often, or when render work is too expensive. Measure first, then optimize the part that is actually hot.
Keep Reading
all posts
What Render and Re-Render Actually Mean in React
18 min read · React / Rendering
Code Splitting & Lazy Loading in React
18 min read · React / Performance
Next.js Rendering & Bundle Simulator
16 min read · Next.js / Performance
share.sh
$ echo "Share this article"
Priyank Deep Singh
Priyank Deep Singh

Senior web engineer who loves building fast, accessible, and beautiful web experiences. Writing about React, Next.js, and everything in between.