DEV Community

Cover image for You’re the reason your React app is slow
<devtips/>
<devtips/>

Posted on

You’re the reason your React app is slow

You didn’t hit a framework limit. You wrote the bottleneck yourself and it’s been quietly billing you in FPS ever since.

There’s a specific kind of suffering that happens when you open the React DevTools Profiler for the first time on a project that’s been “running fine.” You hit record. You click a button. You stop recording. And then you just sit there, staring at a flame graph that looks like a city on fire, wondering how a todo app is re-rendering 47 components when you clicked “add item.”

That was me, about three years into thinking I was pretty decent at React.

I wasn’t bad. My components looked clean. My PRs got approved. The app shipped. But under the hood, I was doing roughly eight things wrong simultaneously, and the only reason nobody noticed was that our user base was small enough that the jank felt like a “network thing.” It was not a network thing.

The React ecosystem has an interesting culture around performance: everyone knows it matters, most articles cover the same four hooks, and almost nobody talks about the architectural decisions that create the problem in the first place. The React Compiler landing in React 19 is going to paper over some of this it automatically memoizes components and values, essentially applying useMemo and useCallback everywhere it's safe to do so but here's the honest truth: it won't save you from architectural issues like overly broad context providers or massive component trees. You can't compile your way out of a bad design. DEV CommunityDEV Community

This article is the one I wish someone had thrown at me back then. No cargo-cult hooks. No “just add React.memo" advice. Just the actual mistakes, why they happen, and what they cost you in the real world.

TL;DR: React is fast by default. You are often the problem. These 10 mistakes are the most common ways engineers including experienced ones quietly murder their own app’s performance, and most of them have nothing to do with the hooks you’ve been memorizing.

The re-render killers

Let’s start with the category that accounts for maybe 60% of React performance complaints I’ve seen in the wild. Not slow APIs. Not bad algorithms. Just components re-rendering when they absolutely did not need to, because of decisions made in the five seconds it took to write a JSX prop.

React’s re-render model is simple enough that it’s easy to underestimate. React re-renders when state or props change by reference, not by value. That one sentence is responsible for more production slowdowns than any framework bug ever was. It sounds obvious until you realize how many ways you’re accidentally creating new references on every render without thinking about it. DEV Community

Mistake 1: Inline functions in JSX

This is the one that gets everyone eventually, usually when they’re moving fast and the code looks clean.

// you write this and it feels fine
<Button onClick={() => handleDelete(item.id)} label="Delete" />

Here’s what’s actually happening: every time the parent component renders, that arrow function is a brand new function object in memory. React does a shallow comparison on props. New reference equals “props changed” equals re-render even if item.id hasn't moved an inch. JavaScript creates a new object or function reference on every render. React does a shallow comparison when deciding whether to re-render a child, and since the reference is always new, the child always re-renders, even when nothing meaningful has changed. DEV Community

The fix is boring and correct: move static handlers outside the component, and for dynamic ones that depend on state or props, reach for useCallback. But there's a catch which brings us directly to mistake number two.

Mistake 2: Using useCallback as a good luck charm

So you read about inline functions, you start wrapping everything in useCallback, and you feel like you've leveled up. You haven't. You've just moved the problem around and added overhead.

useCallback only does anything useful when the component receiving that function is actually memoized wrapped in React.memo. Without that, you're paying the cost of memoization (React has to store the previous function, compare dependencies, and make a decision) while getting zero benefit, because the child rerenders anyway. useCallback only helps if the child component is memoized (React.memo) or uses the callback in its own dependency arrays. Otherwise, you're adding overhead for no benefit. DEV Community

I’ve seen codebases where someone went on a useCallback spree across the entire app, felt productive for a day, and then wondered why nothing got faster. There is a cost to memoization. React must store the previous props, compare them, and make a decision this adds overhead. If your component is fast to render and frequently changing, this comparison step may become more expensive than the render itself. Growin

The actual rule: useCallback is a tool for reference stability, not a performance incantation. Use it when you have a memoized child that receives the function as a prop, or when the function is in a useEffect dependency array and you want control over when the effect fires. That's basically it. Profile first, reach for it second.

Mistake 3: Using array index as key in lists

This one feels harmless until you have a list that changes items get added, removed, or reordered and suddenly your UI starts doing weird things. State ends up in the wrong component. Inputs keep the wrong value. Animations fire on the wrong element. You spend an hour blaming a library that did nothing wrong.

The key prop is React's identity system for list items. When you use the array index, you're telling React "the first item is always the first item, regardless of what it actually is." Reorder the list, and React thinks all the same items are still there just with different content. It patches the DOM in place instead of remounting, which is fast but wrong.

// this looks fine and is not fine
{items.map((item, i) => <Card key={i} data={item} />)}

// this is fine
{items.map(item => <Card key={item.id} data={item} />)}

If your data genuinely has no stable IDs which happens more than it should generate them when the data is created, not at render time. A crypto.randomUUID() call in your fetch handler costs nothing. A Math.random() call inside map gives every item a new key on every render, which tells React to unmount and remount the entire list. That costs a lot.

All three of these mistakes share the same root: React’s rendering model is predictable once you understand it, but it punishes you quietly. No errors. No warnings. Just a Profiler graph that looks increasingly unwell.

The good news is that the React DevTools Profiler will catch all three almost immediately. Record a session, look for components highlighted in yellow or red, and ask yourself: “did this actually need to re-render?” Usually the answer is no, and usually one of these three is why.

State architecture sins

Re-renders from inline functions are annoying. State architecture mistakes are a different category of problem entirely. They’re the ones that survive a code review, pass all your tests, and then slowly make your app feel like it’s running through wet concrete as the feature count grows. They’re structural. And they’re almost always invisible until you’re already in pain.

The pattern is consistent across every codebase I’ve seen it in: someone makes a reasonable decision early, the app grows around that decision, and by the time the jank is obvious there are forty components depending on the thing that’s wrong. Refactoring it feels like surgery on a patient who’s still running a marathon.

Understanding why these happen is more useful than just memorizing the fix.

Mistake 4: Putting state too high up the tree

This is the most common architectural mistake in React, and it’s almost always made with good intentions. You want state to be accessible from multiple places, so you lift it up to a common ancestor. Reasonable. Except that ancestor is now App, and every time a checkbox toggles in a deeply nested form, your entire component tree re-renders.

If your App component’s state changes, every child component re-renders even if their props didn’t change. This cascading effect kills performance at scale. The mental model that helps here: state should live as close as possible to the components that actually use it. Not one level above. Not in a global provider “just in case something else needs it later.” Right next to the thing that needs it. This is called state colocation, and it’s one of those ideas that sounds obvious until you see how rarely it’s actually practiced.

If only two components in a subtree share a piece of state, their nearest common ancestor is the right home for it not the root. If state is only used by one component, it belongs inside that component, full stop. Split your components into two types: container components that handle the logic, and presentational components that are pure display. Because they have no local state, presentational components only re-render when their props actually change.

The performance difference between “state at the root” and “state colocated” can be dramatic in a large component tree. It’s also the kind of change that makes every subsequent optimization easier, because you’re no longer fighting against a re-render cascade every time you try to fix something specific.

Mistake 5: Context without memoization

React Context is one of those features that feels like a complete solution right up until it isn’t. You set up a provider, everything can access your global state, PRs get merged, life is good. Then someone notices that updating the user’s theme preference is somehow causing the entire dashboard to re-render, including the charts, the sidebar, and the table that has nothing to do with theming.

Here’s why. When you pass an object as the context value which almost everyone does that object is recreated on every render of the provider component. Every consumer sees a new reference. Every consumer re-renders. Even the ones that only care about one field in that object that didn’t change.

// every render of AppProvider creates a new value object
// every consumer re-renders every time anything changes
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('dark');
const value = { user, setUser, theme, setTheme };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

Every state update created a new value object. Every context consumer re-rendered. Including components that only cared about the theme, not the user.

Two fixes, and you’ll usually want both. First, wrap the context value in useMemo so the reference only changes when the actual data changes. Second, split large contexts into smaller ones by concern a UserContext and a ThemeContext rather than one AppContext that holds everything. A component that only reads theme should never re-render because the user object updated.

This one is particularly brutal in larger apps because context is often set up early, before the component tree is complex enough to make the problem visible. By the time you feel it, the context is load-bearing and everything’s wired to it.

Mistake 6: useEffect doing too much

useEffect is the Swiss Army knife of React hooks, which is both its strength and the reason developers use it to solve problems it was never designed for. The classic version of this mistake: a useEffect with a dependency array that's either wrong, empty when it shouldn't be, or so long it fires on basically every render anyway.

The subtler version is using useEffect for things that are actually derived state or event-driven logic. If you're running an effect to compute a value from existing state, that should probably be useMemo. If you're running an effect in response to a user action, that logic belongs in the event handler not in a side effect that triggers after the render. Using useEffect without proper dependencies, or too many, can trigger unnecessary logic or cause infinite loops.

The infinite loop variant is a rite of passage. You set state inside a useEffect, that state triggers a re-render, the effect fires again, sets state again, render again, and so on until your browser tab starts sweating. It happens to everyone. The less dramatic version an effect that fires twice as often as it should because the dependency array includes an object that gets recreated on every render is more insidious because it's harder to notice and easy to shrug off as "just how React works."

It’s not just how React works. It’s a dependency array that needs fixing.

A useful heuristic: if you can’t explain in one sentence what your useEffect is synchronizing with the outside world, there's a decent chance it shouldn't be a useEffect at all.

These three mistakes compound each other in particularly ugly ways. State too high up means more components consuming context. Context without memoization means all those components re-render together. Overloaded effects means those re-renders trigger more side effects. By the time you feel it, the performance problem isn’t one thing it’s a system of bad decisions that arrived gradually and now all need untangling at once.

The good news: fixing any one of them improves things measurably. Fixing all three feels like discovering your app had been running with the handbrake on.

Bundle crimes

Everything so far has been about what happens at runtime components re-rendering when they shouldn’t, state living in the wrong place, effects misfiring. These next two mistakes happen before a single line of your React code executes. They happen at load time, and they’re the reason some apps feel slow before the user has even done anything.

The bundle is the thing you’re shipping. It’s the JavaScript your users have to download, parse, and execute before they can interact with your product. Most developers think about it roughly once during the initial setup and then stop thinking about it entirely as the codebase grows. Features get added, dependencies get installed, and the bundle gets quietly heavier with every sprint until your Lighthouse score starts looking embarrassing.

According to HTTP Archive data, the median JavaScript payload for desktop users is over 500 KB, with mobile users often downloading significantly more. That’s the median. Plenty of production React apps are shipping multiples of that. On a mid-range phone on a decent connection, that’s a real wait before anything is interactive and most users won’t stick around for it. Growin

Mistake 7: Not using React.lazy and Suspense

The default behavior in a React app without code splitting is simple: everything ships together. Your landing page bundle includes the code for your settings panel, your admin dashboard, your onboarding flow, and every modal you’ve ever built. The user visiting your homepage for the first time downloads all of it, even though they haven’t navigated anywhere yet and statistically might never open half of those routes.

React.lazy and Suspense exist specifically for this. They let you split your bundle at the route or component level, loading chunks only when they're actually needed.

const AdminDashboard = React.lazy(() => import('./AdminDashboard'));

function App() {
return (
<Suspense fallback={<Spinner />}>
<AdminDashboard />
</Suspense>
);
}

React.lazy() and Suspense, when combined with route-level code splitting or dynamic imports, offer a reliable and modern solution to optimize loading behavior. Growin

The gains here can be significant. A route-level split on a mid-sized app commonly cuts the initial bundle by 30–50%, which translates directly into faster time-to-interactive. The user landing on your homepage gets only what they need to render that page. Everything else loads on demand, in the background, when it becomes relevant.

The reason developers skip this is usually that the app worked fine without it during development. Local development has no network latency and no cold cache, so a 2MB bundle feels instantaneous. Your users on mobile networks in the real world are having a measurably worse time, and the Profiler won’t show you that you have to look at your bundle analyzer and your Core Web Vitals to see it.

If you’re on Next.js or Remix, a lot of this is handled for you at the framework level. If you’re on a custom Vite or Webpack setup, route-level lazy loading is one of the highest-leverage changes you can make with the least amount of code.

Mistake 8: Importing entire libraries when you need one function

This one has been a known issue for years and it still shows up constantly. The pattern looks like this:

import _ from 'lodash';

const result = _.groupBy(data, 'category');

You needed groupBy. You imported all of lodash. Lodash is around 70KB minified and gzipped which is not catastrophic on its own, but it's also not free, and it compounds with every other library you're doing the same thing with.

Importing an entire library rather than just one or more components can dramatically increase your bundle size. A large bundle can slow download times, thereby negatively affecting the user experience. Use named imports rather than default imports, and use code-splitting so you can load only the code you need. TFTUS Official Blog

The fix is named imports, which tree-shake correctly:

import groupBy from 'lodash/groupBy';
// or
import { groupBy } from 'lodash-es';

The lodash-es variant is the ESM version of lodash, which plays nicely with modern bundlers and tree-shaking. Only the functions you actually import end up in your bundle.

Lodash is just the most common example. The same mistake gets made with moment.js a library that is famously large and should almost always be replaced with date-fns or the native Intl API at this point. It gets made with UI component libraries that export everything from a single entry point. It gets made with icon packs where someone imports the entire icon set to use three icons.

The way to catch this at scale is the Webpack Bundle Analyzer or the equivalent for Vite. Run it once on your production build and look at what’s actually inside your bundle. The results are often surprising in ways that are equal parts educational and mortifying. You will almost certainly find at least one dependency in there that you forgot you installed, and at least one that you installed for something you ended up not shipping.

This is worth doing before you spend time on any runtime optimization. It doesn’t matter how well-memoized your components are if you’re making users wait three seconds to download the JavaScript before any of that code runs.

These two mistakes live in a different category from the previous six because they’re invisible during development and only show up in production metrics. Your app feels fast locally. Your users experience something different. The fix for both requires adding a step to your workflow looking at bundle output, running Lighthouse against a production build, checking Core Web Vitals in Search Console rather than changing how you write components.

That habit of looking at what you’re actually shipping is one of the things that separates engineers who ship fast apps from engineers who ship apps that feel fast on their own machine.

The tools you’ve been ignoring

The previous eight mistakes were all things you did. These last two are things you didn’t do which somehow makes them worse. There’s a special category of performance problem that exists not because you wrote something wrong, but because you never reached for the tool that would have either prevented the problem or shown you it existed.

These aren’t obscure. They’re not advanced. They ship with React or have been in the ecosystem for years. They just require you to stop and think about scale before scale becomes the problem, and most of us don’t do that until a user complains or a Lighthouse score goes red.

Mistake 9: Rendering a thousand items when you could render twelve

At some point in almost every data-heavy React app, someone builds a list. The list works great with twenty items in development. It goes to production. The list now has two thousand items. Scrolling feels like dragging furniture across carpet. The component that renders each row is perfectly optimized memoized, no inline functions, stable keys and it doesn’t matter at all, because you’re rendering all two thousand of them simultaneously into the DOM whether the user can see them or not.

This is the problem that virtualization solves, and it’s one of those solutions that feels almost too simple once you understand it. Instead of rendering every item in the list, you render only the ones currently visible in the viewport plus a small buffer above and below for smooth scrolling. As the user scrolls, items that leave the viewport get unmounted, new ones get mounted. The DOM stays small. Performance stays flat regardless of dataset size.

import { FixedSizeList as List } from 'react-window';

<List
height={600}
itemCount={items.length}
itemSize={72}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<UserCard user={items[index]} />
</div>
)}
</List>

[react-window] only renders visible items, making scrolling smooth. Rendering thousands of DOM nodes at once kills performance. DEV Community

react-window is the standard library for this, maintained by Brian Vaughn who also built the React DevTools. It's small, fast, and well-documented. react-virtuoso is a newer alternative with more built-in features if you need variable item heights or grouped lists. Both work on the same principle.

The reason developers skip virtualization is usually that they don’t anticipate scale. The list has twenty items, the list works, ship it. Then the list grows. The performance cost of rendering a DOM node for every item in a list scales linearly double the items, roughly double the render time, double the memory usage, double the layout work the browser has to do on every scroll event. By the time you feel it, you often have a list that’s expensive to refactor because everything downstream depends on how it currently works.

The rule of thumb: if a list could plausibly ever exceed fifty items in production, plan for virtualization from the start. It’s much easier to set up on a new component than to retrofit it onto a complex one that’s been in production for six months.

Mistake 10: Never opening the React DevTools Profiler

This is the one that quietly enables every other mistake on this list to survive as long as it does. The Profiler has been part of React DevTools since React 16.5. It shows you exactly which components re-rendered, how long each render took, and why the render was triggered. It is, genuinely, one of the most useful debugging tools in the frontend ecosystem. Most developers have it installed and have never clicked the record button.

The workflow is straightforward. Open DevTools. Go to the Profiler tab. Hit record. Interact with the part of your app that feels slow. Stop recording. Look at the flame graph.

What you’re looking for: components that re-render more often than they should, and components whose render time is disproportionately long. The Profiler color-codes this for you gray means fast, yellow means moderate, orange and red mean you should probably look at this. You can click any bar in the graph to see exactly why that component rendered, including which prop or state value changed to trigger it.

React DevTools Profiler will show you whether a component re-render is expensive enough to justify memoization. In React development, especially as applications grow more interactive and component-driven, it’s easy to introduce performance issues without realizing it. Growin

This matters because performance optimization without measurement is just guessing. You add useCallback somewhere because it seems like the right area. You split a component because it feels too big. You might be right. You might be optimizing something that was already fast while the actual bottleneck sits three components over, untouched. No cargo-cult programming just understanding the system and applying targeted fixes. DEV Community

The Profiler removes the guessing. It tells you where the fire actually is, not where you think it might be. Every fix on this list inline functions, context memoization, colocated state, code splitting lands differently depending on your specific app. The Profiler is how you know which ones to prioritize and whether your changes actually did anything.

A practical habit: run the Profiler on any feature before you ship it, not after someone complains. It takes three minutes and it has saved me from shipping performance regressions more than once. It’s also genuinely interesting seeing exactly how React thinks about your component tree teaches you things about the framework that no article can.

These two mistakes share the same underlying cause: not thinking about scale and measurement until after the problem exists. Virtualization is what you add when you think ahead about large datasets. The Profiler is what you use when you want to stop guessing and start knowing.

Together, they close the loop on the entire list. The first eight mistakes give you specific patterns to avoid. These two give you the habit of catching what slips through anyway.

You’re not off the hook just because React 19 exists

Here’s the take that’s going to age either very well or very poorly: most of what’s on this list is going to become less relevant over the next two years, and that should make you more careful, not less.

React 19’s biggest change is the React Compiler, which automatically optimizes components without manual useMemo or useCallback wrappers. It analyzes your code at build time and applies memoization where it's safe. The inline function problem, the useCallback cargo-culting, a chunk of the re-render chaos the compiler handles a meaningful portion of that automatically. That's genuinely good news and the React team deserves credit for it. DEV Community

But I keep coming back to something that doesn’t change regardless of what the compiler does. Architecture isn’t a compiler problem. State living in the wrong place, context providers that re-render everything downstream, useEffect being used as a catch-all for logic that should live elsewhere none of that gets fixed at build time. The compiler won't save you from architectural issues like overly broad context providers or massive component trees. You still have to understand the system well enough to design it correctly. DEV Community

And here’s the uncomfortable part: if you learned React by reaching for useMemo and useCallback without understanding why, the compiler bailing you out doesn't mean you understood what it fixed. It means you got lucky. The next framework, the next abstraction, the next performance problem that falls outside what the compiler covers that's where the gap shows up.

Performance isn’t a checklist you run through once before a release. It’s a design skill. It lives in the decisions you make about where state goes, how components are composed, what you ship in the initial bundle, and whether you ever actually look at what your app is doing under load. The engineers I’ve seen consistently ship fast products aren’t the ones who know the most hooks. They’re the ones who profile before they optimize, think about scale before it’s a crisis, and treat the bundle as something they’re responsible for not something that happens automatically.

The React compiler is a great tool. So is the Profiler. So is react-window. None of them replace the judgment call about whether your component tree makes sense.

If this list had a single takeaway it’d be this: open the Profiler on your current project today, before you read another article, before you install another dependency. Record thirty seconds of normal usage. See what’s actually happening. You might be surprised. You might be horrified. Either way, you’ll know something real and that’s where every fix on this list actually starts.

Drop your worst React performance horror story in the comments. Bonus points if the root cause was on this list and you didn’t know it until a user complained.

Helpful resources

Top comments (0)