Skip to content
·8 min read

Stop Re-rendering Everything: A Practical Guide to React Performance

Most React performance advice is wrong. useMemo and useCallback won't fix your app. Here's what actually causes re-renders — and what to do about them.

ReactPerformanceFrontendBest Practices

React performance advice is mostly cargo cult. You've read the articles: "use useMemo here, slap useCallback on that, wrap everything in React.memo." You do it. Your app still feels slow. The re-renders still happen.

Here's why: memoization treats the symptom, not the cause. Re-renders aren't a missing-memo problem. They're a state-structure problem. Fix the structure and most re-renders disappear without a single useMemo.

This is the guide I wish someone had given me before I wrapped every function in useCallback for two years straight.

Why components re-render (the actual rules)

React re-renders a component when its state changes OR its parent re-renders. That's it. There is no third reason. Props don't cause re-renders — parent re-renders do. Props are just the cargo that comes along for the ride.

This is the single most misunderstood thing in React, and misunderstanding it leads to the wrong fixes.

Parent re-renders
  └─ Child re-renders (regardless of props)
       └─ Grandchild re-renders (regardless of props)

You wrap Child in React.memo. Now Child only re-renders if its props changed. Good. But Grandchild still re-renders, because Child re-rendered and Grandchild isn't memoized. So you wrap Grandchild too. And then its children. Congratulations — you're now maintaining ten memo() wrappers for no measurable gain.

The fix isn't more memoization. The fix is fewer parent re-renders.

The #1 fix: move state down

Here's the most common performance bug I see in React codebases:

function App() {
  const [searchQuery, setSearchQuery] = useState("")
  return (
    <div>
      <Header />
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <ExpensiveDashboard />
      <Footer />
    </div>
  )
}

Every keystroke in SearchBar updates searchQuery in App. App re-renders. ExpensiveDashboard re-renders — even though it doesn't care about the search query. If ExpensiveDashboard renders a chart with 5,000 data points, your app janks on every keystroke.

The fix is not to wrap ExpensiveDashboard in React.memo. The fix is to move the search state into a smaller component that doesn't contain the dashboard.

function App() {
  return (
    <div>
      <Header />
      <SearchSection />
      <ExpensiveDashboard />
      <Footer />
    </div>
  )
}
 
function SearchSection() {
  const [searchQuery, setSearchQuery] = useState("")
  return <SearchBar value={searchQuery} onChange={setSearchQuery} />
}

Now when searchQuery changes, only SearchSection re-renders. ExpensiveDashboard is untouched. No memoization needed. No useCallback. No useMemo. Just state where it belongs.

State should live in the smallest component that needs it. This one rule eliminates more re-renders than any optimization.

When React.memo actually helps

React.memo is not free. It compares props on every render. If props are complex, the comparison itself can take longer than just re-rendering the component.

Use React.memo when all three are true:

  1. The component renders something expensive (large lists, heavy calculations, complex SVG)
  2. It re-renders often because of a parent that changes unrelated state
  3. You can't move that state down (maybe the parent genuinely needs it)

If you can't check all three boxes, React.memo adds overhead for no benefit.

const ExpensiveChart = memo(function Chart({ data, options }) {
  // 800x600 canvas with thousands of points — expensive to render
  return <canvas ref={drawChart(data, options)} />
})

This earns its memo. The chart is expensive, it sits inside a parent that re-renders, and the props (data, options) are stable between renders.

Gotcha: if the parent passes an inline function or inline object as a prop, the memo breaks.

// Memo defeated — new function reference every render
<ExpensiveChart data={data} onHover={(p) => setActive(p.id)} />

That (p) => setActive(p.id) is a new function every render. React.memo sees a different prop reference and re-renders anyway. This is where useCallback actually earns its keep — keeping that function reference stable.

const handleHover = useCallback(
  (p) => setActive(p.id),
  [] // setActive from useState is stable, so [] is fine
)
<ExpensiveChart data={data} onHover={handleHover} />

Rule: useCallback only helps when the function is passed to a memoized child or used in a dependency array. Anywhere else, it's dead weight.

Keys: the silent re-render trigger

Bad keys cause two problems: unnecessary DOM work and state corruption. Most people know this. Most people still use array indices as keys.

// Wrong — using index as key
{items.map((item, index) => (
  <ListItem key={index} item={item} />
))}

This works until the list changes. Remove the second item, and React reuses DOM nodes based on position. The third item now sits in the slot that belonged to the second — and any internal component state (uncontrolled inputs, animations, refs) comes with it. Your form fields now show the wrong data.

Use stable, unique IDs:

{items.map((item) => (
  <ListItem key={item.id} item={item} />
))}

If you don't have IDs, generate them once when the data is created — not on every render. Date.now() or a counter works fine. Just don't generate keys inside the render function itself.

Stop measuring bundle size, start measuring interaction speed

Most performance posts obsess over bundle size. Bundle size matters — for the first load. After that, what users feel is interaction responsiveness. A 50KB app that re-renders a 3,000-row table on every keystroke feels broken. A 300KB app that updates only what changed feels instant.

Use React DevTools Profiler. Record a session. Look at what actually re-renders when you type in an input, click a button, or navigate. You'll find that 90% of your optimization opportunities are in 2-3 components — not the 50 you were going to wrap in React.memo.

Record a session →
Find the slow interaction →
Look at what re-rendered →
Ask "did this component need to re-render?" →
If no, find the state that triggered it and move that state

That's the entire workflow. No blog post required. No library. Just one tool and one question.

Server Components: the biggest win

If you're on Next.js, React Server Components eliminate re-renders by eliminating the components that cause them. A Server Component renders once on the server and ships zero JavaScript to the browser. It can't re-render because it doesn't exist on the client.

Most pages have components that never need to be interactive — article bodies, layout chrome, image galleries, footer links. Make those Server Components. Mark only the interactive bits with "use client". The non-interactive parts don't ship JS, don't hydrate, and don't re-render.

I rebuilt a client's dashboard page this way. Server Components for static layout and data display, Client Components only for the interactive filters. Bundle size dropped 60%. Interaction-to-paint latency dropped to nearly zero. No memoization was involved — just moving work off the client entirely.

What I actually do

When I open a slow React app, here's the order I work in:

  1. Profile. Find the actual slow interactions. Don't guess.
  2. Look for state held too high. Move it down. This fixes 80% of cases.
  3. Check list keys. Bad keys cause re-mounts that look like re-renders.
  4. Split heavy components. If one component does too much, breaking it up gives React finer-grained re-rendering.
  5. Memoize last. Wrap only the 2-3 components that actually need it. Verify each memo helps using the Profiler.

Steps 1 and 2 fix most apps. Step 5 is rarely needed and often harmful — every useMemo and useCallback is a dependency array you have to maintain, a stale-closure bug waiting to happen, and complexity the next developer has to read through.

The fastest code is the code that doesn't run. The second-fastest is the code that runs once. Aim for those. Memoization is the last tool in the box, not the first.

React doesn't have a performance problem. Your state tree does. Fix the structure and the re-renders take care of themselves.

Want a React app that's actually fast?

I build React and Next.js apps that ship fast and stay fast — without the cargo-cult memoization. Let's talk.