"The app feels slow."
Every React developer hears this sooner or later, and the first instinct is to blame React. I did too, until I started profiling the slow apps. Again and again, I found the same thing: parts of the screen were updating far more often than they needed to, and the cause was almost always how the app's data was set up.
Here's what those debugging sessions taught me, from an overloaded Context to a React.memo that wasn't doing anything, and where React Compiler fits in.
Why One React Context Causes Unnecessary Re-renders
React Context, the built-in way to share data across components without passing it down by hand, is one of the most useful features in React. Interestingly, it's also one of the easiest to misuse.
Most React applications begin with something simple:
- Authentication
- Theme
- User preferences
Then another requirement appears, and another. Before long, the same Context is storing:
- User information
- Product filters
- Shopping cart
- Notifications
- Feature flags
- Permissions
- UI preferences
It feels organized because everything is in one place. The catch is that whenever a Context value changes, every component reading that Context re-renders, whether or not it uses the part that changed.
A shared Context like this one looks convenient at first:
<AppContext.Provider
value={{
user,
cart,
filters,
notifications,
theme,
permissions,
}}
>
<App />
</AppContext.Provider>I ran into this while building a marketplace app. Early on, putting filters, user settings, and product information into one shared Context sped up development. As the app grew, changing a single filter made product cards, side panels, and other parts of the UI re-render, even though most of them didn't use that filter.
After debugging, the fix wasn't more memoization. We split the shared state into smaller, domain-specific Contexts:
<UserProvider>
<CartProvider>
<FilterProvider>
<App />
</FilterProvider>
</CartProvider>
</UserProvider>Splitting the Contexts narrowed the scope of updates, but it wasn't enough on its own. Each Provider also had to keep its value stable, so that a render of the Provider didn't hand out a brand-new Context value when nothing had actually changed:
const value = useMemo(
() => ({ filters, setFilters }),
[filters]
);
return (
<FilterContext.Provider value={value}>
{children}
</FilterContext.Provider>
);With state split into smaller domains and each Provider's value kept stable, updates reached only the components that actually depended on them.
How to Find Unnecessary Re-renders With React DevTools Profiler
Performance work gets much simpler once you stop guessing. Whenever a component renders, one question matters: why did it render?
The Profiler in React Developer Tools answers that, which is why it quickly became one of my favorite debugging tools. When profiling, I turn on two settings: "Record why each component rendered while profiling" and "Highlight updates when components render." Together they show which components are rendering and what triggered each render.
During one profiling session, the result surprised me. I assumed the large tables were responsible for most of the rendering cost. The biggest offender turned out to be a simple parent component causing dozens of child renders, because a frequently changing state value lived too high in the tree.
Moving that state into a smaller subtree removed a surprising number of unnecessary renders without touching the business logic. In one session, a change of this kind reduced component renders during a typical interaction from 87 to 24.
Measuring first matters beyond re-renders too. I've seen code splitting reduce bundle size on paper without improving the experience. In one case, splitting too aggressively added extra loading steps, and we had to rethink which parts of the application belonged in the critical path. Bundle size and network requests are separate layers of the problem, and our guide to React performance in production covers how to decide which layer to fix first.
React State Management: Keep State Close to Where It's Used
The lesson that changed my thinking the most is simple: state should live as close as possible to the components that actually need it.
Earlier in my career, I often lifted state higher than necessary because it seemed cleaner. Over time, I realized that global state often became global re-renders.
The opposite problem shows up too. With excessive prop drilling, state gets passed through several components that don't actually need it, just to reach one deeply nested child. Simplifying those component boundaries made the data flow easier to follow and cut down unnecessary updates.
One change paid off again and again: moving local UI state back into the components that control it. Modal visibility, expanded rows, selected tabs, and temporary form values rarely need to sit higher up. Moving them down meant fewer renders and code that was easier to understand, because each component owned its own job.
This helps the team as much as the user. I've worked on projects where changing one piece of shared state meant checking several unrelated components, because nobody could tell what would re-render. Once the state was separated into clear domains, debugging got easier and new features felt safer to ship. Making that call early is a big part of a scalable frontend architecture.
Why React.memo Doesn't Work Without Stable Props
React.memo is one of the first optimizations many developers reach for. It's an excellent tool, and also one of the most misunderstood.
On one project, we profiled a product listing page where every product card was wrapped in React.memo(). On paper, everything looked optimized. In practice, every card still re-rendered whenever the parent updated.
The problem was in our own code. Each render created new functions, objects, and arrays and passed them down as props. React.memo only skips a render when the props are the same as last time, so from React's point of view the props had changed, and memoization couldn't help.
<ProductCard
product={product}
onSelect={() => handleSelect(product)}
/>The onSelect function above is recreated every time the parent renders, so its reference changes and React.memo can't skip the render.
Our fix was to give the memoized component a stable callback:
const handleProductSelect = useCallback(
(product) => handleSelect(product),
[handleSelect]
);
<ProductCard
product={product}
onSelect={handleProductSelect}
/>Now ProductCard receives the same function between renders, so React.memo can do its job. This only holds if handleSelect is itself stable; if it's recreated on every render, handleProductSelect will be too.
Objects and arrays cause the same problem, and the patterns are easy to miss:
- Creating new arrays during render
- Creating new objects inline
- Passing anonymous callback functions to deeply nested components
<ProductList
filters={{ category, price }}
items={[...products]}
onSelect={() => handleSelect(product)}
/>All three props get new references on every parent render, so a memoized child sees them as changed even when the values are the same. The fix is to keep those references stable:
const filters = useMemo(
() => ({ category, price }),
[category, price]
);
<ProductList
filters={filters}
items={products}
onSelect={handleProductSelect}
/>The object is memoized, the callback reuses handleProductSelect from the card example, and the array copy is dropped, since products keeps its reference until the data changes.
Because the app keeps working correctly either way, these patterns are easy to ignore until it grows. You don't need to stabilize everything, only the references that are actually causing renders. In another case, stabilizing a handful of frequently passed props reduced component updates from 64 to 21 and cut the time React took to apply the update from 42 ms to 17 ms.
When to Use useMemo and useCallback
useMemo() and useCallback() are quite helpful, and they're also among the most overused hooks in React.
I've reviewed many codebases where almost every callback and derived value was memoized. Those apps weren't noticeably faster. They were just harder to read.
Memoization pays off once you've found a real bottleneck. Adding it everywhere "just in case" adds complexity for little measurable benefit, so I now treat it as a targeted fix rather than a default coding style.
Does React Compiler Fix Unnecessary Re-renders?
React Compiler changes how much of this you have to do by hand.
Keeping track of which components need React.memo, and which values need useMemo or useCallback, adds a lot of complexity to a codebase. React Compiler analyzes components and hooks at build time and applies memoization automatically where it can.
Code that relied on manual memoization:
const ProductCard = React.memo(({ product, onSelect }) => {
const formattedPrice = useMemo(
() => formatPrice(product.price),
[product.price]
);
const handleSelect = useCallback(() => {
onSelect(product);
}, [onSelect, product]);
return (
<div onClick={handleSelect}>
{formattedPrice}
</div>
);
});can often be written more simply, with the compiler handling the memoization:
function ProductCard({ product, onSelect }) {
const formattedPrice = formatPrice(product.price);
const handleSelect = () => {
onSelect(product);
};
return (
<div onClick={handleSelect}>
{formattedPrice}
</div>
);
}What the compiler won't do is fix your state architecture. It can cut down unnecessary re-renders, but it doesn't change where state lives, how state is modeled, or whether an update should have happened in the first place. Those are still decisions you make yourself.
Audit Your React App for Unnecessary Re-renders
Understanding unnecessary re-renders is one thing. Finding where they happen in a real codebase is another.
To make that process easier, I've created the React Performance Auditor, a free downloadable Agent Skill that helps analyze React and React Native codebases for potential performance bottlenecks.
It looks for patterns such as:
- State updates that trigger unnecessary component renders
- Context providers that cause broad re-render cascades
- Unstable props and callbacks
- Expensive list rendering and component updates
- Potentially redundant data-fetching and caching patterns
Rather than blindly applying optimizations, the skill provides evidence, explains its reasoning, identifies potential trade-offs, and suggests ways to validate each finding.
A note on accuracy: The audit is based on code inspection unless runtime profiling is explicitly performed. Its findings are hypotheses to investigate, not proof that your application is slow. Review recommendations and approve changes before applying them.
Download the React Performance Auditor
Final Thoughts
When someone says "React feels slow," the conversation usually starts with React, but it rarely ends there. In every project I've worked on, the real answer was somewhere in where state lived, how often it changed, and how far each update traveled through the component tree.
So before adding another hook, open the Profiler and find out what is re-rendering and whether it needs to. Most of the time the fix is moving or splitting state, and the app gets easier to work on along the way. If your team is dealing with this in a large codebase, our React development team works on exactly these problems.
If you've tracked down a stubborn re-render problem of your own, I'd love to hear how. Connect with me on LinkedIn.
FAQ
Does React Context cause re-renders? Yes. When a Context value changes, every component that reads that Context re-renders, even if it only uses a part of the value that didn't change. Splitting one large Context into smaller, domain-specific Contexts, and memoizing each Provider's value, limits updates to the components that depend on them.
Why isn't React.memo preventing re-renders? React.memo skips a render only when a component's props are the same as last time. If the parent passes a new function, object, or array on every render, the props look different and React.memo can't help. Stabilizing those props with useCallback or useMemo, or passing existing references directly, fixes it.
How do I see why a React component re-rendered? Open the Profiler in React Developer Tools and turn on "Record why each component rendered while profiling." After recording an interaction, selecting a component shows what triggered its render, such as changed props, changed state, or a parent render.
Do I still need useMemo and useCallback with React Compiler? Much less often. React Compiler applies memoization automatically at build time, so many manual useMemo, useCallback, and React.memo calls become unnecessary. It doesn't fix state architecture, though: where state lives and whether an update should happen at all are still design decisions.

Kanchandeep Kaur
SDE 2
Kanchandeep Kaur is a Software Development Engineer specializing in frontend development for web and mobile applications. She works with React, React Native, TypeScript, and modern API-driven architectures to build scalable, production-ready user experiences. Her work spans translating product requirements into intuitive interfaces, improving frontend performance, collaborating with product and design teams, mentoring engineers through code reviews, and solving complex production issues.
