Prevent Unnecessary Renders in React: A Guide to React.memo, useMemo, and useCallback

In React applications, rendering performance can quickly become a challenge—especially as your component tree grows. Without proper optimizations, even minor state changes can trigger unnecessary re-renders, leading to sluggish UIs and wasted processing.

In this article, we'll explore three powerful tools React gives us to tackle this challenge:

  • React.memo — memoize components

  • useMemo — memoize expensive computations

  • useCallback — memoize functions

We'll break down how each of these features works, explore when and why to use them, and demonstrate real-life use cases with clean, practical examples. Whether you're building a todo list, a shopping cart, or a complex dashboard, these patterns can help you write faster and more efficient React code.

Let’s dive into how to prevent unnecessary re-renders in React, and take your performance to the next level.

1. Optimizing with React.memo

What is React.memo?

React.memo or memo is a higher-order component that memoizes a functional component, preventing it from re-rendering unless its props change.

React normally re-renders a component whenever its parent re-renders. With memo, you can create a component that React will not re-render when its parent re-renders so long as its new props are the same as the old props. Such a component is said to be memoized.

To memoize a component, wrap it in memo and use the value that it returns in place of your original component.

import React, { useState } from "react";

const ChildComponent = React.memo(({ value }) => {
  console.log("Child component rendered");
  return <p>Child Value: {value}</p>;
});

function MemoExample() {
  const [count, setCount] = useState(0);
  const [childValue, setChildValue] = useState(100);

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

      <ChildComponent value={childValue} />
      <button onClick={() => setChildValue(childValue + 10)}>
        Update Child Value
      </button>
    </div>
  );
}

export default MemoExample;

How It Works:

  • Clicking Increment Count re-renders the parent.

  • The ChildComponent does not re-render unless childValue changes.

  • Saved re-renders = Better performance!

A React component should always have pure rendering logic. This means that it must return the same output if its props, state, and context haven’t changed. By using memo, you are telling React that your component complies with this requirement, so React doesn’t need to re-render as long as its props haven’t changed.

More Examples :

import { memo, useState } from 'react';

export default function MyApp() {
  const [name, setName] = useState('');
  const [address, setAddress] = useState('');
  return (
    <>
      <label>
        Name{': '}
        <input value={name} onChange={e => setName(e.target.value)} />
      </label>
      <label>
        Address{': '}
        <input value={address} onChange={e => setAddress(e.target.value)} />
      </label>
      <Greeting name={name} />
    </>
  );
}

const Greeting = memo(function Greeting({ name }) {
  console.log("Greeting was rendered at", new Date().toLocaleTimeString());
  return <h3>Hello{name && ', '}{name}!</h3>;
});

In this example, notice that the Greeting component re-renders whenever name is changed (because that’s one of its props), but not when address is changed (because it’s not passed to Greeting as a prop).

Warning :

Even when a component is memoized, it will still re-render when its own state and context value changes. Memoization only has to do with props that are passed to the component from its parent.


2. Optimizing with useMemo

What is useMemo?

useMemo memoizes the result of a function or cache the result of a calculation between re-renders, recomputing the value only when its dependencies change.
Perfect for expensive calculations!

You need to pass two things to useMemo:

  1. A calculation function that takes no arguments, like () =>, and returns what you wanted to calculate.

  2. A list of dependencies including every value within your component that’s used inside your calculation.

On every subsequent render, React will compare the dependencies with the dependencies you passed during the last render. If none of the dependencies have changed, useMemo will return the value you already calculated before. Otherwise, React will re-run your calculation and return the new value.

In other words, useMemo caches a calculation result between re-renders until its dependencies change.

By default, React will re-run the entire body of the component every time that it re-renders. For example, if any child component updates its state or receives new props from its parent, the entire function(expensive calculation) inside this component will re-run.

Usually, this isn’t a problem because most calculations are very fast. However, if you’re filtering or transforming a large array, or doing some expensive computation, you might want to skip doing it again if data hasn’t changed.

Example :

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

const expensiveCalculation = (num) => {
  console.log("Running expensive calculation...");
  for (let i = 0; i < 1000; i++) {} // Simulate heavy work
  return num * 2;
};

function UseMemoExample() {
  const [count, setCount] = useState(0);
  const [number, setNumber] = useState(5);

  const computedValue = useMemo(() => expensiveCalculation(number), [number]);

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

      <h2>Computed Value: {computedValue}</h2>
      <button onClick={() => setNumber(number + 1)}>Change Number</button>
    </div>
  );
}

export default UseMemoExample;
  • The expensiveCalculation only runs when number changes.

  • Updating count does not trigger a costly recalculation.


Combo : useMemo and memo

React’s smartest optimization trick isn’t complex — it’s simply knowing when to combine useMemo and memo.

Using useMemo and memo together in React creates a powerful optimization strategy. While useMemo caches expensive calculations like filtering or sorting, memo prevents components from re-rendering unless their props actually change. By combining both, you can dramatically reduce unnecessary work — minimizing recalculations and avoiding wasteful DOM updates. This leads to smoother, faster applications, especially when working with large lists, complex UIs, or frequent state changes.

A simple project can show you the real power.

App.jsx


import { useState, useMemo } from 'react';
import TodoList from './TodoList';

const initialTodos = [
  { id: 1, text: 'Learn React', completed: true },
  { id: 2, text: 'Learn useMemo and memo', completed: false },
  { id: 3, text: 'Build a Todo App', completed: false },
];

export default function App() {
  const [todos, setTodos] = useState(initialTodos);
  const [tab, setTab] = useState('all');
  const [theme, setTheme] = useState('light');

  function handleAddTodo() {
    const text = prompt('New todo:');
    if (text) {
      setTodos([
        ...todos,
        { id: todos.length + 1, text, completed: false }
      ]);
    }
  }

  function handleToggleTheme() {
    setTheme(theme === 'light' ? 'dark' : 'light');
  }

  return (
    <div className={`app ${theme}`}>
      <h1>Todo App</h1>
      <div className="buttons">
        <button onClick={() => setTab('all')}>All</button>
        <button onClick={() => setTab('active')}>Active</button>
        <button onClick={() => setTab('completed')}>Completed</button>
        <button onClick={handleAddTodo}>Add Todo</button>
        <button onClick={handleToggleTheme}>
          Toggle {theme === 'light' ? 'Dark' : 'Light'} Theme
        </button>
      </div>
      <TodoList todos={todos} tab={tab} theme={theme} />
    </div>
  );
}

Explanation:

App.jsx manages the core application state: the todos, the current filter tab, and the theme. It provides handlers to add a todo or toggle between light and dark mode. All states are passed to TodoList for further management.


TodoList.jsx

import { useMemo } from 'react';
import List from './List';

function filterTodos(todos, tab) {
  if (tab === 'active') {
    return todos.filter(todo => !todo.completed);
  } else if (tab === 'completed') {
    return todos.filter(todo => todo.completed);
  }
  return todos;
}

export default function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);

  return (
    <div className={`todo-list ${theme}`}>
      <List items={visibleTodos} />
    </div>
  );
}

Explanation:

TodoList.jsx filters todos according to the selected tab using filterTodos, then uses useMemo to memorize the filtered result, preventing unnecessary recalculations.


List.jsx

import { memo } from 'react';
import TodoItem from './TodoItem';

const List = memo(function List({ items }) {
  console.log('Rendering <List />');

  if (items.length === 0) {
    return <p>No todos</p>;
  }

  return (
    <ul>
      {items.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
});

export default List;

Explanation:

List.jsx renders a list of todos, and is wrapped with memo to avoid re-rendering unless the items array changes.


TodoItem.jsx

import { memo } from 'react';

const TodoItem = memo(function TodoItem({ todo }) {
  console.log('Rendering <TodoItem />:', todo.text);

  return (
    <li style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
      {todo.text}
    </li>
  );
});

export default TodoItem;

Explanation:

TodoItem.jsx renders a single todo with a strikethrough if completed. Memoization prevents it from re-rendering unless the individual todo actually changes.


3. Optimizing with useCallback

What is useCallback?

useCallback memoizes a function, keeping its reference stable between renders unless dependencies change.

Useful when passing callbacks to memoized components (React.memo) — otherwise, new function references would trigger unnecessary re-renders.

Example:

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

const Button = React.memo(({ handleClick }) => {
  console.log("Button component rendered");
  return <button onClick={handleClick}>Click Me</button>;
});

function UseCallbackExample() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  const handleClick = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);

  return (
    <div>
      <h1>Count: {count}</h1>
      <Button handleClick={handleClick} />

      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Type something..."
      />
    </div>
  );
}

export default UseCallbackExample;

What Happens Without useCallback :

  1. When text state changes (e.g., typing in the input field), UseCallbackExample component re-renders.

  2. The handleClick function is recreated, generating a new function reference.

  3. Since Button receives a new handleClick prop, React.memo detects a prop change and re-renders the Button component, even though nothing visible has changed.

What Happens With useCallback :

  1. When text state changes, UseCallbackExample still re-renders.

  2. However, useCallback memoizes the function, maintaining the same function reference unless the dependencies change.

  3. The Button component receives the same handleClick function reference, and React.memo prevents unnecessary re-rendering.


A real-life example

When combined, useCallback and memo form a powerful optimization strategy in React. useCallback keeps function references stable across renders, preventing child components from re-rendering unnecessarily when functions are passed as props. Meanwhile, memo ensures components only re-render when their props actually change. This duo is especially useful for passing event handlers—like onClick or onToggle—to deeply nested components. Together, they help reduce wasted renders, improve performance, and keep UIs smooth and scalable. Let’s see it in a real-life simple example.

App.jsx

import { useState, useCallback } from 'react';
import ProductList from './ProductList';
import Cart from './Cart';

const initialProducts = [
  { id: 1, name: 'Laptop', price: 999 },
  { id: 2, name: 'Phone', price: 499 },
  { id: 3, name: 'Headphones', price: 199 },
];

export default function App() {
  const [cartItems, setCartItems] = useState([]);

  const handleAddToCart = useCallback((product) => {
    setCartItems((prevItems) => [...prevItems, product]);
  }, []);

  return (
    <div className="app">
      <h1>Shopping Cart</h1>
      <ProductList products={initialProducts} onAddToCart={handleAddToCart} />
      <Cart cartItems={cartItems} />
    </div>
  );
}

Explanation:

The App.jsx file initializes a list of products and manages the cart items. It defines the handleAddToCart function, memoized with useCallback, so the function identity stays stable across re-renders, ensuring child components relying on this function don't unnecessarily re-render.

ProductList.jsx

import ProductItem from './ProductItem';
import { memo } from 'react';

const ProductList = memo(function ProductList({ products, onAddToCart }) {
  console.log('Rendering <ProductList />');

  return (
    <div className="product-list">
      {products.map(product => (
        <ProductItem key={product.id} product={product} onAddToCart={onAddToCart} />
      ))}
    </div>
  );
});

export default ProductList;

Explanation:

ProductList.jsx renders a list of products and is memoized using memo, ensuring it only re-renders when products or onAddToCart props change.

ProductItem.jsx

import { memo } from 'react';

const ProductItem = memo(function ProductItem({ product, onAddToCart }) {
  console.log('Rendering <ProductItem />:', product.name);

  return (
    <div className="product-item">
      <h2>{product.name}</h2>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product)}>Add to Cart</button>
    </div>
  );
});

export default ProductItem;

Explanation:

ProductItem.jsx represents a single product and offers an "Add to Cart" button. It’s wrapped in memo, ensuring it re-renders only if its own props change.

Cart.jsx

import { memo } from 'react';

const Cart = memo(function Cart({ cartItems }) {
  console.log('Rendering <Cart />');

  if (cartItems.length === 0) {
    return <p>Your cart is empty.</p>;
  }

  return (
    <div className="cart">
      <h2>Cart</h2>
      <ul>
        {cartItems.map((item, index) => (
          <li key={index}>{item.name} - ${item.price}</li>
        ))}
      </ul>
    </div>
  );
});

export default Cart;

Explanation:

Cart.jsx renders the list of items added to the cart. It’s wrapped in memo so it only re-renders when the cartItems prop changes.


When Should You Use useCallback?

  • When passing functions as props to memoized components (React.memo).

  • When using functions in dependencies of useEffect, useMemo, or other hooks.

  • When performance matters, e.g., in large component trees or frequent re-renders.

Why it is important ?

  • React.memo + useCallback = Prevents unnecessary child component re-renders when passing functions as props.

  • Without useCallback, the function reference changes on every render, causing React.memo to fail in preventing re-renders.


Final Thoughts

With the help of real-world examples and hands-on code, you’ve seen how to prevent unnecessary re-renders and make your React apps smoother and more efficient. Whether you're working on a simple feature or architecting a complex UI, these techniques (React.memo, useMemo, and useCallback) will serve you well.

Keep exploring, keep experimenting—and remember: performance isn’t just about speed, it’s about writing smarter code that does only what it needs to.

0
Subscribe to my newsletter

Read articles from Md. Monirul Islam directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Md. Monirul Islam
Md. Monirul Islam

Passionate and detail-oriented React Developer with over 5 years of industry experience in building scalable web and mobile applications using modern technologies such as React.js, Next.js, TypeScript, and Node.js. Proven track record of delivering high-quality solutions in the fintech, real estate, and government sectors.