2024-09-26 Web Development

Optimizing React Components with useCallback

By O. Wolfson

In React, callback functions are commonly passed as props to child components, which can sometimes cause unnecessary re-renders and degrade performance. React’s useCallback hook addresses this issue by memoizing functions and ensuring they are not recreated on each render unless necessary. This article will demonstrate the practical use of useCallback by comparing two child components—one using useCallback and the other not—showing the impact on re-renders.


What is a Callback Function?

A callback function is a function passed as an argument to another function, often used in React to pass event handlers or other logic from a parent component to a child.

The Problem with Callbacks in React

By default, React recreates any functions declared inside a component every time that component re-renders. If a parent component passes a callback to a child as a prop, the child will re-render when the parent re-renders, even if the callback hasn’t changed. This is problematic when the child doesn’t need to update, resulting in wasted renders and unnecessary performance costs.

Enter useCallback

The useCallback hook helps prevent this by memoizing the function, ensuring the callback reference remains stable between renders unless its dependencies change. This is especially useful when passing callbacks to child components wrapped in React.memo() (which skips rendering unless props change).


Demonstration: Comparing Two Child Components

Let’s create a demonstration to compare two child components—one with a callback wrapped in useCallback and one without.

Parent Component

javascript
"use client";
// ParentComponent.jsx
import React, { useState, useCallback, useRef } from "react";
import ChildWithCallback from "./ChildWithCallback";
import ChildWithoutCallback from "./ChildWithoutCallback";

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

  // Keep track of how many times the parent has rendered
  const parentRenderCount = useRef(0);
  parentRenderCount.current += 1;

  // Memoized increment function
  const incrementWithCallback = useCallback(() => {
    setCount((c) => c + 1);
  }, []);

  // Regular increment function (not memoized)
  const incrementWithoutCallback = () => {
    setCount((c) => c + 1);
  };

  return (
    <div className="flex flex-col gap-4">
      <h1>Count: {count}</h1>
      <p>Parent Render Count: {parentRenderCount.current}</p>

      <h2>Child With useCallback:</h2>
      <ChildWithCallback onIncrement={incrementWithCallback} />

      <h2>Child Without useCallback:</h2>
      <ChildWithoutCallback onIncrement={incrementWithoutCallback} />

      <div>
        <button onClick={() => setCount((c) => c + 1)}>
          Increment from Parent
        </button>
      </div>
    </div>
  );
}

export default ParentComponent;

Child Components

Child Component Using useCallback

javascript
// ChildWithCallback.jsx
import React, { useRef } from "react";

const ChildWithCallback = React.memo(({ onIncrement }) => {
  const childRenderCount = useRef(0);
  childRenderCount.current += 1;

  return (
    <div>
      <p>Child With useCallback Render Count: {childRenderCount.current}</p>
      <button onClick={onIncrement}>
        Increment from Child With useCallback
      </button>
    </div>
  );
});

export default ChildWithCallback;

Child Component Without useCallback

javascript
// ChildWithoutCallback.jsx
import React, { useRef } from "react";

const ChildWithoutCallback = React.memo(({ onIncrement }) => {
  const childRenderCount = useRef(0);
  childRenderCount.current += 1;

  return (
    <div>
      <p>Child Without useCallback Render Count: {childRenderCount.current}</p>
      <button onClick={onIncrement}>
        Increment from Child Without useCallback
      </button>
    </div>
  );
});

export default ChildWithoutCallback;

Explanation

  1. Parent Component:

    • The parent component keeps track of its render count using useRef.
    • It passes an increment function to two child components: one that uses useCallback to memoize the function, and another that does not.
  2. Child Components:

    • Both child components use React.memo() to prevent re-renders unless their props change.
    • The child with useCallback should only re-render when its props change, whereas the child without useCallback will re-render every time the parent does, even if the prop function remains logically the same.

How to Observe the Behavior

  1. Initial Load:

    • Both child components will render once when the app is first loaded.
    • The parent and child render counts will both start at 1.
  2. Clicking "Increment from Parent":

    • The parent render count will increase by 1 each time the button is clicked.
    • Child With useCallback should not re-render, as the memoized incrementWithCallback function reference remains the same.
    • Child Without useCallback will re-render on every parent render because the incrementWithoutCallback function is recreated each time, causing the child to re-render unnecessarily.
  3. Clicking "Increment from Child":

    • Both child components will increment the count displayed in the parent.
    • However, the re-render behavior of the child components will remain the same, demonstrating that useCallback prevents re-renders unless props change.

See a Deployed Demonstration

Conclusion

This demonstration shows how useCallback can prevent unnecessary re-renders in child components by memoizing callback functions. In larger applications with many child components, this kind of optimization can significantly improve performance. By comparing two child components—one using useCallback and one not—you can clearly see the performance benefits in preventing unnecessary renders.