Optimizing React Apps with useMemo and useCallback

By Maulik Paghdal

18 Dec, 2024

•  12 minutes to Read

Optimizing React Apps with useMemo and useCallback

Introduction

Performance optimization becomes unavoidable once your React app starts handling real user data and complex interactions. I've debugged enough sluggish dashboards and janky animations to know that understanding useMemo and useCallback isn't just nice-to-have knowledge anymore.

These hooks solve specific performance problems by controlling when expensive operations run and when functions get recreated. But here's the thing: they're not magic bullets. Use them wrong, and you might actually make things slower. Use them right, and you'll see noticeable improvements in components that were previously struggling.

This guide covers the practical side of both hooks, including the gotchas I wish someone had told me about earlier.

Understanding React Rendering

React's rendering cycle is pretty straightforward: when state or props change, the component re-renders. This works great for keeping your UI in sync, but it can create performance problems when you have expensive operations or deep component trees.

Every re-render means:

  • All functions inside the component get recreated
  • All calculations run again
  • Child components might re-render unnecessarily

The key insight is that not everything needs to happen on every render. Some calculations only need to run when specific values change. Some functions can be reused between renders if their logic hasn't changed.

That's where memoization comes in. It's essentially a cache that stores the result of an operation and returns the cached version when the inputs haven't changed.

What is useMemo?

useMemo caches the result of a calculation. Think of it as a smart cache that only recalculates when something it depends on actually changes.

Syntax

const memoizedValue = useMemo(() => {
  return expensiveCalculation(dependency1, dependency2);
}, [dependency1, dependency2]);

The function you pass to useMemo only runs when one of the dependencies in the array changes. Otherwise, it returns the cached result from the previous render.

Real-World Example: Processing Large Datasets

Here's a scenario I run into frequently: filtering and sorting large lists based on user input.

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

function ProductList({ products }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [sortBy, setSortBy] = useState('name');
  const [category, setCategory] = useState('all');

  // Without useMemo, this runs on every render
  const processedProducts = useMemo(() => {
    console.log('Processing products...'); // You'll see this only when dependencies change
    
    return products
      .filter(product => {
        const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase());
        const matchesCategory = category === 'all' || product.category === category;
        return matchesSearch && matchesCategory;
      })
      .sort((a, b) => {
        switch (sortBy) {
          case 'price':
            return a.price - b.price;
          case 'name':
            return a.name.localeCompare(b.name);
          default:
            return 0;
        }
      });
  }, [products, searchTerm, sortBy, category]);

  return (
    <div>
      <input 
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search products..."
      />
      <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
        <option value="name">Sort by Name</option>
        <option value="price">Sort by Price</option>
      </select>
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>
      
      {processedProducts.map(product => (
        <div key={product.id}>{product.name} - ${product.price}</div>
      ))}
    </div>
  );
}

Without useMemo, the filtering and sorting would happen on every render, even if you just clicked a button that updated some unrelated state. With useMemo, it only processes when the actual search term, sort option, category, or product list changes.

When useMemo Makes Sense

  • Heavy calculations: Mathematical computations, data transformations, or complex filtering
  • Creating objects or arrays: Preventing new object references that cause child re-renders
  • Derived state: Values calculated from multiple state variables
// Good use case: expensive calculation
const expensiveValue = useMemo(() => {
  return heavyProcessing(data);
}, [data]);

// Good use case: preventing object recreation
const chartConfig = useMemo(() => ({
  width: 400,
  height: 300,
  data: processedData
}), [processedData]);

// Bad use case: simple calculation
const simpleValue = useMemo(() => a + b, [a, b]); // Just do: const simpleValue = a + b;

💡 Tip: Use the React DevTools Profiler to identify components that are actually slow before adding useMemo. Sometimes the performance problem is elsewhere.

What is useCallback?

useCallback is like useMemo but for functions. It returns a memoized version of a function that only changes when its dependencies change.

Syntax

const memoizedCallback = useCallback(() => {
  doSomething(dependency1, dependency2);
}, [dependency1, dependency2]);

The Child Re-render Problem

Here's the classic scenario where useCallback shines: preventing child components from re-rendering when they receive the same function prop.

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

// Child component wrapped in React.memo
const TodoItem = memo(({ todo, onToggle, onDelete }) => {
  console.log(`Rendering TodoItem: ${todo.text}`);
  
  return (
    <div className="todo-item">
      <span 
        style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
        onClick={() => onToggle(todo.id)}
      >
        {todo.text}
      </span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </div>
  );
});

function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React', completed: false },
    { id: 2, text: 'Build an app', completed: false }
  ]);
  const [newTodo, setNewTodo] = useState('');

  // Without useCallback, these functions get recreated on every render
  // This causes TodoItem to re-render even when the todo data hasn't changed
  const handleToggle = useCallback((id) => {
    setTodos(prev => prev.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }, []);

  const handleDelete = useCallback((id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);

  const addTodo = () => {
    if (newTodo.trim()) {
      setTodos(prev => [...prev, {
        id: Date.now(),
        text: newTodo,
        completed: false
      }]);
      setNewTodo('');
    }
  };

  return (
    <div>
      <input 
        value={newTodo}
        onChange={(e) => setNewTodo(e.target.value)}
        placeholder="Add new todo..."
      />
      <button onClick={addTodo}>Add Todo</button>
      
      {todos.map(todo => (
        <TodoItem 
          key={todo.id}
          todo={todo}
          onToggle={handleToggle}
          onDelete={handleDelete}
        />
      ))}
    </div>
  );
}

In this example, TodoItem is wrapped with React.memo, which means it only re-renders when its props actually change. Without useCallback, the onToggle and onDelete functions would be recreated on every render, causing all TodoItem components to re-render even when their todo data hasn't changed.

Advanced useCallback Pattern: Event Handlers with Dynamic Data

Sometimes you need to pass changing data to a callback while still keeping it stable:

const UserCard = memo(({ user, onUpdate }) => {
  return (
    <div>
      <h3>{user.name}</h3>
      <button onClick={() => onUpdate(user.id, { lastViewed: Date.now() })}>
        Mark as Viewed
      </button>
    </div>
  );
});

function UserList({ users }) {
  const [userStats, setUserStats] = useState({});

  // This callback depends on nothing, making it stable across renders
  const handleUserUpdate = useCallback((userId, updates) => {
    setUserStats(prev => ({
      ...prev,
      [userId]: { ...prev[userId], ...updates }
    }));
  }, []);

  return (
    <div>
      {users.map(user => (
        <UserCard 
          key={user.id}
          user={user}
          onUpdate={handleUserUpdate}
        />
      ))}
    </div>
  );
}

⚠️ Warning: Don't wrap every function in useCallback. Only do it when you're passing the function to a memoized child component or when the function has expensive dependencies.

Comparison Table: useMemo vs useCallback

FeatureuseMemouseCallback
PurposeMemoizes a computed valueMemoizes a function reference
Return ValueThe result of the computationA memoized version of the function
DependenciesRecomputes when dependencies changeRecreates function when dependencies change
Primary Use CaseExpensive calculations, derived stateStable function references for child props
Performance ImpactPrevents unnecessary computationsPrevents unnecessary child re-renders
Common MistakeMemoizing trivial calculationsWrapping functions that don't need stability
Memory UsageStores computed valuesStores function references

Advanced Patterns and Best Practices

Combining Both Hooks

Sometimes you'll use both hooks together for complex optimizations:

function DataVisualization({ rawData, filters }) {
  // Expensive data processing
  const processedData = useMemo(() => {
    return rawData
      .filter(item => filters.category === 'all' || item.category === filters.category)
      .map(item => ({
        ...item,
        normalizedValue: (item.value - filters.minValue) / (filters.maxValue - filters.minValue)
      }));
  }, [rawData, filters]);

  // Stable event handlers
  const handleDataPointClick = useCallback((dataPoint) => {
    console.log('Clicked:', dataPoint);
    // Handle click logic
  }, []);

  const handleZoom = useCallback((zoomLevel) => {
    // Handle zoom logic
  }, []);

  return (
    <Chart 
      data={processedData}
      onDataPointClick={handleDataPointClick}
      onZoom={handleZoom}
    />
  );
}

Dependency Array Best Practices

The dependency array is where most bugs happen. Here are the rules I follow:

// ✅ Good: All external dependencies included
const calculation = useMemo(() => {
  return complexCalculation(data, userPreferences, apiConfig);
}, [data, userPreferences, apiConfig]);

// ❌ Bad: Missing dependencies (will use stale values)
const calculation = useMemo(() => {
  return complexCalculation(data, userPreferences, apiConfig);
}, [data]); // Missing userPreferences and apiConfig

// ✅ Good: Stable dependencies using refs or state setters
const handleSave = useCallback(() => {
  saveData(formData);
}, [formData]);

// ✅ Good: Empty dependencies when function doesn't use external values
const createNewItem = useCallback(() => {
  return { id: generateId(), createdAt: Date.now() };
}, []); // No external dependencies

📌 Note: ESLint plugin react-hooks/exhaustive-deps will catch most dependency array mistakes. Install it and trust its warnings.

Custom Hooks with Memoization

Creating custom hooks that properly use memoization:

function useFilteredData(data, filters) {
  const filteredData = useMemo(() => {
    if (!data || data.length === 0) return [];
    
    return data.filter(item => {
      return Object.entries(filters).every(([key, value]) => {
        if (value === null || value === undefined || value === '') return true;
        return item[key] === value;
      });
    });
  }, [data, filters]);

  const handleFilter = useCallback((key, value) => {
    return { ...filters, [key]: value };
  }, [filters]);

  return { filteredData, handleFilter };
}

When NOT to Use These Hooks

Don't Memoize Everything

I used to wrap every calculation in useMemo thinking it would make everything faster. It doesn't. Memoization has overhead too.

// ❌ Unnecessary memoization
const simpleSum = useMemo(() => a + b, [a, b]);
const isEven = useMemo(() => number % 2 === 0, [number]);

// ✅ Just calculate directly
const simpleSum = a + b;
const isEven = number % 2 === 0;

Don't useCallback Without React.memo

useCallback only helps when the function is passed to a component that's wrapped in React.memo. Otherwise, you're just adding complexity for no benefit.

// ❌ useCallback without memo - no performance benefit
function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />; // Child is not memoized
}

// ✅ useCallback with memo - actual performance benefit
const Child = memo(({ onClick }) => {
  return <button onClick={onClick}>Click me</button>;
});

function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />;
}

Common Pitfalls and How to Avoid Them

Pitfall 1: Object Dependencies in Arrays

Objects and arrays in dependency arrays can cause issues because React uses shallow comparison:

// ❌ This will re-run on every render because {} !== {}
const result = useMemo(() => {
  return processData(config);
}, [config]); // If config is { threshold: 10 }, it's recreated each render

// ✅ Better: Destructure the specific values you need
const result = useMemo(() => {
  return processData(config);
}, [config.threshold, config.sortOrder]);

// ✅ Or memoize the config object itself
const memoizedConfig = useMemo(() => ({
  threshold: 10,
  sortOrder: 'asc'
}), []);

Pitfall 2: Functions in Dependencies

// ❌ Function recreated every render, breaks memoization
function Component({ data }) {
  const processItem = (item) => {
    return item.value * 2;
  };

  const processedData = useMemo(() => {
    return data.map(processItem);
  }, [data, processItem]); // processItem changes every render!

  // ✅ Better: Move function inside useMemo
  const processedData = useMemo(() => {
    const processItem = (item) => item.value * 2;
    return data.map(processItem);
  }, [data]);

  // ✅ Or use useCallback for the function
  const processItem = useCallback((item) => {
    return item.value * 2;
  }, []);

  const processedData = useMemo(() => {
    return data.map(processItem);
  }, [data, processItem]);
}

Pitfall 3: Over-Engineering Simple Components

// ❌ Over-optimized for a simple component
function SimpleCounter() {
  const [count, setCount] = useState(0);
  
  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);
  
  const doubledCount = useMemo(() => count * 2, [count]);
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubledCount}</p>
      <button onClick={increment}>+</button>
    </div>
  );
}

// ✅ Simple and clear
function SimpleCounter() {
  const [count, setCount] = useState(0);
  const doubledCount = count * 2;
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubledCount}</p>
      <button onClick={() => setCount(prev => prev + 1)}>+</button>
    </div>
  );
}

Measuring Performance Impact

Before optimizing, measure what's actually slow:

function ExpensiveComponent({ data }) {
  // Add performance markers to see the impact
  const processedData = useMemo(() => {
    console.time('data-processing');
    const result = heavyDataProcessing(data);
    console.timeEnd('data-processing');
    return result;
  }, [data]);

  return <DataVisualization data={processedData} />;
}

Use React DevTools Profiler to see which components are re-rendering and how long they take. Focus your optimization efforts on the components that are actually causing problems.

⚠️ Warning: Premature optimization can make your code harder to read and maintain. Profile first, optimize second.

Real-World Performance Patterns

Pattern 1: Stable References for Third-Party Libraries

Many third-party libraries (like chart libraries) re-initialize when they receive new function props:

function ChartComponent({ data, onDataSelect }) {
  const chartOptions = useMemo(() => ({
    responsive: true,
    plugins: {
      legend: { position: 'top' },
      tooltip: { enabled: true }
    }
  }), []);

  const handleChartClick = useCallback((event, elements) => {
    if (elements.length > 0) {
      const dataIndex = elements[0].index;
      onDataSelect(data[dataIndex]);
    }
  }, [data, onDataSelect]);

  return (
    <Chart 
      data={data}
      options={chartOptions}
      onClick={handleChartClick}
    />
  );
}

Pattern 2: Optimizing List Rendering

When rendering large lists, stable functions prevent unnecessary re-renders of individual items:

const ListItem = memo(({ item, onEdit, onDelete }) => {
  return (
    <div className="list-item">
      <span>{item.name}</span>
      <button onClick={() => onEdit(item.id)}>Edit</button>
      <button onClick={() => onDelete(item.id)}>Delete</button>
    </div>
  );
});

function ItemList({ items }) {
  const [editingId, setEditingId] = useState(null);

  const handleEdit = useCallback((id) => {
    setEditingId(id);
  }, []);

  const handleDelete = useCallback((id) => {
    setItems(prev => prev.filter(item => item.id !== id));
  }, []);

  return (
    <div>
      {items.map(item => (
        <ListItem 
          key={item.id}
          item={item}
          onEdit={handleEdit}
          onDelete={handleDelete}
        />
      ))}
    </div>
  );
}

Testing Your Optimizations

Using React DevTools

  1. Open React DevTools
  2. Go to the Profiler tab
  3. Record interactions with your component
  4. Look for components that re-render unnecessarily or take a long time

Simple Performance Test

function usePerformanceTest(name) {
  const startTime = performance.now();
  
  useEffect(() => {
    const endTime = performance.now();
    console.log(`${name} render time: ${endTime - startTime}ms`);
  });
}

function OptimizedComponent() {
  usePerformanceTest('OptimizedComponent');
  
  // Your component logic here
}

Conclusion

useMemo and useCallback are tools for solving specific performance problems, not blanket solutions to make everything faster. The key is understanding when your components are actually slow and why.

Use useMemo when you have expensive calculations that don't need to run on every render. Use useCallback when you're passing functions to memoized child components. Always measure the impact of your optimizations.

The best optimization is often simplifying your component structure or moving expensive operations outside of render cycles entirely. These hooks are your second line of defense when good component design isn't enough.

Start with profiling, identify the real bottlenecks, then apply these optimizations thoughtfully. Your users will notice the difference in components that actually need it.

Topics Covered

About Author

I'm Maulik Paghdal, the founder of Script Binary and a passionate full-stack web developer. I have a strong foundation in both frontend and backend development, specializing in building dynamic, responsive web applications using Laravel, Vue.js, and React.js. With expertise in Tailwind CSS and Bootstrap, I focus on creating clean, efficient, and scalable solutions that enhance user experiences and optimize performance.