Performance Optimization: Guide to Using memo, useMemo, and useCallback

ContentQR Team
10 min read
Technical Development
React
Performance
Optimization
memo
useMemo
useCallback

React performance optimization is crucial for building fast, responsive applications. As your application grows, you'll encounter performance bottlenecks that can impact user experience. Understanding when and how to use React's optimization tools—memo, useMemo, and useCallback—is essential for creating efficient applications. These tools help prevent unnecessary re-renders and expensive recalculations, but they must be used correctly to avoid premature optimization or performance degradation. In this guide, you'll learn the practical differences between these optimization techniques, when to use each one, and common pitfalls to avoid. We'll explore real-world examples from building ContentQR, where performance optimization played a critical role in handling complex QR code generation and content management workflows. By the end, you'll have a clear understanding of how to optimize your React components effectively.

Understanding React Re-renders

Before diving into optimization techniques, you need to understand when React components re-render:

Re-render Triggers:

  • State changes (useState, useReducer)
  • Props changes
  • Parent component re-renders
  • Context value changes

Important: Not all re-renders are bad. React is fast, and most re-renders are necessary and efficient. Only optimize when you have a performance problem.

React.memo: Preventing Component Re-renders

React.memo is a higher-order component that memoizes the result of a component. It only re-renders if its props have changed.

Basic Usage

import { memo } from 'react';

interface UserCardProps {
  name: string;
  email: string;
  avatar: string;
}

const UserCard = memo(function UserCard({ name, email, avatar }: UserCardProps) {
  console.log('UserCard rendered');
  
  return (
    <div className="user-card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>{email}</p>
    </div>
  );
});

export default UserCard;

When to Use memo

✅ Good Use Cases:

  • Expensive components that render frequently
  • Components that receive stable props
  • List items in large lists

❌ Avoid When:

  • Props change frequently
  • Component is cheap to render
  • Premature optimization

Custom Comparison Function

You can provide a custom comparison function:

const UserCard = memo(
  UserCard,
  (prevProps, nextProps) => {
    // Return true if props are equal (skip re-render)
    // Return false if props are different (re-render)
    return prevProps.id === nextProps.id && 
           prevProps.name === nextProps.name;
  }
);

Real-World Example: QR Code List

In ContentQR, we use memo for QR code list items:

import { memo } from 'react';

interface QRCodeItemProps {
  id: string;
  title: string;
  url: string;
  createdAt: string;
  onClick: (id: string) => void;
}

const QRCodeItem = memo(function QRCodeItem({ 
  id, 
  title, 
  url, 
  createdAt, 
  onClick 
}: QRCodeItemProps) {
  return (
    <div 
      className="qr-code-item"
      onClick={() => onClick(id)}
    >
      <h3>{title}</h3>
      <p>{url}</p>
      <span>{new Date(createdAt).toLocaleDateString()}</span>
    </div>
  );
}, (prevProps, nextProps) => {
  // Only re-render if id or title changes
  return prevProps.id === nextProps.id && 
         prevProps.title === nextProps.title;
});

export default QRCodeItem;

useMemo: Memoizing Expensive Calculations

useMemo memoizes the result of an expensive calculation and only recalculates when dependencies change.

Basic Usage

import { useMemo } from 'react';

function ExpensiveComponent({ items, filter }: { items: Item[]; filter: string }) {
  // Expensive calculation - only runs when items or filter changes
  const filteredItems = useMemo(() => {
    console.log('Filtering items...');
    return items.filter(item => 
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [items, filter]);
  
  return (
    <div>
      {filteredItems.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
}

When to Use useMemo

✅ Good Use Cases:

  • Expensive calculations (filtering, sorting, transformations)
  • Creating objects/arrays that are passed as props
  • Preventing unnecessary recalculations

❌ Avoid When:

  • Simple calculations
  • Premature optimization
  • Dependencies change frequently

Real-World Example: Content Processing

In ContentQR, we use useMemo for processing markdown content:

import { useMemo } from 'react';
import { marked } from 'marked';

interface ContentPreviewProps {
  markdown: string;
  options: ProcessingOptions;
}

function ContentPreview({ markdown, options }: ContentPreviewProps) {
  // Expensive: Parsing markdown and applying transformations
  const processedContent = useMemo(() => {
    console.log('Processing markdown...');
    
    const parsed = marked.parse(markdown);
    
    // Apply custom transformations based on options
    if (options.highlightCode) {
      // Code highlighting logic
    }
    
    if (options.processLinks) {
      // Link processing logic
    }
    
    return parsed;
  }, [markdown, options.highlightCode, options.processLinks]);
  
  return (
    <div 
      className="content-preview"
      dangerouslySetInnerHTML={{ __html: processedContent }}
    />
  );
}

Common Pitfall: Object Dependencies

Be careful with object dependencies:

// ❌ Bad: options object recreated every render
const processed = useMemo(() => {
  return processData(data, options);
}, [data, options]); // options is new object every time

// ✅ Good: Use specific properties
const processed = useMemo(() => {
  return processData(data, options.highlightCode, options.processLinks);
}, [data, options.highlightCode, options.processLinks]);

useCallback: Memoizing Functions

useCallback memoizes a function and returns the same function reference unless dependencies change.

Basic Usage

import { useCallback, useState } from 'react';

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // Memoized callback - same function reference unless count changes
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []); // Empty deps: function never changes
  
  return <ChildComponent onClick={handleClick} />;
}

const ChildComponent = memo(function ChildComponent({ 
  onClick 
}: { 
  onClick: () => void 
}) {
  console.log('ChildComponent rendered');
  return <button onClick={onClick}>Increment</button>;
});

When to Use useCallback

✅ Good Use Cases:

  • Functions passed to memoized components
  • Functions in dependency arrays (useEffect, useMemo)
  • Event handlers in frequently re-rendering components

❌ Avoid When:

  • Functions don't need stable references
  • Premature optimization
  • Simple functions that don't cause issues

Real-World Example: QR Code Generation

In ContentQR, we use useCallback for QR code generation handlers:

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

interface QRCodeListProps {
  qrCodes: QRCode[];
  onGenerate: (content: string) => Promise<void>;
}

function QRCodeList({ qrCodes, onGenerate }: QRCodeListProps) {
  const [isGenerating, setIsGenerating] = useState(false);
  
  // Memoized handler - stable reference for memoized child components
  const handleGenerate = useCallback(async (content: string) => {
    setIsGenerating(true);
    try {
      await onGenerate(content);
    } finally {
      setIsGenerating(false);
    }
  }, [onGenerate]);
  
  return (
    <div>
      {qrCodes.map(qrCode => (
        <QRCodeItem
          key={qrCode.id}
          qrCode={qrCode}
          onGenerate={handleGenerate}
        />
      ))}
    </div>
  );
}

// QRCodeItem is memoized, so it needs stable function references
const QRCodeItem = memo(function QRCodeItem({ 
  qrCode, 
  onGenerate 
}: QRCodeItemProps) {
  return (
    <div>
      <h3>{qrCode.title}</h3>
      <button onClick={() => onGenerate(qrCode.content)}>
        Regenerate
      </button>
    </div>
  );
});

Common Pitfall: Unnecessary Dependencies

// ❌ Bad: Including unnecessary dependencies
const handleClick = useCallback(() => {
  console.log('Clicked');
  setCount(count + 1); // Using count in closure
}, [count]); // Recreates function when count changes

// ✅ Good: Use functional update
const handleClick = useCallback(() => {
  console.log('Clicked');
  setCount(c => c + 1); // Functional update
}, []); // Stable function reference

Combining Optimization Techniques

In real applications, you'll often combine these techniques:

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

interface FilteredListProps {
  items: Item[];
  onItemClick: (id: string) => void;
}

function FilteredList({ items, onItemClick }: FilteredListProps) {
  const [filter, setFilter] = useState('');
  
  // Memoized filtered list
  const filteredItems = useMemo(() => {
    return items.filter(item =>
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [items, filter]);
  
  // Memoized click handler
  const handleItemClick = useCallback((id: string) => {
    onItemClick(id);
  }, [onItemClick]);
  
  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Filter items..."
      />
      {filteredItems.map(item => (
        <ListItem
          key={item.id}
          item={item}
          onClick={handleItemClick}
        />
      ))}
    </div>
  );
}

// Memoized list item
const ListItem = memo(function ListItem({ 
  item, 
  onClick 
}: { 
  item: Item; 
  onClick: (id: string) => void;
}) {
  return (
    <div onClick={() => onClick(item.id)}>
      {item.name}
    </div>
  );
});

Performance Measurement

Before optimizing, measure performance:

import { Profiler } from 'react';

function App() {
  const onRenderCallback = (
    id: string,
    phase: 'mount' | 'update',
    actualDuration: number
  ) => {
    console.log(`${id} ${phase}: ${actualDuration}ms`);
  };
  
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <YourComponent />
    </Profiler>
  );
}

React DevTools Profiler

Use React DevTools Profiler to identify performance bottlenecks:

  1. Record a performance profile
  2. Identify components with long render times
  3. Check for unnecessary re-renders
  4. Optimize based on actual data

Best Practices

1. Measure First, Optimize Later

Don't optimize prematurely. Measure performance first:

// ❌ Premature optimization
const Component = memo(function Component({ data }) {
  const processed = useMemo(() => data.map(x => x * 2), [data]);
  return <div>{processed}</div>;
});

// ✅ Measure first, then optimize if needed
function Component({ data }) {
  const processed = data.map(x => x * 2);
  return <div>{processed}</div>;
}

2. Use memo for Expensive Components

Only memoize components that are expensive to render:

// ✅ Good: Expensive component
const ExpensiveChart = memo(function ExpensiveChart({ data }) {
  // Complex chart rendering
  return <ComplexChart data={data} />;
});

// ❌ Unnecessary: Simple component
const SimpleText = memo(function SimpleText({ text }) {
  return <p>{text}</p>;
});

3. Be Careful with Dependencies

Always include all dependencies:

// ❌ Missing dependency
const filtered = useMemo(() => {
  return items.filter(item => item.category === category);
}, [items]); // Missing category

// ✅ Correct dependencies
const filtered = useMemo(() => {
  return items.filter(item => item.category === category);
}, [items, category]);

4. Consider Context Performance

When using Context, consider splitting contexts:

// ❌ Bad: Single context with all values
const AppContext = createContext({ user, theme, settings });

// ✅ Good: Split contexts
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const SettingsContext = createContext(settings);

Common Mistakes to Avoid

1. Overusing memo

// ❌ Unnecessary memo
const Button = memo(function Button({ label, onClick }) {
  return <button onClick={onClick}>{label}</button>;
});

2. Incorrect Dependencies

// ❌ Wrong dependencies
const result = useMemo(() => {
  return process(data, options);
}, [data]); // Missing options

// ✅ Correct dependencies
const result = useMemo(() => {
  return process(data, options);
}, [data, options]);

3. Creating Functions in Render

// ❌ Function recreated every render
function Component({ items }) {
  const handleClick = (id) => {
    // Handle click
  };
  
  return items.map(item => (
    <Item key={item.id} onClick={handleClick} />
  ));
}

// ✅ Memoized function
function Component({ items }) {
  const handleClick = useCallback((id) => {
    // Handle click
  }, []);
  
  return items.map(item => (
    <Item key={item.id} onClick={handleClick} />
  ));
}

Conclusion

React performance optimization with memo, useMemo, and useCallback is powerful when used correctly. The key is understanding when each tool is appropriate and measuring performance before optimizing. In ContentQR, these optimizations helped us handle complex QR code generation workflows efficiently, but we only applied them after identifying actual performance bottlenecks through profiling.

Key Takeaways:

  • Use memo to prevent component re-renders when props haven't changed
  • Use useMemo for expensive calculations that depend on specific values
  • Use useCallback to maintain stable function references for memoized components
  • Always measure performance before optimizing—don't optimize prematurely
  • Include all dependencies in dependency arrays to avoid bugs
  • Combine these techniques for maximum performance benefits in complex components

Next Steps:

  • Profile your React application using React DevTools Profiler
  • Identify components with performance issues
  • Apply memo, useMemo, or useCallback based on actual bottlenecks
  • Test optimizations to ensure they improve performance
  • Consider using Next.js App Router for built-in performance optimizations
  • Learn about Server Components vs Client Components to reduce client-side bundle size