2 min left
2 min read

React Performance Optimization Checklist

Michael Hospedales, software engineer and freelance developer in Miami, FL
Michael Hospedales

Software Engineer & Freelance Developer

Results you can expect: Reduce bundle size by 40%, improve initial page load by 60%, eliminate memory leaks, and create lightning-fast user experiences.

1. Performance Measurement & Audit

✅ Set up React DevTools Profiler

Before optimizing, measure. Install React DevTools and enable the Profiler to identify actual bottlenecks.

// Add this to your development environment
if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render')
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  })
}

✅ Track Core Web Vitals

Monitor LCP, FID, and CLS to understand real user performance impact.

// Custom hook for performance monitoring
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'

function useWebVitals() {
  useEffect(() => {
    getCLS(console.log)
    getFID(console.log)
    getFCP(console.log)
    getLCP(console.log)
    getTTFB(console.log)
  }, [])
}

✅ Analyze bundle size with Webpack Bundle Analyzer

Visualize what's making your bundle large.

npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer build/static/js/*.js

2. Bundle Size Optimization

✅ Implement route-based code splitting

Split your app by routes to load only necessary code.

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

// Lazy load route components
const Home = lazy(() => import('./pages/Home'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Profile = lazy(() => import('./pages/Profile'))

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </Suspense>
  )
}

✅ Split large components

Break down heavy components into smaller, loadable chunks.

// Instead of importing heavy components directly
const DataVisualization = lazy(() => import('./DataVisualization'))
const ChartLibrary = lazy(() => import('./ChartLibrary'))

function Dashboard() {
  const [showCharts, setShowCharts] = useState(false)

  return (
    <div>
      <h1>Dashboard</h1>
      {showCharts && (
        <Suspense fallback={<div>Loading charts...</div>}>
          <DataVisualization />
          <ChartLibrary />
        </Suspense>
      )}
    </div>
  )
}

✅ Optimize imports for tree shaking

Import only what you need from large libraries.

// ❌ Bad - imports entire library
import * as _ from 'lodash'
import { Button, TextField, Dialog } from '@mui/material'

// ✅ Good - imports only needed functions
import debounce from 'lodash/debounce'
import groupBy from 'lodash/groupBy'
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'

3. Component Rendering Optimization

✅ Use React.memo for pure components

Prevent unnecessary re-renders of components with stable props.

// ❌ Re-renders on every parent update
function ExpensiveComponent({ data, onUpdate }) {
  const processedData = expensiveCalculation(data)
  return <div>{processedData}</div>
}

// ✅ Only re-renders when props change
const ExpensiveComponent = React.memo(
  ({ data, onUpdate }) => {
    const processedData = expensiveCalculation(data)
    return <div>{processedData}</div>
  },
  (prevProps, nextProps) => {
    // Custom comparison function
    return prevProps.data.id === nextProps.data.id
  }
)

✅ Optimize expensive calculations with useMemo

Cache calculation results between renders.

function ProductList({ products, filters }) {
  // ✅ Memoized calculation
  const filteredProducts = useMemo(() => {
    return products
      .filter(product => matchesFilters(product, filters))
      .sort((a, b) => a.price - b.price)
  }, [products, filters])

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

✅ Stabilize function references with useCallback

Prevent child re-renders caused by new function references.

function TodoApp() {
  const [todos, setTodos] = useState([])

  // ✅ Stable function reference
  const handleAddTodo = useCallback(text => {
    setTodos(prev => [...prev, { id: Date.now(), text }])
  }, [])

  const handleToggleTodo = useCallback(id => {
    setTodos(prev =>
      prev.map(todo => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
    )
  }, [])

  return (
    <div>
      <AddTodoForm onAdd={handleAddTodo} />
      <TodoList todos={todos} onToggle={handleToggleTodo} />
    </div>
  )
}

4. Advanced Optimization Patterns

✅ Implement virtual scrolling for large lists

Handle thousands of items efficiently.

import { FixedSizeList as List } from 'react-window'

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <ItemComponent item={items[index]} />
    </div>
  )

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

✅ Optimize Context usage

Split contexts and memoize values to prevent cascade re-renders.

// ✅ Split into focused contexts
const UserContext = createContext()
const ThemeContext = createContext()
const DataContext = createContext()

function UserProvider({ children }) {
  const [user, setUser] = useState(null)

  // Memoize context value to prevent unnecessary re-renders
  const value = useMemo(
    () => ({
      user,
      setUser,
    }),
    [user]
  )

  return <UserContext.Provider value={value}>{children}</UserContext.Provider>
}

✅ Debounce expensive operations

Reduce frequency of expensive operations like API calls.

import { debounce } from 'lodash'

function SearchInput() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  // Debounced search function
  const debouncedSearch = useCallback(
    debounce(async searchQuery => {
      const results = await searchAPI(searchQuery)
      setResults(results)
    }, 300),
    []
  )

  useEffect(() => {
    if (query.length > 2) {
      debouncedSearch(query)
    }
  }, [query, debouncedSearch])

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

5. State Management Performance

✅ Normalize complex state shapes

Avoid deeply nested updates that cause wide re-renders.

// ✅ Normalized state structure
const [users, setUsers] = useState({
  1: { id: 1, name: 'John', postIds: [1] },
})
const [posts, setPosts] = useState({
  1: { id: 1, title: 'Post 1', userId: 1 },
})

✅ Use reducer for complex state logic

Centralize state updates and optimize with immer.

import { useImmerReducer } from 'use-immer'

function todosReducer(draft, action) {
  switch (action.type) {
    case 'ADD_TODO':
      draft.push({ id: Date.now(), text: action.text, completed: false })
      break
    case 'TOGGLE_TODO':
      const todo = draft.find(todo => todo.id === action.id)
      if (todo) todo.completed = !todo.completed
      break
    default:
      break
  }
}

function TodoApp() {
  const [todos, dispatch] = useImmerReducer(todosReducer, [])
  // Component implementation...
}

6. Network & Data Fetching Optimization

✅ Implement request deduplication

Prevent duplicate API calls for the same data.

// Custom hook with request deduplication
const requestCache = new Map()

function useApiData(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    // Check if request is already in progress
    if (requestCache.has(url)) {
      requestCache.get(url).then(setData)
      return
    }

    setLoading(true)
    const request = fetch(url).then(res => res.json())
    requestCache.set(url, request)

    request
      .then(data => {
        setData(data)
        setLoading(false)
      })
      .catch(() => {
        requestCache.delete(url)
        setLoading(false)
      })

    // Clean up cache after some time
    setTimeout(() => requestCache.delete(url), 30000)
  }, [url])

  return { data, loading }
}

✅ Implement intelligent caching

Cache API responses with automatic invalidation.

import { QueryClient, QueryClientProvider, useQuery } from 'react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 10 * 60 * 1000, // 10 minutes
      retry: 3,
      refetchOnWindowFocus: false,
    },
  },
})

function useProducts() {
  return useQuery('products', fetchProducts, {
    select: useCallback(data => {
      // Transform data to reduce re-renders
      return data.map(product => ({
        id: product.id,
        name: product.name,
        price: product.price,
      }))
    }, []),
  })
}

Performance Monitoring & Alerts

✅ Set up automated performance monitoring

Track performance regressions in production.

// Performance monitoring utility
function trackPerformance(metricName, value) {
  // Send to analytics service
  if (typeof window !== 'undefined' && window.gtag) {
    window.gtag('event', 'timing_complete', {
      name: metricName,
      value: Math.round(value),
    })
  }
}

// Custom hook for performance tracking
function usePerformanceTracking(componentName) {
  useEffect(() => {
    const startTime = performance.now()

    return () => {
      const endTime = performance.now()
      trackPerformance(`${componentName}_render_time`, endTime - startTime)
    }
  }, [componentName])
}

Common Performance Anti-patterns to Avoid

❌ Don't memoize everything

Memoization has overhead. Only memoize expensive calculations or frequently changing props.

// ❌ Unnecessary memoization
const simpleValue = useMemo(() => props.name, [props.name])

// ✅ Direct usage is more efficient
const simpleValue = props.name

❌ Don't create objects in render

Object creation in render causes unnecessary re-renders.

// ❌ New object on every render
function MyComponent({ items }) {
  return <List items={items} config={{ sortable: true, filterable: false }} />
}

// ✅ Stable object reference
const LIST_CONFIG = { sortable: true, filterable: false }

function MyComponent({ items }) {
  return <List items={items} config={LIST_CONFIG} />
}

Results You Can Expect

By implementing these optimization techniques, you can expect:

  • 40% reduction in bundle size through effective code splitting and tree shaking
  • 60% improvement in First Contentful Paint with optimized component architecture
  • Elimination of memory leaks that cause performance degradation
  • 70% reduction in API calls through intelligent caching strategies
  • Smooth 60fps animations even on low-end devices
  • Improved Core Web Vitals scores leading to better SEO rankings

Next Steps

  1. Audit your current app using React DevTools Profiler
  2. Start with the biggest impact items - usually bundle size and expensive renders
  3. Measure before and after each optimization
  4. Set up monitoring to catch performance regressions
  5. Make performance a team priority by establishing performance budgets

Remember: Premature optimization is the root of all evil, but strategic optimization based on real data can dramatically improve user experience.


This guide is based on real-world experience optimizing React applications serving millions of users. For personalized optimization help, contact me directly.