7 min read

React.memo & useCallback for smoother apps

React.memo & useCallback for smoother apps

React.memo and useCallback are optimization tools that prevent unnecessary re-renders and function recreations, making your order management apps faster and more efficient. These techniques build directly on concepts like controlled inputs, props vs state, and list rendering from previous articles.

Performance Bottlenecks in React

Unoptimized React apps re-render child components on every parent update, even if props haven't changed. In order dashboards with dynamic lists and forms, this leads to laggy UIs during frequent state updates like adding orders.

Functions defined inside components recreate on each render, causing child re-renders if passed as props. Without memoization, your order item lists and form handlers waste cycles.

What is React.memo?

React.memo is a higher-order component that memoizes a component, skipping re-renders if props are unchanged (shallow comparison). It wraps pure components receiving stable props, like individual order items in a list.

Use it for list items, static displays, or expensive computations to cut renders by 50-90% in dynamic UIs.

Basic React.memo Example: Order Item

Start with a memoized OrderItem displaying customer and amount from props.

OrderItem.js

import React from 'react';

function OrderItem({ order }) {
  console.log(`OrderItem ${order.id} rendered`); // Track renders
  return (
    <li style={{ padding: '8px', borderBottom: '1px solid #ccc' }}>
      Order #{order.id}: {order.customer} — ${order.amount}
    </li>
  );
}

export default React.memo(OrderItem);

This skips renders if the order prop equals the previous one.

App.js for Basic Memo Test

App.js

import React, { useState } from 'react';
import OrderFormControlled from '../src/OrderFormControlled';
import OrderItem from '../src/components/OrderItem';

function App() {
  const [orders, setOrders] = useState([]);
  const [filter, setFilter] = useState(''); // Triggers parent re-render

  const handleCreateOrder = (orderData) => {
    const newOrder = { id: Date.now(), ...orderData };
    setOrders([...orders, newOrder]);
  };

  const filteredOrders = orders.filter(o => 
    o.customer.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div style={{ padding: '20px', maxWidth: '600px' }}>
      <h2>Order Dashboard (with React.memo)</h2>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
        placeholder="Filter customers"
        style={{ marginBottom: '10px', padding: '5px' }}
      />
      <OrderFormControlled onCreateOrder={handleCreateOrder} />
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {filteredOrders.map(order => (
          <OrderItem key={order.id} order={order} />
        ))}
      </ul>
    </div>
  );
}

export default App;

OrderFormControlled.js (reuse from Controlled vs Uncontrolled Inputs article):

import React, { useState } from 'react';

function OrderFormControlled({ onCreateOrder }) {
  const [customer, setCustomer] = useState('');
  const [amount, setAmount] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (customer && amount) {
      onCreateOrder({ customer, amount: Number(amount) });
      setCustomer('');
      setAmount('');
    }
  };

  return (
    <form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
      <label style={{ display: 'block', marginBottom: '10px' }}>
        Customer Name:
        <input
          value={customer}
          onChange={e => setCustomer(e.target.value)}
          type="text"
          style={{ marginLeft: '10px', padding: '5px' }}
        />
      </label>
      <label style={{ display: 'block', marginBottom: '10px' }}>
        Order Amount:
        <input
          value={amount}
          onChange={e => setAmount(e.target.value)}
          type="number"
          min={0}
          style={{ marginLeft: '10px', padding: '5px' }}
        />
      </label>
      <button type="submit" style={{ padding: '5px 10px' }}>Create Order</button>
    </form>
  );
}

export default OrderFormControlled;

Run npm start. Type in filter—watch console: non-memoized OrderItems re-render all, memoized only changed ones.

The Function Recreation Problem

Inline functions like onDelete recreate each parent render, breaking memo since props change. In parent-child communication (React's Chain of Command article), passing handlers causes child re-renders.

Introducing useCallback

useCallback memoizes functions, returning the same instance if dependencies unchanged. Wrap callbacks passed to memoized children.

Enhanced OrderItem with Delete

Update OrderItem to accept delete handler.

OrderItem.js (updated)

import React from 'react';

function OrderItem({ order, onDelete }) {
  console.log(`OrderItem ${order.id} rendered`);
  return (
    <li style={{ padding: '8px', borderBottom: '1px solid #ccc', display: 'flex', justifyContent: 'space-between' }}>
      <span>Order #{order.id}: {order.customer} — ${order.amount}</span>
      <button onClick={() => onDelete(order.id)} style={{ background: '#ff4444', color: 'white', border: 'none', padding: '4px 8px' }}>
        Delete
      </button>
    </li>
  );
}

export default React.memo(OrderItem);

App.js with useCallback

App.js (updated)

import React, { useState, useCallback } from 'react';
import OrderFormControlled from '../src/OrderFormControlled';
import OrderItem from '../src/components/OrderItem';

function App() {
  const [orders, setOrders] = useState([]);
  const [filter, setFilter] = useState('');

  const handleCreateOrder = useCallback((orderData) => {
    const newOrder = { id: Date.now(), ...orderData };
    setOrders(prev => [...prev, newOrder]);
  }, []); // Empty deps: stable across all renders

  const handleDeleteOrder = useCallback((idToDelete) => {
    setOrders(prev => prev.filter(o => o.id !== idToDelete));
  }, []); // Stable delete function

  const filteredOrders = orders.filter(o => 
    o.customer.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div style={{ padding: '20px', maxWidth: '600px' }}>
      <h2>Optimized Order Dashboard (memo + useCallback)</h2>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
        placeholder="Filter customers"
        style={{ marginBottom: '10px', padding: '5px', width: '100%' }}
      />
      <OrderFormControlled onCreateOrder={handleCreateOrder} />
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {filteredOrders.map(order => (
          <OrderItem key={order.id} order={order} onDelete={handleDeleteOrder} />
        ))}
      </ul>
      <p>Total Orders: {filteredOrders.length}</p>
    </div>
  );
}

export default App;

Filter or add orders—console shows minimal OrderItem renders. Delete works without re-rendering unchanged items.

Complex Example: Reusable Components

Build on Designing Reusable Component Systems. Create OrderList with memoized children.

OrderList.js

import React from 'react';
import OrderItem from './OrderItem';

function OrderList({ orders, onDelete }) {
  console.log('OrderList rendered');
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {orders.map(order => (
        <OrderItem key={order.id} order={order} onDelete={onDelete} />
      ))}
    </ul>
  );
}

export default React.memo(OrderList); // Memo the list itself if props stable

Integrating useState Pitfalls

Extend useState Practical Pitfalls. Add status toggle without breaking memo.

OrderItem.js (status-enhanced)

import React from 'react';

function OrderItem({ order, onDelete, onStatusToggle }) {
  console.log(`OrderItem ${order.id} rendered`);
  return (
    <li style={{ padding: '8px', borderBottom: '1px solid #ccc', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
      <span>
        Order #{order.id}: {order.customer} — ${order.amount}
        <span style={{ marginLeft: '10px', color: order.status === 'shipped' ? 'green' : 'orange' }}>
          ({order.status})
        </span>
      </span>
      <div>
        <button onClick={() => onStatusToggle(order.id)} style={{ background: '#444', color: 'white', border: 'none', padding: '4px 8px', marginRight: '5px' }}>
          Toggle Status
        </button>
        <button onClick={() => onDelete(order.id)} style={{ background: '#ff4444', color: 'white', border: 'none', padding: '4px 8px' }}>
          Delete
        </button>
      </div>
    </li>
  );
}

export default React.memo(OrderItem);

App.js (full optimized)

import React, { useState, useCallback } from 'react';
import OrderFormControlled from './OrderFormControlled';
import OrderItem from './OrderItem';

function App() {
  const [orders, setOrders] = useState([]);
  const [filter, setFilter] = useState('');

  const handleCreateOrder = useCallback((orderData) => {
    const newOrder = { id: Date.now(), customer: orderData.customer, amount: orderData.amount, status: 'pending' };
    setOrders(prev => [...prev, newOrder]);
  }, []);

  const handleDeleteOrder = useCallback((id) => {
    setOrders(prev => prev.filter(o => o.id !== id));
  }, []);

  const handleToggleStatus = useCallback((id) => {
    setOrders(prev => prev.map(o => 
      o.id === id ? { ...o, status: o.status === 'pending' ? 'shipped' : 'pending' } : o
    ));
  }, []);

  const filteredOrders = orders.filter(o => 
    o.customer.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div style={{ padding: '20px', maxWidth: '700px' }}>
      <h2>Advanced Order Management (memo + useCallback + Status)</h2>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
        placeholder="Filter by customer"
        style={{ marginBottom: '20px', padding: '8px', width: '100%', boxSizing: 'border-box' }}
      />
      <OrderFormControlled onCreateOrder={handleCreateOrder} />
      <div>
        <h3>Orders ({filteredOrders.length})</h3>
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {filteredOrders.map(order => (
            <OrderItem 
              key={order.id} 
              order={order} 
              onDelete={handleDeleteOrder}
              onStatusToggle={handleToggleStatus}
            />
          ))}
        </ul>
      </div>
    </div>
  );
}

export default App;

Toggle status, filter, delete—renders stay minimal, even with multiple callbacks.

Lifting State Up Integration

From Parent-Child Communication, lift filter state to parent, pass via props. useCallback ensures stability.

List Keys Synergy

Pairs perfectly with Keys in Lists. Stable key={order.id} + memo + useCallback = optimal dynamic order lists.

Custom Hook with Memoization

Build on Building Custom Hooks. Memoize handlers in hook.

useOrderActions.js

import { useCallback } from 'react';

export const useOrderActions = (orders, setOrders) => {
  const handleCreate = useCallback((orderData) => {
    const newOrder = { id: Date.now(), ...orderData, status: 'pending' };
    setOrders(prev => [...prev, newOrder]);
  }, [setOrders]);

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

  const handleToggleStatus = useCallback((id) => {
    setOrders(prev => prev.map(o => 
      o.id === id ? { ...o, status: o.status === 'pending' ? 'shipped' : 'pending' } : o
    ));
  }, [setOrders]);

  return { handleCreate, handleDelete, handleToggleStatus };
};

App.js (hook-integrated)

import React, { useState, useCallback } from 'react';
import OrderFormControlled from './OrderFormControlled';
import OrderItem from './OrderItem';
import { useOrderActions } from './useOrderActions';

function App() {
  const [orders, setOrders] = useState([]);
  const [filter, setFilter] = useState('');
  const { handleCreate, handleDelete, handleToggleStatus } = useOrderActions(orders, setOrders);

  const filteredOrders = orders.filter(o => 
    o.customer.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div style={{ padding: '20px', maxWidth: '700px' }}>
      <h2>Order Mgmt with Custom Hook + Optimizations</h2>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
        placeholder="Filter customers"
        style={{ marginBottom: '20px', padding: '8px', width: '100%' }}
      />
      <OrderFormControlled onCreateOrder={handleCreate} />
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {filteredOrders.map(order => (
          <OrderItem 
            key={order.id} 
            order={order} 
            onDelete={handleDelete}
            onStatusToggle={handleToggleStatus}
          />
        ))}
      </ul>
    </div>
  );
}

export default App;

Context Integration Caution

When using Context API, memoize consumers or useCallback providers to avoid cascades. For order state, combine with memo.

Redux Toolkit Parallel

In Redux Toolkit Best Practices, useCallback wraps dispatch selectors for memoized components.

Common Pitfalls & Fixes

  • Dependencies: Include all used values in useCallback deps, or ESLint warns.
  • Over-memoization: Don't memo everything—profile first with React DevTools.
  • Deep Props: React.memo shallow compares; stringify complex objects or use useMemo (next topic).
  • Inline Objects{ key: value } recreates; use primitives or memoized objects.
PitfallSymptomFix
Missing deps in useCallbackStale closuresAdd all deps
Complex prop objectsAlways re-renderuseMemo on objects
No keys in listsPoor diffingStable unique keys
Unmemoized listsAll items re-rendermemo + keys + useCallback

Props vs State Reminder

These optimize prop passing from Props vs State, preventing state lifts from triggering prop re-renders.

Real-World Order Dashboard

Combine controlled forms, lists with keys, reusable systems, useState/useEffect pitfalls, custom hooks—all optimized.

Full files above compile directly. Add 50 orders, filter/toggle—smooth performance.

When NOT to Use

  • Frequent prop changes: Memo adds overhead.
  • Simple apps: Premature optimization hurts readability.
  • useEffect heavy: Memo doesn't help side effects.

Profile with DevTools Profiler before/after.

Next article: useMemo in complex UI scenarios.