React Performance Pitfalls: Fixing Re-Renders

Mohit GuptaMohit Gupta
11 min read

When we encounter performance issues in React applications, the root cause often boils down to unnecessary re-renders of components. To address this, developers frequently turn to method like React.memo, useMemo, and useCallback. However, while these methods can help optimize performance when used correctly, they are often misunderstood or misapplied, leading to code that is harder to read, debug, and maintain.

In this blog, we’ll explore common pitfalls associated with these optimization techniques, dive into their limitations, and discuss alternative approaches to solving re-rendering issues effectively.

Understanding React.memo

React.memo is a higher-order component that prevents a functional component from re-rendering if its props haven’t changed. At first glance, this seems like a perfect solution for performance optimization. However, there are nuances to consider.

const Child = () => {
  return <p>I am a child component</p>;
};

const MemoizedChild = React.memo(Child);

const Parent = () => {
  const [counter, setCounter] = useState(0);

  return (
    <>
      <h1>Counter: {counter}</h1>
      <MemoizedChild />
      <button onClick={() => setCounter(counter + 1)}>Increase Counter</button>
    </>
  );
}

In this example, clicking the button increments the counter state, and as we knowMemoizedChild component does not re-render because its props have not changed.

The Problem with Props

once you pass props to a memoized component, React compares the previous and current values of those props. If they differ, the component re-renders. This comparison can become tricky to track in deeply nested component trees, where prop changes propagate through multiple levels.

Example: Nested Components and Prop Changes

const AnotherNestedChild = (props) => {
  const [data, setData] = useState({});
  return <SomeOtherChild data={{ ...props, ...data }} />; 
};

const MemoizedAnotherNestedChild = React.memo(AnotherNestedChild);

const AnotherChild = (props) => {
  const [data, setData] = useState({});
  return <MemoizedAnotherNestedChild data={{ ...props, ...data }} />;
};

const MemoizedAnotherChild = React.memo(AnotherChild);

const Child = ({ prop }) => {
  const [data, setData] = useState({});
  return <MemoizedAnotherChild data={{ ...prop, ...data }} />;
};

const MemoizedChild = React.memo(Child);

const Parent = (props) => {
  const [title, setTitle] = useState();
  return <MemoizedChild data={{ title, ...props }} />;
};

in this example let’s say If I need to memoize SomeOtherChildusing React.memo I must ensure its props remain stable. This requires tracing the prop flow through AnotherChildChildParent, as props are passed down the tree. Any change in data along the way can break the rendering chain if memoization isn’t applied consistently.

As the app grows, tracking every prop and ensuring memoization becomes increasingly difficult. If a developer modifies the code without maintaining proper memoization, all optimization efforts collapse. This fragility makes relying solely on React.memo unsustainable for complex component trees.

Disadvantages of Using React.memo

  1. Complexity in Tracking Prop Changes : In deeply nested component trees, it’s hard to determine which props are changing and why.

  2. Breaking the Rendering Chain : If one component in the chain is not properly memoized, it can disrupt the entire rendering flow.

  3. Increased Cognitive Load : Developers must constantly monitor and update memoization logic as the codebase evolves.

Best Practices to Avoid Pitfalls

Before diving into alternatives, here are some general guidelines to minimize unnecessary re-renders:

  1. Avoid Spreading Props : Instead of spreading props coming from parent to child (<Child {...props} />), pass only the specific values needed. This makes it easier to track what’s being passed and reduces the risk of unintended prop changes.

  2. Prefer Primitive Values : Pass primitive values (e.g., strings, numbers) instead of objects or arrays. Reference changes for complex data types can trigger unnecessary re-renders.

  3. Minimize Deep Nesting : Simplify your component tree to reduce the complexity of prop propagation.

Alternative to React.memo: Component Composition

Instead of relying on React.memo, consider using component composition or the render props pattern . These approaches allow you to pass components as props, ensuring consistent references and avoiding unnecessary re-renders.

function ChildComponent() {
  return <div>Hi</div>;
}

function ParentComponent({ children }) {
  return <>{children}</>;
}

function App() {
  const childProp = <ChildComponent />;

  return (
    <ParentComponent>
      {childProp}
    </ParentComponent>
  );
}

In this example, ChildComponent is passed as a prop to ParentComponent. Since the reference to childProp remains constant, React skips re-rendering ChildComponent unless explicitly required.

Why This Works

To truly grasp, let’s break it down step by step. React’s rendering process operates in two conceptual phases :

  1. Render Phase : React evaluates components, calculates changes, and generates a virtual DOM tree.

  2. Commit Phase : React applies those changes to the actual DOM.

For example, consider a component tree: A > B > C > D. If the state of B updates, React will re-render B and all its children (C and D) by default.

Under the hood, JSX syntax is transformed into React.createElement() calls during compilation. For instance:

// JSX Syntax:
return <MyComponent a={42} b="testing">Text here</MyComponent>;

// Becomes:
return React.createElement(MyComponent, { a: 42, b: "testing" }, "Text Here");

// Which results in this plain JavaScript object:
{
  type: MyComponent,
  props: { a: 42, b: "testing" },
  children: ["Text Here"]
}

React uses this object tree to compare the previous and current virtual DOM. It follows these steps:

  1. Check Object Reference :
  • If the reference of the object (e.g., {type: MyComponent, props: {...}}) hasn’t changed, React skips re-rendering.

  • If the reference has changed, React either re-renders or mount, unmount the component.

2. Check Component Type : To determine whether a component needs to re-render or mount/unmount , React evaluates its type.

  • if type after and before rendering tree having Same Reference : React re-renders it by invoking its function again (e.g., MyComponent(props)).

  • Different Reference : If the reference changes, React unmounts the old component and mounts a new one, replacing it in the DOM tree.

Let’s understand it via below example

function ChildComponent() {
    return <div>Hi</div>;
  }

// This creates a new `ChildComponent` reference every time!
function ParentComponent() {
  return <ChildComponent />;
}
Before                                      After

   {                                     {
        type: ChildComponent,                type: ChildComponent,
        props: {},                           props: {},
        childs: []                           childs: []
   }                                      }

When the state of a parent component changes, React re-renders it by invoking ParentComponent(props) again. As part of this process:

  1. React evaluates the returned JSX from <ChildComponent />.

  2. It converts the JSX as mentiond above into an object tree, which it compares to the previous render’s tree.

At first glance, these object trees might appear identical. However, React doesn’t just look at their content — it checks their reference.

Since <ChildComponent /> is called inside ParentComponent during every re-render, its object reference changes. This triggers React's rules for updates:

  1. If the reference changes, React checks the type of the component.

  2. If the type matches (reference should be same) in our example both pointing to same ChildComponent), so React re-renders the component by calling its function.

This is why the ChildComponent re-renders even though it looks the same—it has a different object reference but same typereference each time.

What if we declare the compoent like below

// This creates a new `ChildComponent` reference every time!
function ParentComponent() {
  function ChildComponent() {
    return <div>Hi</div>;
  }

  return <ChildComponent />;
}

In this code: ChildComponent is defined inside ParentComponent.

  • Every time ParentComponent re-renders, a new instance of ChildComponent is created.

  • Consequently, the reference to ChildComponent function changes on every render

As per React’s reconciliation process, when it checks the type of the component before and after rendering, the ChildComponent function will have a different reference each time. This causes React to treat it as a new component, triggering the mounting process instead of a simple re-render.

Mounting is significantly more expensive than re-rendering because it involves initializing the component from scratch, including setting up its DOM nodes, state, and lifecycle methods.

// ✅ GOOD: Stable reference ensures efficient re-renders
function ChildComponent() {
  return <div>Hi</div>;
}

function ParentComponent() {
  return <ChildComponent />;
}

// ❌ BAD: This creates a new `ChildComponent` reference every time!
function ParentComponent() {
  function ChildComponent() {
    return <div>Hi</div>;
  }

  return <ChildComponent />;
}

Now coming back to our questions why passing compoent as props works

function ChildComponent() {
  return <div>Hi</div>;
}

function ParentComponent({ children }) {
  return <>{children}</>;
}

function App() {
  const childProp = <ChildComponent />;

  return (
    <ParentComponent>
      {childProp}
    </ParentComponent>
  );
}

In this case, when the parent component re-renders, the childProp reference remains unchanged because it is passed down from the App component. If the reference does not change, React skips re-rendering the child component, optimizing performance by avoiding unnecessary updates.

Understanding useCallback and useMemo

Below are the scenarios where these functions should be used:

  1. These functions should ideally be used in conjunction with React.memo to prevent unnecessary component re-renders.
function ChildComponent() {
  // Child component logic
}

const MemoizedChild = React.memo(ChildComponent);

function ParentComponent() {
  const [counter, setCounter] = useState(0); // Initialize counter with a default value

  // Stable callback using useCallback
  const onSubmit = useCallback(() => {
    // Logic for onSubmit
  }, []);

  // Memoized value using useMemo
  const data = useMemo(() => {
    // Compute or derive data here
    return {}; // Example: return some computed value
  }, []);

  return (
    <>
      <MemoizedChild onSubmit={onSubmit} data={data} />
      <button onClick={() => setCounter((prev) => prev + 1)}>Increase</button>
    </>
  );
}

2. Avoiding Multiple useEffect Executions: By stabilizing function references or memoizing values, you can avoid triggering useEffect multiple times unnecessarily.

import React, { useState, useEffect, useCallback } from 'react';

function ChildComponent({ onSubmit }) {
  useEffect(() => {
    console.log('useEffect in ChildComponent triggered due to onSubmit');
  }, [onSubmit]);

  return <button onClick={onSubmit}>Submit</button>;
}

function ParentComponent() {
  const [counter, setCounter] = useState(0);

  // Stable reference using useCallback
  const onSubmit = useCallback(() => {
    console.log('Submitted');
  }, []);

  return (
    <>
      <ChildComponent onSubmit={onSubmit} />
      <button onClick={() => setCounter((prev) => prev + 1)}>Increase</button>
    </>
  );
}

Apart from the two major use cases mentioned above, I haven’t seen much need for using useCallback and useMemo in most scenarios.

Below are some common pit falls where using useCallback and useMemo doesn’t make any sense

  1. In this example, there is no need to use useCallback, as it only makes the code harder to read. Additionally, it won't prevent the child component from re-rendering on every parent re-render. This is because ChildComponent is not memoized, so React has no reason to skip its re-render.
function ChildComponent() {
  // Child component logic
}

function ParentComponent() {
  const [counter, setCounter] = useState(0); // Initialize counter with a default value

  const onSubmit = useCallback(() => {
    // Logic for onSubmit
  }, []); // Empty dependency array ensures stable reference

  return (
    <>
      <ChildComponent onSubmit={onSubmit} />
      <button onClick={() => setCounter((prev) => prev + 1)}>Increase</button>
    </>
  );
}

2. Even if we are using React.memo along with useMemo and useCallback, the memoization chain can still break in the example below.

import React, { useState, useCallback, useMemo } from 'react';

const ChildComponent = ({ onSubmit, data }) => {
  console.log('ChildComponent rendered');
  return (
    <div>
      <button onClick={onSubmit}>Submit</button>
      <pre>{JSON.stringify(data)}</pre>
    </div>
  );
};

const MemoizedChild = React.memo(ChildComponent);

function ParentComponent() {
  const [counter, setCounter] = useState(0);

  const onSubmit = useCallback(() => {
    console.log('Submitted');
  }, []);

  const data = useMemo(() => {
    return {}; // returns a new object, even though memoized
  }, []);

  return (
    <>
      <MemoizedChild onSubmit={onSubmit} data={data} />
      <h1>I am the children</h1>
      <MemoizedChild>
        <p>Some child content</p>
      </MemoizedChild>
      <button onClick={() => setCounter((prev) => prev + 1)}>Increase</button>
    </>
  );
}

Why Memoization Broken: why the component still re-renders on every parent render.

Reason:
children prop in the second MemoizedChild:

  • If you're using MemoizedChild like this:
  •   jsxCopy code<MemoizedChild>
        <p>Some content</p>
      </MemoizedChild>
    

    — then children are a prop, and they change every time the parent re-renders (since JSX inside creates a new React element each time).

3. In the case below, even though we are passing MemoizedAnotherChild, as mentioned in the example above, the reference to this component will differ on every parent re-render. This causes the memoization chain to break.

import React, { useState, useCallback, useMemo } from 'react';

const ChildComponent = ({ onSubmit, data, children }) => {
  console.log('ChildComponent rendered');
  return (
    <div>
      <button onClick={onSubmit}>Submit</button>
      <pre>{JSON.stringify(data)}</pre>
      {children}
    </div>
  );
};

const AnotherChild = () => {
  console.log('AnotherChild rendered');
  return <div>I am AnotherChild</div>;
};

const MemoizedChild = React.memo(ChildComponent);
const MemoizedAnotherChild = React.memo(AnotherChild);

function ParentComponent() {
  const [counter, setCounter] = useState(0);

  const onSubmit = useCallback(() => {
    console.log('Submitted');
  }, []);

  const data = useMemo(() => {
    return {};
  }, []);

  return (
    <>      
      {/* JSX inside will cause re-render due to unstable `children` prop */}
      <MemoizedChild  onSubmit={onSubmit} data={data} >
        <MemoizedAnotherChild />
      </MemoizedChild>

      <button onClick={() => setCounter((prev) => prev + 1)}>Increase</button>
    </>
  );
}

in above example JSX returns a new React element on every render, even if the component itself is memoized.

This means the children prop passed to MemoizedChild is a new object on every re-render, causing it to ignore memoization and render again.

<MemoizedChild>
  <MemoizedAnotherChild />
</MemoizedChild>

4. Another common pitfall is , let’s say we have 300 object now without bothering about re-rendering we are more foucse on using React.memo to memozied expensive calcuation We should always keep in 99% of the time re-rendering is more costly then doing some calculaiton of 300 object in an array, most of the cpu do this kind of calcuaiton in 1–2 ms. But rendering 300 element in screen may take some more time. So focuse should be on to prevent compeont re-rendirng instead of unnecessary using useMemo


function Child() {
  const [arr, setArray] = useState(Array(300).fill({})); // Initialize with an array of 300 objects

  const data = useMemo(() => {
    // Perform some computation or transformation on `arr`
    return arr.map(item => ({ ...item })); // Example: Clone each object in the array
  }, [arr]); // Dependency on `arr` ensures recalculations only when `arr` changes

  return <>{/* Render something meaningful here */}</>;
}

function ParentComponent() {
  const [counter, setCounter] = useState(0); // Initialize counter with a default value

  return (
    <>
      <Child />
      <button onClick={() => setCounter((prev) => prev + 1)}>Increase</button>
    </>
  );
}

After reading the article above, we can see that there are several pitfalls when using React.memo, useCallback, and useMemo. In nested components, it becomes challenging to track changes if these tools are not used effectively. Misuse or overuse of these optimizations can lead to unnecessary complexity and even degrade performance instead of improving it.

On the other hand, the composition pattern appears to be much easier to understand and implement. In my next article, I will explore different ways to build components based on composition patterns, which can help simplify your code and make it more maintainable.

3
Subscribe to my newsletter

Read articles from Mohit Gupta directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mohit Gupta
Mohit Gupta