All Articles
React

React Performance Optimization: Hooks, Memoization, and Best Practices

Boost your React app performance with proven optimization techniques

14-18 minutes

React Performance Optimization: Hooks, Memoization, and Best Practices

Performance is crucial for user experience. A slow React application can frustrate users and hurt your business. In this comprehensive guide, we'll explore proven techniques to optimize your React applications.

Why Performance Matters

  • User Experience: Faster apps lead to happier users
  • 📱 Mobile Performance: Critical for users on slower devices
  • 💰 Conversion Rates: Speed directly impacts business metrics
  • 🔍 SEO: Google considers page speed in rankings

Understanding React Rendering

Before optimizing, understand how React renders:

  1. State/Props Change → Triggers re-render
  2. Component Re-renders → All child components re-render
  3. Virtual DOM Diff → React calculates changes
  4. DOM Update → Only changed elements update

React.memo: Prevent Unnecessary Re-renders

React.memo is a higher-order component that memoizes your component:

import React from 'react';

// Without memo - re-renders on every parent render
function ExpensiveComponent({ data }) {
  console.log('Rendering ExpensiveComponent');
  return <div>{data.map(item => <div key={item.id}>{item.name}</div>)}</div>;
}

// With memo - only re-renders when props change
const MemoizedComponent = React.memo(ExpensiveComponent);

// Custom comparison function
const MemoizedWithCustom = React.memo(
  ExpensiveComponent,
  (prevProps, nextProps) => {
    return prevProps.data.length === nextProps.data.length;
  }
);

When to Use React.memo

Use when:

  • Component renders often with same props
  • Component is expensive to render
  • Component receives complex props

Don't use when:

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

useMemo: Memoize Expensive Calculations

useMemo caches the result of expensive computations:

import { useMemo } from 'react';

function DataTable({ data, filter }) {
  // ❌ Bad - recalculates on every render
  const filteredData = data.filter(item => item.category === filter);

  // ✅ Good - only recalculates when dependencies change
  const filteredData = useMemo(() => {
    console.log('Filtering data...');
    return data.filter(item => item.category === filter);
  }, [data, filter]);

  return (
    <table>
      {filteredData.map(item => (
        <tr key={item.id}>
          <td>{item.name}</td>
        </tr>
      ))}
    </table>
  );
}

Real-World Example

function ProductList({ products, searchTerm, sortBy }) {
  const processedProducts = useMemo(() => {
    // Expensive operations
    let result = products.filter(p =>
      p.name.toLowerCase().includes(searchTerm.toLowerCase())
    );

    result.sort((a, b) => {
      if (sortBy === 'price') return a.price - b.price;
      if (sortBy === 'name') return a.name.localeCompare(b.name);
      return 0;
    });

    return result;
  }, [products, searchTerm, sortBy]);

  return (
    <div>
      {processedProducts.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

useCallback: Memoize Functions

useCallback prevents function recreation on every render:

import { useCallback, useState } from 'react';

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

  // ❌ Bad - new function on every render
  const handleClick = () => {
    console.log('Clicked');
  };

  // ✅ Good - same function reference
  const handleClick = useCallback(() => {
    console.log('Clicked');
  }, []);

  // With dependencies
  const addItem = useCallback((item) => {
    setItems(prev => [...prev, item]);
  }, []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedChild onClick={handleClick} />
    </div>
  );
}

const MemoizedChild = React.memo(({ onClick }) => {
  console.log('Child rendered');
  return <button onClick={onClick}>Click Me</button>;
});

useCallback vs useMemo

// useCallback - memoizes the function itself
const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

// useMemo - memoizes the return value
const memoizedValue = useMemo(() => {
  return computeExpensiveValue(a, b);
}, [a, b]);

// These are equivalent:
const memoizedCallback = useCallback(fn, deps);
const memoizedCallback = useMemo(() => fn, deps);

Code Splitting with React.lazy

Split your code to load only what's needed:

import React, { Suspense, lazy } from 'react';

// ❌ Bad - loads everything upfront
import HeavyComponent from './HeavyComponent';
import Dashboard from './Dashboard';
import Settings from './Settings';

// ✅ Good - loads on demand
const HeavyComponent = lazy(() => import('./HeavyComponent'));
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Route-based Code Splitting

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/contact" element={<Contact />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Virtualization for Long Lists

Use virtualization for rendering large lists:

import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

Debouncing and Throttling

Control how often functions execute:

import { useState, useCallback } from 'react';
import { debounce } from 'lodash';

function SearchComponent() {
  const [results, setResults] = useState([]);

  // Debounce search - waits for user to stop typing
  const handleSearch = useCallback(
    debounce(async (query) => {
      const data = await fetch(`/api/search?q=${query}`);
      setResults(await data.json());
    }, 300),
    []
  );

  return (
    <input
      type="text"
      onChange={(e) => handleSearch(e.target.value)}
      placeholder="Search..."
    />
  );
}

Image Optimization

// Use next/image for automatic optimization
import Image from 'next/image';

function ProductImage({ src, alt }) {
  return (
    <Image
      src={src}
      alt={alt}
      width={500}
      height={500}
      loading="lazy"
      placeholder="blur"
    />
  );
}

// Or use native lazy loading
function LazyImage({ src, alt }) {
  return <img src={src} alt={alt} loading="lazy" />;
}

Profiling Your Application

Use React DevTools Profiler to identify bottlenecks:

import { Profiler } from 'react';

function onRenderCallback(
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) {
  console.log(`${id} took ${actualDuration}ms to render`);
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <YourComponent />
    </Profiler>
  );
}

Best Practices Checklist

State Management

  • ✅ Keep state as local as possible
  • ✅ Use Context API wisely (can cause re-renders)
  • ✅ Consider state management libraries for complex apps

Component Design

  • ✅ Break down large components
  • ✅ Use composition over inheritance
  • ✅ Avoid inline object/array creation in props

Rendering

  • ✅ Use keys properly in lists
  • ✅ Avoid index as key for dynamic lists
  • ✅ Implement error boundaries

Data Fetching

  • ✅ Cache API responses
  • ✅ Use SWR or React Query
  • ✅ Implement pagination/infinite scroll

Common Anti-Patterns

1. Premature Optimization

// ❌ Don't optimize everything
const MemoizedEverything = React.memo(({ text }) => <div>{text}</div>);

// ✅ Only optimize when needed
function SimpleComponent({ text }) {
  return <div>{text}</div>;
}

2. Incorrect Dependencies

// ❌ Missing dependencies
useEffect(() => {
  fetchData(userId);
}, []); // userId should be in deps

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

3. Creating Objects in Render

// ❌ New object on every render
<Component style={{ margin: 10 }} />

// ✅ Define outside or use useMemo
const style = { margin: 10 };
<Component style={style} />

Performance Monitoring

// Custom hook for performance monitoring
function usePerformance(componentName) {
  useEffect(() => {
    const start = performance.now();

    return () => {
      const end = performance.now();
      console.log(`${componentName} took ${end - start}ms`);
    };
  });
}

function MyComponent() {
  usePerformance('MyComponent');
  // component logic
}

Conclusion

React performance optimization is about finding the right balance. Not every component needs memoization, and premature optimization can make code harder to maintain.

Key Takeaways:

  1. Profile first, optimize second
  2. Use React.memo for expensive components
  3. Leverage useMemo and useCallback wisely
  4. Implement code splitting for large apps
  5. Virtualize long lists
  6. Monitor and measure performance

Start with these techniques and measure the impact. Your users will thank you!

Resources

Enjoyed this article?

Explore more deep dives into architecture, performance, and modern .NET.

View All Articles