Yong Sen - Full-Stack Developer

Understanding React Hooks: useEffect vs useMemo vs useCallback

A comprehensive guide to understanding when and why to use useEffect, useMemo, and useCallback in React applications.

January 15, 2025
6 min read

Understanding React Hooks: useEffect vs useMemo vs useCallback

React hooks are powerful tools that help us manage state and side effects in functional components. However, understanding when to use each hook can be confusing, especially for developers new to React. In this post, we'll explore the key differences between useEffect, useMemo, and useCallback, and learn when to use each one.

The Three Hooks Explained

useEffect - Side Effects and Lifecycle

useEffect is used for side effects that need to happen after rendering. It's the functional component equivalent of lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount.

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // This runs after every render
    const fetchUser = async () => {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        setUser(userData);
      } catch (error) {
        console.error('Failed to fetch user:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Only re-run when userId changes

  if (loading) return <div>Loading...</div>;
  return <div>{user?.name}</div>;
}

When to use useEffect:

  • API calls and data fetching
  • Setting up subscriptions
  • Manually changing the DOM
  • Timers and intervals
  • Cleanup operations

useMemo - Expensive Calculations

useMemo memoizes the result of a computation and only recalculates when its dependencies change. It's useful for expensive calculations that you don't want to run on every render.

import { useState, useMemo } from 'react';

function ExpensiveComponent({ items, filter }) {
  const [searchTerm, setSearchTerm] = useState('');

  // This expensive calculation only runs when items, filter, or searchTerm change
  const filteredItems = useMemo(() => {
    console.log('Filtering items...'); // This won't log on every render
    return items
      .filter(item => item.category === filter)
      .filter(item => item.name.toLowerCase().includes(searchTerm.toLowerCase()))
      .sort((a, b) => a.name.localeCompare(b.name));
  }, [items, filter, searchTerm]);

  return (
    <div>
      <input 
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search items..."
      />
      <ul>
        {filteredItems.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

When to use useMemo:

  • Expensive calculations or transformations
  • Filtering and sorting large datasets
  • Creating objects or arrays that are passed as props
  • Preventing unnecessary re-renders of child components

useCallback - Function Memoization

useCallback memoizes a function and returns the same function reference when its dependencies haven't changed. This prevents child components from re-rendering unnecessarily when the function is passed as a prop.

import { useState, useCallback } from 'react';

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

  // Without useCallback, this function is recreated on every render
  const handleAddItem = useCallback((newItem) => {
    setItems(prev => [...prev, newItem]);
  }, []); // Empty dependency array means this function never changes

  // This function depends on count, so it will be recreated when count changes
  const handleIncrement = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>Increment</button>
      <ItemList items={items} onAddItem={handleAddItem} />
    </div>
  );
}

// Child component that receives the memoized function
const ItemList = React.memo(({ items, onAddItem }) => {
  console.log('ItemList rendered'); // This won't log unnecessarily
  
  return (
    <div>
      {items.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
      <button onClick={() => onAddItem({ id: Date.now(), name: 'New Item' })}>
        Add Item
      </button>
    </div>
  );
});

When to use useCallback:

  • Functions passed as props to child components
  • Functions used in dependency arrays of other hooks
  • Event handlers that are expensive to recreate
  • Preventing unnecessary re-renders of memoized components

Key Differences Summary

HookPurposeWhen to UseDependencies
useEffectSide effects after renderAPI calls, subscriptions, DOM manipulationArray of values to watch
useMemoMemoize expensive calculationsComplex computations, filtering, sortingArray of values that affect the calculation
useCallbackMemoize function referencesFunctions passed as props, event handlersArray of values the function depends on

Common Pitfalls and Best Practices

1. Don't Overuse useMemo and useCallback

// ❌ Unnecessary - simple calculations don't need memoization
const doubled = useMemo(() => count * 2, [count]);

// ✅ Just use regular calculation
const doubled = count * 2;

2. Be Careful with Dependency Arrays

// ❌ Missing dependency - will cause stale closure
useEffect(() => {
  fetchData(userId);
}, []); // Missing userId dependency

// ✅ Correct dependency array
useEffect(() => {
  fetchData(userId);
}, [userId]);

3. Don't Memoize Everything

// ❌ Over-memoization - objects are recreated anyway
const config = useMemo(() => ({
  theme: 'dark',
  language: 'en'
}), []);

// ✅ Just create the object directly
const config = { theme: 'dark', language: 'en' };

Performance Considerations

When Memoization Helps

function ExpensiveParent({ data }) {
  // This expensive calculation only runs when data changes
  const processedData = useMemo(() => {
    return data.map(item => ({
      ...item,
      processed: expensiveTransformation(item)
    }));
  }, [data]);

  // This function reference stays stable
  const handleClick = useCallback((id) => {
    console.log('Clicked item:', id);
  }, []);

  return (
    <div>
      {processedData.map(item => (
        <ExpensiveChild 
          key={item.id} 
          data={item} 
          onClick={handleClick} 
        />
      ))}
    </div>
  );
}

When Memoization Doesn't Help

function SimpleComponent({ name }) {
  // ❌ Unnecessary - simple string concatenation
  const greeting = useMemo(() => `Hello, ${name}!`, [name]);
  
  // ❌ Unnecessary - simple function
  const handleClick = useCallback(() => {
    alert(`Hello, ${name}!`);
  }, [name]);

  return <button onClick={handleClick}>{greeting}</button>;
}

Real-World Example: Search and Filter

Let's see all three hooks working together in a practical example:

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

function ProductSearch() {
  const [products, setProducts] = useState([]);
  const [searchTerm, setSearchTerm] = useState('');
  const [category, setCategory] = useState('all');
  const [loading, setLoading] = useState(true);

  // useEffect for data fetching
  useEffect(() => {
    const fetchProducts = async () => {
      setLoading(true);
      try {
        const response = await fetch('/api/products');
        const data = await response.json();
        setProducts(data);
      } catch (error) {
        console.error('Failed to fetch products:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchProducts();
  }, []);

  // useMemo for expensive filtering and sorting
  const filteredProducts = useMemo(() => {
    return products
      .filter(product => 
        category === 'all' || product.category === category
      )
      .filter(product =>
        product.name.toLowerCase().includes(searchTerm.toLowerCase())
      )
      .sort((a, b) => a.price - b.price);
  }, [products, searchTerm, category]);

  // useCallback for stable function reference
  const handleProductClick = useCallback((productId) => {
    // Navigate to product detail page
    window.location.href = `/products/${productId}`;
  }, []);

  if (loading) return <div>Loading products...</div>;

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search products..."
      />
      <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>
      
      <div>
        {filteredProducts.map(product => (
          <ProductCard
            key={product.id}
            product={product}
            onClick={handleProductClick}
          />
        ))}
      </div>
    </div>
  );
}

const ProductCard = React.memo(({ product, onClick }) => {
  return (
    <div onClick={() => onClick(product.id)}>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
});

Conclusion

Understanding when to use useEffect, useMemo, and useCallback is crucial for writing efficient React applications:

  • useEffect: Use for side effects that need to happen after rendering
  • useMemo: Use for expensive calculations that you want to cache
  • useCallback: Use for functions that you want to keep stable across renders

Remember that premature optimization can be counterproductive. Start with simple, readable code and add memoization only when you have performance issues and can measure the improvement.

The key is to understand what each hook does and use them judiciously to solve specific problems, not as a blanket solution for all performance concerns.

Post Details

January 15, 2025
6 min read
Tags
ReactHooksPerformanceJavaScript