DEV Community

Cover image for React Performance Debugging: Find and Fix Unnecessary Re-Renders with Real Examples
Purvi Sondarva
Purvi Sondarva

Posted on

React Performance Debugging: Find and Fix Unnecessary Re-Renders with Real Examples

Have you ever clicked a button in a React application and noticed that components you didn't touch were rendering again?

Maybe a dashboard feels sluggish when updating a counter. Perhaps a large list becomes slow when typing into a search field. Or maybe you've added React. memo, useMemo, and useCallback, but the performance hasn't improved much.

These problems are common in React applications, but the solution isn't to add memoization everywhere.

The first step is to identify what is rendering, why it is rendering, and whether that rendering is actually expensive.

In this tutorial, we'll investigate unnecessary React re-renders, reproduce a common problem, fix it with practical code, and learn how to verify whether the optimization works.

What causes unnecessary re-renders in React?

A React component can render again when its state changes, its parent renders, or its consumed context changes. External store subscriptions and other React mechanisms can also trigger updates.

A render isn't necessarily a problem. React is designed to render components when needed.

The real problem occurs when an interaction triggers expensive work that doesn't need to happen.

For example, imagine a dashboard containing:

  • A counter that updates frequently.
  • A user profile card.
  • A large table containing hundreds of records.
  • Several charts with expensive calculations.

If updating the counter also causes expensive components to render repeatedly, the application may feel slower than necessary.

Here are four common causes worth investigating:

The important distinction is that a component rendering again does not automatically mean it should be optimized. Start by finding measurable performance problems.

Step 1: Reproduce an unnecessary re-render

Let's consider a small dashboard. It has a counter and a user profile component.

The profile doesn't depend on the counter, but it renders again whenever the counter changes.

Here's the simplified implementation:

import { memo, useState } from "react";

const UserProfile = memo(function UserProfile({ user }) {
  console.log("UserProfile rendered");

  return (
    <section>
      <h2>{user.name}</h2>
      <p>{user.role}</p>
    </section>
  );
});

export default function Dashboard() {
  const [count, setCount] = useState(0);

  const user = {
    name: "Alex",
    role: "Frontend Engineer",
  };

  return (
    <main>
      <h1>Dashboard</h1>

      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>

      <UserProfile user={user} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this looks reasonable. The profile component is already wrapped in memo, so why does it render again?

The problem is this line:

const user = {
  name: "Alex",
  role: "Frontend Engineer",
};
Enter fullscreen mode Exit fullscreen mode

Every time Dashboard renders, JavaScript creates a new object.

Even though the object's properties are unchanged, its reference is different. React's default memoization comparison sees the new user prop as changed, so UserProfile cannot skip rendering because of memo.

This is a common source of confusion: identical object contents do not mean identical object references.

Step 2: Find the problem with React DevTools Profiler

Before changing the code, confirm what is happening.

The React Developer Tools extension includes Components and Profiler panels that help you inspect components and investigate rendering behavior.

Here's a practical debugging workflow:

  1. Open your application in the browser.
  2. Open React DevTools and select the Profiler tab.
  3. Start recording.
  4. Perform the interaction that feels slow, such as clicking the counter repeatedly.
  5. Stop recording and inspect the components involved in each commit.
  6. Look for expensive rendering work or components that repeatedly render without needing updated data.

Don't focus only on how many times a component renders. A component that renders frequently but takes very little time may not be worth optimizing.
Instead, look for repeated work associated with a noticeably slow interaction.

For a deeper investigation, React's Profiler API can measure rendering performance programmatically.

Step 3: Fix the unstable object reference

One possible fix is to memoize the user object with useMemo.

import { memo, useMemo, useState } from "react";

const UserProfile = memo(function UserProfile({ user }) {
  console.log("UserProfile rendered");

  return (
    <section>
      <h2>{user.name}</h2>
      <p>{user.role}</p>
    </section>
  );
});

export default function Dashboard() {
  const [count, setCount] = useState(0);

  const user = useMemo(
    () => ({
      name: "Alex",
      role: "Frontend Engineer",
    }),
    []
  );

  return (
    <main>
      <h1>Dashboard</h1>

      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>

      <UserProfile user={user} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the object reference stays stable between renders, as long as the dependencies don't change.

When the counter updates, UserProfile can skip rendering because its user prop has not changed.

This works because two things are happening together:

  • useMemo preserves the calculated object between renders.
  • memo lets the child skip rendering when its props remain unchanged.

Using useMemo without a reason isn't automatically beneficial. If the object is cheap to create and the child isn't memoized, the optimization may provide no meaningful improvement.

A simpler alternative: If the object is constant, move it outside the component entirely.

const user = {
  name: "Alex",
  role: "Frontend Engineer",
};
Enter fullscreen mode Exit fullscreen mode

This is often easier to understand when the data doesn't depend on props, state, or other reactive values.

Step 4: Fix unstable function props

Objects aren't the only issue. Functions can also prevent memoized components from skipping renders.

Consider this example:

const SaveButton = memo(function SaveButton({ onSave }) {
  console.log("SaveButton rendered");

  return <button onClick={onSave}>Save changes</button>;
});

function Editor() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Counter: {count}
      </button>

      <SaveButton onSave={() => console.log("Saved")} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The inline function passed to SaveButton is a new function reference on each render. Consequently, memo cannot skip the child render based on unchanged props.

If profiling confirms that this child is worth optimizing, useCallback can preserve the function reference:

import { memo, useCallback, useState } from "react";

const SaveButton = memo(function SaveButton({ onSave }) {
  console.log("SaveButton rendered");

  return <button onClick={onSave}>Save changes</button>;
});

export default function Editor() {
  const [count, setCount] = useState(0);

  const handleSave = useCallback(() => {
    console.log("Saved");
  }, []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Counter: {count}
      </button>

      <SaveButton onSave={handleSave} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the callback reference stays stable, so SaveButton can skip rendering when its props remain unchanged.

Remember that useCallback caches a function definition; it doesn't prevent the function from being created during the component's execution. React returns the cached function when its dependencies haven't changed.

For more details, see the official useCallback documentation.

Step 5: Don't optimize every component

It's tempting to wrap every component in memo and every value in useMemo. In practice, this can make code harder to read and maintain without improving the user experience.

Before adding memoization, consider these alternatives:

  • Keep state close to where it is used. If a search field only affects one section, its state usually doesn't need to live at the top of the entire application.
  • Avoid unnecessary Effects. An Effect that updates state can create additional render cycles. If a value can be calculated from existing props or state during rendering, you may not need another state variable.
  • Use component composition. Sometimes moving frequently changing state into a smaller component prevents unrelated parts of the interface from re-rendering.
  • Measure expensive calculations. Use useMemo when a calculation is demonstrably expensive or when stable values are needed for an effective memoization boundary.
  • Check your React setup. React Compiler can automatically memoize components, values, and functions when appropriately configured. This can reduce the need for manual memo, useMemo, and useCallback in supported applications.

You can learn more in the official React memo documentation.

Step 6: Verify that the optimization worked

After making a change, record the same interaction again using React DevTools Profiler.

Compare the results before and after the change:

Avoid inventing performance percentages. The improvement depends on your application, the component tree, the data, and the environment.

Also remember that development-mode measurements can differ from production behavior. React Strict Mode may intentionally invoke rendering logic more than once during development to help reveal bugs. For meaningful performance conclusions, use appropriate profiling tools and repeatable test conditions.

The goal isn't to achieve the fewest possible renders. It's to remove unnecessary work without making the code more complicated than it needs to be.

A practical checklist for debugging React performance

When a React application feels slow, use this sequence:

  • Reproduce the slow interaction consistently.
  • Record the interaction with React DevTools Profiler.
  • Identify which components consume meaningful rendering time.
  • Check whether props, state, or context actually need to change.
  • Look for unstable object and function references.
  • Consider simpler state placement or component composition.
  • Apply memoization only when it addresses the identified problem.
  • Profile again and verify both performance and correctness.

Following this process makes React performance debugging more systematic and helps avoid premature optimization.

Conclusion

Unnecessary React re-renders are worth investigating when they cause measurable performance problems, but rendering itself isn't the enemy.

Start with the React DevTools Profiler, identify the work that matters, and then choose the simplest fix. Stable props, sensible state placement, component composition, and targeted memoization can all help when used for the right reasons.

Optimizing rendering is just one part of building fast, maintainable React applications. A well-structured component architecture and thoughtful performance decisions also play an important role. If you're exploring these aspects further, learn more about ReactJS development.

*The takeaway is simple: measure first, optimize second, and measure again.
*

What's the most common React performance issue you've encountered in a real project: unstable props, expensive calculations, state management, or something else? Share your experience in the comments.

Top comments (0)