Nine Common Patterns That Trigger Unnecessary React Re-Renders
Explains nine everyday React state and effect patterns that silently widen re-render scope, and how to restructure components to keep updates localized.
This article is published in English.
Sources: 9 React Habits That Quietly Cause Unnecessary Re-Renders
React tends to run fast on its own. Trouble shows up when a single small update forces a much larger portion of the component tree to re-evaluate itself than the change actually warrants.
For a long time, it seemed reasonable to treat excessive re-rendering as something React itself needed to fix. When a component updated more than it should, the instinct was to wrap it in memo, or reach for useMemo and useCallback. If the interface still felt sluggish, the next layer of components got the same treatment, in the hope that React would eventually settle down.
That kind of patching sometimes made a profiler chart look better, but it rarely made the underlying component structure any clearer. State still sat higher in the tree than it needed to, context values were still bundled together, derived data was still duplicated, references were still unstable, and Effects still kicked off further updates the moment they ran. Memoization was effectively being used to shield the UI from a data flow that was wider than it needed to be.
A re-render on its own is not a bug. React is built to call your component functions again whenever their inputs might have changed, and plenty of components are cheap enough that blocking every harmless render would add more complexity than it saves.
The real question isn't whether a component rendered again. It's why that render was triggered, and what work happened once React invoked it.
Most avoidable re-renders trace back to habits that feel harmless while a component is small. Their cost only shows up later, once the component grows, its children get heavier, or a fast-changing value starts invalidating a large chunk of the page.
1. Keeping Frequently Changing State Too High in the Tree
Lifting state up to a shared ancestor is one of the earliest patterns React developers pick up. When two components rely on the same value, giving ownership to their closest common parent keeps them in sync and avoids duplicated sources of truth.
This becomes a problem when every scrap of local interaction state gets pushed up to the page-level component just because that component happens to have access to everything.
A search box keeps its text value on the dashboard. A dropdown's open/closed flag lives next to the table's data. Hover state is tracked by the card grid. A modal's internal form fields are managed by the screen that triggers the modal. Every keystroke or minor interaction now forces that top-level owner to re-render.
Because the page component returns its children fresh each time, updating that search text can cause charts, tables, navigation, and dialogs to run again, even though none of their actual data changed.
This isn't a case for burying every piece of state as deep as possible either. State belongs at the smallest boundary that still needs to coordinate the relevant components. A filter that the URL, the table, and an export button all depend on probably does belong above those regions. Whether a single tooltip is currently open usually doesn't need to live there.
This distinction matters most with values that update rapidly. Typing, dragging, pointer tracking, animation progress, and live form fields can all generate many updates per second. Put one of those above an expensive subtree, and a purely local interaction turns into work for the whole page.
Moving state closer to the components that actually use it tends to fix performance more directly than wrapping the whole page in memoization, since React has no parent to re-invalidate once that parent no longer owns the state in question.
2. Using One Context for Unrelated Values
Context exists to solve a specific problem: letting descendant components read shared values without forcing every component in between to pass along props it never touches.
Problems start once a single provider turns into the catch-all container for anything considered "global."
It's common to see one application-wide context carrying the current user, the theme, an unread notification count, sidebar visibility, locale, feature flags, permissions, and a handful of action functions. It feels tidy — one provider, one custom Hook to consume it.
But from an update standpoint, these values don't share a single responsibility. Notification counts might update multiple times a minute; the theme might stay fixed for months. Sidebar visibility is local UI state, while the authenticated user reflects the session itself. Bundling them together means every consumer effectively subscribes to changes it may not care about at all.
Often, the provider also rebuilds its context value on every render:
function AppProvider({ children }) {
const [theme, setTheme] = useState("light");
const [notifications, setNotifications] = useState([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const value = {
theme,
setTheme,
notifications,
setNotifications,
sidebarOpen,
setSidebarOpen,
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
Each time the provider re-renders, value becomes a brand-new object. Consumers see this as a change by reference, even if the specific field they read hasn't actually changed.
Memoizing the value passed to the provider can stop re-renders caused by an unrelated parent updating, but it can't split the context value into independent parts. If notifications change, the whole combined object still counts as changed.
A better approach is to split context along lines of responsibility and update frequency, rather than convenience of having one provider file. Configuration that rarely changes can live in one context. Fast-moving live data can have its own. Purely local layout state may not need to go through application-wide context at all.
Context isn't just a transport mechanism for values — it's also a mechanism for distributing updates. How the provider's value is shaped determines how far each change is able to spread.
3. Rebuilding Objects, Arrays, and Functions When Identity Actually Matters
Building a fresh value on every render is ordinary JavaScript behavior. An inline object, a filtered array, or a new arrow function usually costs so little that trying to avoid it wouldn't buy you anything.
Things get tricky once some downstream logic relies on that value's identity rather than just its content.
A memoized child checks its props by reference. An Effect checks its dependency list by reference. A table component might rebuild its internal structures whenever the columns array reference changes. A context provider tells subscribers about an update whenever the value it holds points to a new reference.
Two objects can hold identical data yet still be two distinct objects in memory. If a child receives a brand-new settings object on every parent render, it has no way of knowing that nothing about those settings actually changed.
A common reaction is to wrap every object in useMemo and every function in useCallback by default. That just trades one reflexive habit for another. Memoization isn't free — it has its own overhead, its own dependency array to maintain, and its own upkeep cost. It earns its keep only when a stable reference lets something expensive be skipped or reused.
Values that never change can frequently be defined outside the component altogether. Instead of packaging several props into one object purely for tidiness, a child can often just receive the individual primitive values directly. A function should be stabilized only when it feeds into an Effect, a subscription, or a memoization boundary where identity genuinely governs behavior.
The real test is following what happens because of the new reference. If nothing downstream cares, recreating the value each render is harmless. But if a fresh object triggers a large table to rebuild or causes a subscription to tear down and reconnect, that object's identity has become part of the component's performance contract, and should be treated accordingly.
4. Storing Computed Values and Keeping Them Updated via Effects
One of the least obvious sources of extra rendering is keeping a piece of state around for something that could simply be computed from existing props or state.
Consider a component that holds the full list of records, the current filter text, and then a separately stored filtered list. Or a form that tracks its fields, its validation errors, and also a standalone canSubmit flag. Or a list component that stores both a selected id and a second state value holding the matching selected object.
Because that duplicated value can drift out of sync, developers add an Effect to keep it current:
function Products({ products }) {
const [search, setSearch] = useState("");
const [visibleProducts, setVisibleProducts] = useState(products);
useEffect(() => {
setVisibleProducts(
products.filter((product) =>
product.name.toLowerCase().includes(search.toLowerCase())
)
);
}, [products, search]);
// ...
}
Here's what happens when search changes: the component renders first with the outdated filtered list still in state, React commits that render, then the Effect fires, updates the state, and schedules yet another render.
That second render only exists because a computed result was stored as if it were an independent piece of state.
If visibleProducts is instead computed directly while rendering, it stays automatically in sync and the extra render disappears entirely. When the filtering logic is actually costly, you can wrap the calculation in a memo keyed off its real inputs. What matters is that this kind of memoization only caches a computation — it should never turn the cached result into a second, independently writable source of truth.
This pattern has consequences beyond wasted renders. Derived state can briefly show a value that contradicts its inputs, and stacking multiple Effects together can chain into cascading re-renders. Eliminating the redundant state usually eliminates several synchronization Effects along with it.
5. Subscribing to More of an External Store Than You Actually Need
Selector-driven state libraries can deliver very targeted updates, but only if components actually select narrow, specific values.
A frequent shortcut is subscribing to an entire store, or to one large feature-level object, and then pulling out just a single field for use. Doing this means the component re-renders whenever anything inside that broader object changes — even fields it never touches.
This issue isn't unique to state management libraries. It shows up equally with data-fetching caches, observable-based systems, and hand-rolled subscription hooks. Reading from a large source purely out of convenience makes a component reactive to far more changes than it needs to be.
How much state a component is technically permitted to reach is not the same question as how much of that state should be allowed to trigger its re-render.
6. Unstable Keys That Silently Cause Remounts Instead of Updates
A re-render runs a component again while keeping its identity, its local state, and its position intact. A remount does something different — it tears down the existing component instance and builds a fresh one from scratch. Remounting tends to be far costlier, since React can no longer reuse the prior local state, the existing DOM relationship, or any resources tied to the component's lifecycle.
Keys that aren't stable can quietly turn what should be simple updates into repeated remounts.
If a key is derived from a random value, the current timestamp, or a field that changes while the item is being edited, React interprets that as the old item disappearing entirely. Since every render produces a different identity, the previous child gets removed and a brand-new one is mounted in its place.
Often the visible symptoms show up before anyone notices a performance issue at all. Text inputs lose focus mid-typing, expanded rows snap back closed, animations restart from the beginning, and local component state vanishes. The performance impact becomes serious when the remounted components carry heavier setup work — initializing editors, charts, observers, subscriptions, media players, or network requests.
Using array indexes as keys creates a subtler version of this problem once list items can be reordered, inserted, or removed. React may keep component state tied to a position in the list even though a different underlying item now occupies that position. That mismatch can cause state to attach to the wrong item and force unnecessary reconciliation work, all because the keys don't actually reflect stable item identities.
A well-chosen key should represent an item's durable identity within its list. It should only change when that item is meant to be treated as an entirely different instance — not simply because a render happened to occur.
There are legitimate cases where deliberately changing a key is the right tool, since it forces React to reset a whole subtree. That should be a conscious choice, because it means throwing away everything React had previously preserved for that subtree. An unintentional remount is much more expensive than a typical re-render, and it's easy to miss when the key expression looks innocuous at first glance.
7. Leaving Heavy Interfaces Mounted When They're Out of View
A component doesn't have to be visible to still be doing work.
Tabs, drawers, modals, and collapsible panels are frequently left mounted but visually hidden with CSS, since this approach preserves internal state and lets the interface reopen instantly. For lightweight content, that trade-off is often reasonable.
For larger interfaces, though, staying mounted means they may keep listening to context and external stores, keep receiving new props, keep re-rendering, keep running calculations, and keep their Effects alive — all while the user has no way of seeing the result. A dashboard might keep multiple charts fully active behind tabs nobody is currently looking at. An editor tucked away in a hidden panel might keep processing document updates in the background. A dialog that's been dismissed visually can still be holding onto a large form tree along with its validation logic.
Your app pays a real cost for this, because React still treats that mounted subtree as part of the relevant render paths. Setting display: none only changes how the browser lays things out — it has no effect on whether React still owns and updates that subtree.
That said, unmounting every hidden piece of UI isn't automatically the right call either. Rebuilding a heavyweight editor from scratch every time someone switches tabs can be slower overall, and it can wipe out user state that ought to persist. What matters is making a deliberate choice based on the component's lifecycle and how expensive it actually is.
One option is to delay mounting a heavy panel until it's first activated. State that genuinely needs to survive a visual unmount can live in a smaller, dedicated owner while the rendered tree itself goes away. Some products strike a middle ground by keeping only the most recently used panels mounted and releasing the rest.
The real question isn't whether something is currently on screen. It's whether it should still be receiving and reacting to updates while it's off screen.
8. Letting Effects Restart Due to Unstable Dependencies
An Effect that depends on some value will clean up and re-run whenever that value changes — and that's exactly correct when the external relationship the Effect represents has genuinely changed.
The trouble is that unstable dependencies can make it look like that relationship changed on every single render, even when nothing meaningful did.
Consider a component that builds an options object and hands it to a custom Hook, which then uses that object as an Effect dependency. Because the parent creates a brand-new object on every render, the Effect keeps tearing down its connection and reopening it. Similarly, an event handler recreated on each render and used as a dependency for a listener effect will cause that listener to be removed and reattached over and over. A query-configuration object can trigger a fresh request each time, even though none of its meaningful fields actually changed.
This kind of overhead doesn't always announce itself as a rendering problem. Instead, it shows up as duplicate network requests, connections opening and closing rapidly, UI that flickers, or event listeners that vanish and reappear. Worse, these Effect restarts often call state setters themselves, triggering further renders that obscure the original source of the problem.
The fix is not to strip dependencies out of the array just to quiet the warnings. Dependencies exist to describe exactly what values the Effect reads, and hiding one introduces stale closures and broken synchronization.
The better move is to give the dependency a stable identity when that identity is what actually matters. Depending on the situation, you might construct the object inside the Effect itself from primitive values, stabilize the callback so it isn't rebuilt every render, or realize that the logic doesn't belong in an Effect at all and move it into an event handler instead.
An Effect should restart because the external system genuinely requires new synchronization — not because a render happened to produce another functionally identical object.
9. Replacing State With a New Object That Represents the Same Thing
Depending on how React compares values, it can sometimes skip re-rendering when the next state is equal to the current one. But for objects and arrays, handing React a fresh reference makes the state look different even when its actual contents say the same thing.
This pattern shows up often in event handlers and store logic. A filter gets reset to a brand-new object that's actually equivalent to what was already there. A reducer returns a freshly built state object for an action that didn't change anything. A response from polling gets copied into a new array even though every item inside it is unchanged.
React has no general way to detect deep semantic equality on its own. If whatever owns the state hands back a new object, React treats that as a meaningful enough update to process.
To avoid this, reducers should return the existing state object when an action wouldn't actually change anything. State setters can compare the values that matter before swapping out an entire structured object. Layers responsible for normalizing incoming data can keep the same references for records that haven't changed, rather than rebuilding the whole collection from scratch after every response.
None of this means you should deep-compare every state update. Deep comparison can end up costing more than the render it's trying to prevent, and it introduces yet another notion of "equality" you now have to maintain consistently. A more sustainable approach is to preserve object identity naturally whenever the underlying data hasn't actually changed.
Structural sharing is what lets React, along with any memoized consumers downstream, tell a genuine change apart from a repeated copy of the same information. A new reference should signal a new state — not just a new allocation of memory holding the same values.
The Goal Is Not to Eliminate Re-Renders
React apps don't get fast because they refuse to render. They get fast because renders track meaningful changes and stay confined to the parts of the tree that actually need to reconsider their output.
Re-rendering a small, simple component is frequently cheaper than building and maintaining an elaborate memoization scheme around it. A context update that only touches a handful of lightweight consumers may not justify any architectural rework at all. Decisions about performance should be driven by the real cost of the interaction, by profiling, and by the scale you actually expect — not by a blanket instinct to prevent rendering wherever possible.
The habits described above are risky precisely because they quietly widen the scope of what gets updated. Pushing state too high turns a small, local interaction into work that spans the whole page. Broad context and store subscriptions spread unrelated changes to components that didn't need to know about them. Freshly created references make stable inputs look different from one render to the next. Derived state combined with Effects can trigger cascades of additional updates. Unstable keys throw away reuse in favor of full remounts. Hidden subtrees keep doing work nobody can see, and state that's structurally identical keeps getting treated as brand new.
Memoization can genuinely help once these underlying relationships are properly understood. It works best when your component boundaries, your state ownership, and your reference identities already make sense on their own. Without that foundation, memoization just becomes a defensive layer wrapped around an update graph that's still unnecessarily large.
The most useful question to ask about React performance isn't "how do I keep this component from rendering?"
It's "what actually changed, who owns that change, and why did this component get asked to reconsider itself again?"
Once you have clear answers to those questions, a large share of unnecessary re-renders tend to disappear on their own — without turning your component tree into a tangle of caches and comparison functions.