Written by: ekwoster.dev on Thu Aug 28

Why Your React App Feels Slow — Even When It Isn’t: The Hidden Cost of Unoptimized Renders 🔍⚡️

Why Your React App Feels Slow — Even When It Isn’t: The Hidden Cost of Unoptimized Renders 🔍⚡️

Cover image for Why Your React App Feels Slow — Even When It Isn’t: The Hidden Cost of Unoptimized Renders 🔍⚡️

Why Your React App Feels Slow — Even When It Isn’t: The Hidden Cost of Unoptimized Renders 🔍⚡️

React is known for its performance advantages and declarative UI model, but many developers still find themselves asking:

"Why does my React app feel sluggish even though I'm using all the best practices?"

The answer often lies in unoptimized rendering and the overlooked pitfalls of component lifecycle and memoization. In this deep dive, we’ll look beyond the standard advice, into why this happens, how React’s diffing algorithm (Reconciliation) works under the hood, and concrete strategies to target invisible performance leaks.

Let’s fix this!


🎯 What You Think Is Fast Isn’t Always Perceived Fast

There’s a major UX concept called perceived performance: If a component updates unnecessarily (even quickly), it can trigger thousands of micro-operations in the DOM or React tree — causing lag, layout shifts, and frustrated users.

Let’s look at an example.

🧪 An Innocent-looking Component

// components/CommentList.js
import React from 'react';

const Comment = ({ comment }) => {
  console.log("Rendering comment", comment.id);
  return <div>{comment.text}</div>;
};

const CommentList = ({ comments }) => {
  return (
    <div>
      {comments.map(comment => (
        <Comment key={comment.id} comment={comment} />
      ))}
    </div>
  );
};

export default CommentList;

Looks safe, right?

Try this in your dev tools:

<CommentList comments={comments} />

Now trigger setState anywhere in the parent component and watch how all comments rerender — even when nothing changed.

This is death by 1,000 cuts.

👀 React Re-renders: The Subtle Culprit

When a parent component renders, its child components rerender by default — unless you prevent it. Even if props are the same object with same values (by content), the === identity check fails because it’s a new array/object reference.

<CommentList comments={[...comments]} />  // <- React thinks it's a new prop

React does shallow comparison only in memoized components. So what's the plan?


💡 Weapon 1: React.memo() to the Rescue 📦

Let’s wrap our functional components:

const Comment = React.memo(({ comment }) => {
  console.log("Rendering comment", comment.id);
  return <div>{comment.text}</div>;
});

This is a powerful tool — though with great power...

  • 🟩 Pros: Skips needless re-renders
  • 🔴 Cons: Can make things worse if misused (more on that later)

👉 Memoization only helps if props are similar in memory identity — time to tame our props!


🛑 Red Flag: Spawning New Props Every Time

<CommentList comments={[...comments]} />  // array recreated every render

This regenerates the comments prop every single render, causing all comments to be seen as new. Even if the content is identical.

✅ Right approach:

Keep stable references with state or useMemo:

const stableComments = useMemo(() => comments, [comments]);
<CommentList comments={stableComments} />

💥 The Real Performance Killer: useEffect + Extra Renders

Sometimes unoptimized lifecycle hooks trigger unnecessary re-renders or DOM updates.

useEffect(() => {
  fetchData();
}, [filter]);

What if filter is always a new object?

fetchData({ name: 'John' })

Even if name doesn’t change, the object is always new → useEffect always runs.

📌 Use stable dependencies — object keys only change when values do.

const stableFilter = useMemo(() => ({ name }), [name]);

🔍 Deep Dive: React’s Reconciliation Explained

React uses a process called Reconciliation to compare old virtual DOM and new — it does shallow diffs by default.

If your tree creates brand-new nodes or objects/arrays every render, React thinks it has to rerender everything.

✔️ Same reference = NO work
❌ New object/array = Marked for update

🧠 Advanced Moves: useCallback and useMemo

React will recreate functions on every render unless you memoize it.

const handleClick = () => doSomething(id);

This makes React rerender memoized children if they receive this function as a prop. Solution:

const handleClick = useCallback(() => doSomething(id), [id]);

This keeps the function reference stable as long as id doesn’t change.

🚀 Real-world improvement:

When we applied useMemo and useCallback to a legacy dashboard app at my company, the render count dropped by 76% — even with the same code logic.


🎁 Bonus: Developer Ergonomics with why-did-you-render

Tool to spot unnecessary renders in components:

npm install @welldone-software/why-did-you-render

Set up:

import React from 'react';
import whyDidYouRender from '@welldone-software/why-did-you-render';

whyDidYouRender(React, {
  trackAllPureComponents: true,
});

Your console will now tell you why each rerender happened.


🧹 Recap: Clean Up the React Noise

🧭 Use this checklist to fix render lag:

  • [x] Use React.memo() on pure components
  • [x] Maintain stable object/array references with useMemo
  • [x] Use useCallback for passing functions
  • [x] Avoid anonymous functions in render body
  • [x] Use helper dev tools for diagnostics

✨ Final Takeaway

Your React app might not be slow, but it might "feel" slow — due to avoidable rerenders. Optimizing the React render flow is less about code volume, more about thinking in memory identity.

Take the time to track your renders, stabilize props, and refactor rendering logic. Your users (and your future self) will thank you.


📚 Further Reading & Tools

  • React Docs: https://reactjs.org/docs/react-api.html#reactmemo
  • why-did-you-render: https://github.com/welldone-software/why-did-you-render
  • React Profiler (built into React DevTools)
  • CodeSandbox Template: React Memo Patterns

Happy optimizing! ⚙️

💡 If you need help building snappy React interfaces or debugging performance in your frontend — we offer frontend development services.