Performance Optimization with Memoization in React

Module 25: Frontend Frameworks & State Management

Understanding React's Rendering Behavior

Before diving into memoization, it's essential to understand how React's rendering process works:

  1. When a component's state or props change, React re-renders that component
  2. When a parent component re-renders, all of its children re-render by default
  3. This cascading re-render happens regardless of whether the children's props have changed

Think of React's rendering like a family getting ready in the morning: if the parent decides to change their outfit (state change), all the children have to at least check if they need to change their outfits too, even if they don't end up changing.

graph TD A[Parent Component] --> B[Child 1] A --> C[Child 2] A --> D[Child 3] B --> E[Grandchild 1.1] B --> F[Grandchild 1.2] style A fill:#f96 style B fill:#f96 style C fill:#f96 style D fill:#f96 style E fill:#f96 style F fill:#f96 classDef default fill:#f96,stroke:#333,stroke-width:2px;

When the parent re-renders, all descendants re-render by default

For simple applications, this behavior is usually fine. But as applications grow in complexity with deeper component trees, these unnecessary re-renders can lead to performance issues.

What is Memoization?

Memoization is a programming technique that stores the results of expensive function calls and returns the cached result when the same inputs occur again. It's like remembering the answer to a math problem instead of recalculating it every time.

In React, memoization helps us:

Real-world analogy: Imagine you're a barista at a coffee shop. Instead of making a latte from scratch every time someone orders the same drink, you might keep a few popular drinks ready to serve (memoized). Only when someone orders something different or the prepared drinks sit too long do you make fresh ones.

React.memo for Component Memoization

React.memo is a higher-order component that memoizes your component, preventing re-renders if props haven't changed. It performs a shallow comparison of props by default.

Basic Usage Example

// Without memoization
function ExpensiveComponent({ user }) {
  console.log('ExpensiveComponent rendered');
  
  // Imagine this component does something computationally expensive
  return (
    <div>
      <h2>{user.name}'s Profile</h2>
      <p>Email: {user.email}</p>
    </div>
  );
}

// With memoization
const MemoizedExpensiveComponent = React.memo(ExpensiveComponent);

// Parent component
function UserProfile() {
  const [user, setUser] = useState({ name: 'John', email: 'john@example.com' });
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Clicked {count} times
      </button>
      
      {/* This will re-render on every count change */}
      <ExpensiveComponent user={user} />
      
      {/* This will only re-render when user changes */}
      <MemoizedExpensiveComponent user={user} />
    </div>
  );
}

In this example, when the count state changes, the ExpensiveComponent will re-render unnecessarily, while the MemoizedExpensiveComponent will not re-render because its props (user) haven't changed.

graph TD A[UserProfile: count changes] --> B[ExpensiveComponent: Re-renders] A --> C[MemoizedExpensiveComponent: Skips re-render] style A fill:#f96 style B fill:#f96 style C fill:#6c6 classDef default stroke:#333,stroke-width:2px;

Custom Comparison Function

For more complex props, you can provide a custom comparison function to React.memo:

const MemoizedComponent = React.memo(MyComponent, (prevProps, nextProps) => {
  // Return true if passing nextProps to render would return
  // the same result as passing prevProps to render,
  // otherwise return false
  
  return (
    prevProps.name === nextProps.name &&
    prevProps.items.length === nextProps.items.length &&
    prevProps.items.every((item, i) => item.id === nextProps.items[i].id)
  );
});

Note that the comparison function returns true when props are equal (opposite of shouldComponentUpdate).

Real-world example: A product list component that only needs to re-render when products are added, removed, or reordered, but not when unrelated parts of the app change.

The Problem with Reference Types

A key challenge with memoization in React is that objects and functions are created fresh on each render, breaking referential equality even when their contents are identical.

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // This object is recreated on every render
  const user = { name: 'John', age: 30 };
  
  // This function is recreated on every render
  const handleClick = () => {
    console.log('Button clicked');
  };
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      
      {/* Will re-render every time, even with React.memo */}
      <MemoizedComponent user={user} onClick={handleClick} />
    </div>
  );
}

Even with React.memo, the component will re-render because user and handleClick are new references each time, even though their contents haven't changed.

useMemo for Value Memoization

The useMemo hook lets you memoize expensive calculations or object creation, updating the value only when dependencies change:

function UserStats({ userId }) {
  // This expensive calculation runs on every render
  const expensiveStats = calculateStats(userData, preferences);
  
  // With useMemo, it only runs when dependencies change
  const memoizedStats = useMemo(() => {
    console.log('Calculating stats...');
    return calculateStats(userData, preferences);
  }, [userData, preferences]);
  
  return <div>{/* Render stats */}</div>;
}

For objects that are passed as props, memoize them to maintain reference equality:

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('John');
  
  // Memoize the user object
  const user = useMemo(() => {
    return { name, age: 30 };
  }, [name]); // Only recreate when name changes
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      
      {/* Now this won't re-render when count changes */}
      <MemoizedComponent user={user} />
    </div>
  );
}

When to use useMemo:

useCallback for Function Memoization

The useCallback hook memoizes function references, ensuring they don't change between renders unless dependencies change:

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('John');
  
  // This function is recreated on every render
  const handleClick = () => {
    console.log(`Clicked by ${name}`);
  };
  
  // This function is stable between renders unless name changes
  const memoizedHandleClick = useCallback(() => {
    console.log(`Clicked by ${name}`);
  }, [name]); // Only recreate when name changes
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      
      {/* Will re-render on every count change */}
      <MemoizedButton onClick={handleClick} label="Click Me" />
      
      {/* Will only re-render when name changes */}
      <MemoizedButton onClick={memoizedHandleClick} label="Click Me" />
    </div>
  );
}

When to use useCallback:

Real-World Examples

Data Grid with Sorting and Filtering

function DataGrid({ items, columns }) {
  const [sortField, setSortField] = useState(null);
  const [filterText, setFilterText] = useState('');
  
  // Memoize the filtering and sorting computation
  const processedItems = useMemo(() => {
    console.log('Processing items...');
    
    // First filter
    let result = filterText
      ? items.filter(item => 
          item.name.toLowerCase().includes(filterText.toLowerCase()))
      : items;
    
    // Then sort if needed
    if (sortField) {
      result = [...result].sort((a, b) => {
        if (a[sortField] < b[sortField]) return -1;
        if (a[sortField] > b[sortField]) return 1;
        return 0;
      });
    }
    
    return result;
  }, [items, filterText, sortField]);
  
  // Memoize handlers
  const handleSort = useCallback((field) => {
    setSortField(field);
  }, []);
  
  const handleFilterChange = useCallback((e) => {
    setFilterText(e.target.value);
  }, []);
  
  return (
    <div>
      <input 
        type="text" 
        value={filterText} 
        onChange={handleFilterChange} 
        placeholder="Filter items..."
      />
      
      <table>
        <thead>
          <tr>
            {columns.map(column => (
              <th 
                key={column.field}
                onClick={() => handleSort(column.field)}
              >
                {column.name}
                {sortField === column.field ? ' ↓' : ''}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {processedItems.map(item => (
            <MemoizedRow key={item.id} item={item} columns={columns} />
          ))}
        </tbody>
      </table>
    </div>
  );
}

// Memoize the row component
const MemoizedRow = React.memo(({ item, columns }) => {
  return (
    <tr>
      {columns.map(column => (
        <td key={column.field}>{item[column.field]}</td>
      ))}
    </tr>
  );
});

This data grid example demonstrates multiple optimization techniques:

Real-world application: In enterprise applications with large datasets, these optimizations can transform a sluggish table into a responsive one, especially when typing in filters or sorting large lists.

When to Memoize (and When Not To)

Memoization isn't free - it comes with its own overhead. Here's guidance on when to use it:

Do Memoize:

Don't Memoize:

Remember: Premature optimization is the root of all evil. Start without memoization, measure performance, and add optimizations where needed.

Common Pitfalls

Missing Dependencies

// Incorrect: Missing dependencies
const memoizedValue = useMemo(() => {
  return calculateValue(a, b);
}, []); // Should include a and b in the dependency array

// Correct
const memoizedValue = useMemo(() => {
  return calculateValue(a, b);
}, [a, b]);

Excessive Memoization

// Unnecessary - simple calculation doesn't need memoization
const sum = useMemo(() => a + b, [a, b]);

// Better - just calculate it directly
const sum = a + b;

Memoizing Everything

// Don't wrap every component in React.memo
const Button = React.memo((props) => {
  return <button>{props.label}</button>;
});

// Simple components that re-render quickly don't need memoization
const Button = (props) => {
  return <button>{props.label}</button>;
};

Measuring Performance

Before optimizing, measure to identify real performance issues:

React DevTools Profiler

The React DevTools Profiler is your best friend for measuring render performance:

Performance Monitoring Tools

Practice Activity

Optimize a Shopping Cart Component

Refactor this shopping cart component to use memoization techniques:

function ShoppingCart() {
  const [items, setItems] = useState([
    { id: 1, name: 'Laptop', price: 999, quantity: 1 },
    { id: 2, name: 'Headphones', price: 99, quantity: 1 },
    { id: 3, name: 'Keyboard', price: 59, quantity: 1 }
  ]);
  
  const [couponCode, setCouponCode] = useState('');
  
  // Calculate the total price
  const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  
  // Apply coupon discount if valid
  const discount = couponCode === 'SAVE10' ? total * 0.1 : 0;
  const finalTotal = total - discount;
  
  // Update quantity of an item
  const updateQuantity = (id, newQuantity) => {
    setItems(items.map(item => 
      item.id === id ? { ...item, quantity: newQuantity } : item
    ));
  };
  
  // Remove an item from cart
  const removeItem = (id) => {
    setItems(items.filter(item => item.id !== id));
  };
  
  return (
    <div className="shopping-cart">
      <h2>Your Cart</h2>
      
      {items.map(item => (
        <CartItem 
          key={item.id}
          item={item}
          updateQuantity={updateQuantity}
          removeItem={removeItem}
        />
      ))}
      
      <div className="coupon">
        <input
          type="text"
          value={couponCode}
          onChange={(e) => setCouponCode(e.target.value)}
          placeholder="Enter coupon code"
        />
      </div>
      
      <div className="summary">
        <p>Subtotal: ${total.toFixed(2)}</p>
        {discount > 0 && <p>Discount: -${discount.toFixed(2)}</p>}
        <p className="final-total">Total: ${finalTotal.toFixed(2)}</p>
      </div>
      
      <button>Checkout</button>
    </div>
  );
}

function CartItem({ item, updateQuantity, removeItem }) {
  return (
    <div className="cart-item">
      <h3>{item.name}</h3>
      <p>${item.price}</p>
      <input
        type="number"
        min="1"
        value={item.quantity}
        onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
      />
      <p>Total: ${(item.price * item.quantity).toFixed(2)}</p>
      <button onClick={() => removeItem(item.id)}>Remove</button>
    </div>
  );
}

Apply the following optimizations:

  1. Memoize the CartItem component to prevent unnecessary re-renders
  2. Memoize the total, discount, and finalTotal calculations
  3. Use useCallback for the updateQuantity and removeItem functions
  4. Consider what dependencies each memoized value should have

Key Takeaways

Additional Resources