MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Performance Optimization Techniques

Comprehensive guide to React performance optimization: memoization, virtualization, code splitting, profiling, and production best practices.

ReactPerformanceFrontend

By MinhVo

Introduction

React applications can become slow as they grow. Unnecessary re-renders, large bundle sizes, and inefficient data structures all contribute to poor performance. This guide covers the most effective optimization techniques β€” from basic memoization to advanced profiling β€” with practical examples you can apply immediately.

Performance optimization in React follows a simple principle: measure first, optimize second. The React DevTools Profiler is your best friend β€” it shows exactly which components re-render, why they re-render, and how long they take. Don't optimize blindly; use data to guide your decisions.

Performance optimization

Understanding Re-Renders

When Does React Re-Render?

A component re-renders when:

  1. Its state changes (useState, useReducer)
  2. Its parent re-renders (unless memoized)
  3. A context it consumes changes
// This component re-renders when parent re-renders
function ExpensiveComponent({ data }) {
  // Heavy computation here
  const processed = processData(data);
  return <div>{processed}</div>;
}
 
// Parent re-renders β†’ ExpensiveComponent re-renders
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveComponent data={staticData} /> {/* Re-renders! */}
    </div>
  );
}

Profiling with React DevTools

// Enable Profiler in development
import { Profiler } from 'react';
 
function onRenderCallback(id, phase, actualDuration) {
  console.log(`${id} ${phase}: ${actualDuration}ms`);
}
 
function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <Dashboard />
    </Profiler>
  );
}

Memoization Techniques

React.memo

React.memo prevents re-renders when props haven't changed:

// Without memo β€” re-renders on every parent render
function ExpensiveList({ items, onSelect }) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}
 
// With memo β€” only re-renders when items or onSelect change
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

Custom Comparison Function

const UserCard = React.memo(
  function UserCard({ user, onClick }) {
    return (
      <div onClick={() => onClick(user.id)}>
        <img src={user.avatar} alt={user.name} />
        <span>{user.name}</span>
      </div>
    );
  },
  (prevProps, nextProps) => {
    // Only re-render if user data changed
    return prevProps.user.id === nextProps.user.id &&
           prevProps.user.name === nextProps.user.name;
  }
);

useMemo for Expensive Computations

function DataGrid({ data, sortKey, filterText }) {
  // Without useMemo β€” recalculates on every render
  // const sorted = data.sort((a, b) => a[sortKey] - b[sortKey]);
  // const filtered = sorted.filter(item => item.name.includes(filterText));
 
  // With useMemo β€” only recalculates when dependencies change
  const processedData = useMemo(() => {
    console.log('Processing data...');
    const sorted = [...data].sort((a, b) => a[sortKey] - b[sortKey]);
    return sorted.filter(item =>
      item.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }, [data, sortKey, filterText]);
 
  return (
    <ul>
      {processedData.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

useCallback for Stable References

function TodoList() {
  const [todos, setTodos] = useState([]);
 
  // Without useCallback β€” new function reference on every render
  // const handleDelete = (id) => setTodos(todos.filter(t => t.id !== id));
 
  // With useCallback β€” stable reference
  const handleDelete = useCallback((id) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  }, []); // No dependencies β€” never changes
 
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onDelete={handleDelete} />
      ))}
    </ul>
  );
}
 
// TodoItem wrapped in React.memo won't re-render when parent re-renders
const TodoItem = React.memo(function TodoItem({ todo, onDelete }) {
  console.log(`Rendering ${todo.id}`);
  return (
    <li>
      {todo.text}
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </li>
  );
});

Code splitting

Code Splitting

Route-Based Splitting

import { lazy, Suspense } from 'react';
 
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Profile = lazy(() => import('./pages/Profile'));
 
function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </Suspense>
  );
}

Component-Based Splitting

// Heavy component that's only needed conditionally
const RichTextEditor = lazy(() => import('./RichTextEditor'));
 
function BlogPost({ isEditing }) {
  return (
    <div>
      <h1>{post.title}</h1>
      {isEditing ? (
        <Suspense fallback={<div>Loading editor...</div>}>
          <RichTextEditor content={post.content} />
        </Suspense>
      ) : (
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      )}
    </div>
  );
}

Dynamic Import for Libraries

// Don't import heavy libraries at the top level
// import { Chart } from 'chart.js'; // BAD: always loaded
 
// Import dynamically when needed
async function renderChart(data) {
  const { Chart } = await import('chart.js');
  const chart = new Chart(canvasRef.current, {
    type: 'bar',
    data: data,
  });
  return chart;
}

Virtualization for Large Lists

Using react-window

import { FixedSizeList } from 'react-window';
 
function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <span>{items[index].name}</span>
      <span>{items[index].email}</span>
    </div>
  );
 
  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={items.length}
      itemSize={50}
    >
      {Row}
    </FixedSizeList>
  );
}

Using react-virtuoso

import { Virtuoso } from 'react-virtuoso';
 
function UserList({ users }) {
  return (
    <Virtuoso
      style={{ height: 600 }}
      totalCount={users.length}
      itemContent={(index) => (
        <div className="user-row">
          <img src={users[index].avatar} alt="" />
          <span>{users[index].name}</span>
        </div>
      )}
    />
  );
}

Virtual scrolling

State Management Optimization

Keep State Local

// BAD: Global state for local UI
const [isModalOpen, setIsModalOpen] = useState(false); // In parent
 
// GOOD: Keep UI state local to component
function Modal() {
  const [isOpen, setIsOpen] = useState(false);
  // ...
}

Avoid Derived State

// BAD: Storing derived data in state
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]); // Redundant!
 
useEffect(() => {
  setFilteredItems(items.filter(item => item.active));
}, [items, filter]);
 
// GOOD: Compute derived data during render
const [items, setItems] = useState([]);
const filteredItems = useMemo(
  () => items.filter(item => item.active),
  [items]
);

Use useReducer for Complex State

function todosReducer(state, action) {
  switch (action.type) {
    case 'add':
      return [...state, action.todo];
    case 'toggle':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, done: !todo.done } : todo
      );
    case 'delete':
      return state.filter(todo => todo.id !== action.id);
    default:
      return state;
  }
}
 
function TodoApp() {
  const [todos, dispatch] = useReducer(todosReducer, []);
  // ...
}

DOM Optimization

Avoid Layout Thrashing

// BAD: Reading and writing in alternation
function updateElements() {
  elements.forEach(el => {
    const height = el.offsetHeight; // Read (forces layout)
    el.style.height = height * 2 + 'px'; // Write
  });
}
 
// GOOD: Batch reads and writes
function updateElements() {
  const heights = elements.map(el => el.offsetHeight); // All reads
  elements.forEach((el, i) => {
    el.style.height = heights[i] * 2 + 'px'; // All writes
  });
}

Use CSS for Animations

// BAD: JavaScript animations cause re-renders
function AnimatedBox() {
  const [position, setPosition] = useState(0);
  useEffect(() => {
    const interval = setInterval(() => {
      setPosition(p => p + 1); // Re-renders every frame
    }, 16);
    return () => clearInterval(interval);
  }, []);
  return <div style={{ transform: `translateX(${position}px)` }} />;
}
 
// GOOD: CSS animations don't cause re-renders
function AnimatedBox() {
  return <div className="slide-in" />;
}
 
// CSS
// .slide-in {
//   animation: slide 1s ease-in-out;
// }
// @keyframes slide {
//   from { transform: translateX(0); }
//   to { transform: translateX(100px); }
// }

Image Optimization

// Lazy load images
function OptimizedImage({ src, alt }) {
  const imgRef = useRef();
  const [isVisible, setIsVisible] = useState(false);
 
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );
 
    if (imgRef.current) observer.observe(imgRef.current);
    return () => observer.disconnect();
  }, []);
 
  return (
    <img
      ref={imgRef}
      src={isVisible ? src : ''}
      alt={alt}
      loading="lazy"
      decoding="async"
    />
  );
}

Bundle Analysis and Tree Shaking

# Analyze bundle size with webpack
npx webpack-bundle-analyzer build/static/js/*.map
 
# Or with Vite
npx vite-bundle-visualizer
 
# Check for duplicate dependencies
npx depcheck
npx npm-check

Common bundle issues to look for:

  • Duplicate dependencies: Multiple versions of the same library bundled (e.g., two versions of lodash)
  • Large libraries: Replace heavy libraries with lighter alternatives (Moment.js β†’ date-fns or dayjs, Lodash β†’ lodash-es or native methods)
  • Unused code: Code that wasn't tree-shaken due to side effects or incorrect import patterns
  • Polyfills: Unnecessary polyfills targeting browsers you don't support
// BAD: Imports entire lodash library (~70KB gzipped)
import _ from 'lodash';
_.debounce(fn, 300);
 
// GOOD: Import only what you need (~1KB gzipped)
import debounce from 'lodash/debounce';
debounce(fn, 300);
 
// EVEN BETTER: Use native or lightweight alternatives
// For simple cases, write your own debounce
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

For Vite projects, ensure build.rollupOptions.output.manualChunks splits vendor code from application code so that vendor changes don't invalidate the entire bundle cache.

Server Components and Data Fetching

React Server Components fundamentally change the performance equation by moving data fetching to the server. Instead of client-side fetching with loading states and waterfalls, server components fetch data directly and send rendered HTML to the client.

// Server Component: zero client-side JavaScript for data fetching
async function ProductPage({ id }: { id: string }) {
  // These run in parallel on the server
  const [product, reviews, recommendations] = await Promise.all([
    db.products.findUnique({ where: { id } }),
    db.reviews.findMany({ where: { productId: id } }),
    getRecommendations(id)
  ]);
 
  return (
    <div>
      <ProductDetails product={product} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewsList reviews={reviews} />
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendationsGrid items={recommendations} />
      </Suspense>
    </div>
  );
}

The performance benefit is dramatic: no client-side JavaScript for data fetching, no loading spinners for initial content, and no request waterfalls. The server fetches all data in a single round trip to the database, then streams the rendered HTML to the client.

Avoiding Data Fetching Waterfalls

A common performance anti-pattern is sequential data fetching in nested components:

// BAD: Sequential waterfalls
async function Page() {
  const user = await getUser();        // 200ms
  const posts = await getPosts();      // 200ms
  const comments = await getComments(); // 200ms
  // Total: 600ms
}
 
// GOOD: Parallel fetching
async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments()
  ]);
  // Total: 200ms (longest individual request)
}

Best Practices

React performance

  1. Measure before optimizing β€” Use React DevTools Profiler
  2. Memo expensive components β€” React.memo for components with stable props
  3. Memoize computations β€” useMemo for expensive calculations
  4. Stabilize callbacks β€” useCallback when passing to memoized children
  5. Code split by route β€” lazy() and Suspense for route-based splitting
  6. Virtualize long lists β€” react-window or react-virtuoso
  7. Keep state local β€” Don't lift state higher than necessary
  8. Use CSS for animations β€” Avoid JavaScript-driven animations

Common Pitfalls

PitfallImpactSolution
Memoizing everythingComplexity overheadOnly memo expensive components
Missing dependency arraysStale closures or infinite loopsAlways specify dependencies
Large component treesSlow reconciliationSplit into smaller components
Inline objects/functionsBreaks memoizationUse useMemo/useCallback
Not profiling firstOptimizing the wrong thingUse React DevTools Profiler

Performance Optimization Checklist

CategoryTechniqueImpactComplexity
RenderingReact.memoHighLow
RenderinguseMemo/useCallbackMediumLow
RenderingVirtualizationHighMedium
BundleCode splittingHighLow
BundleTree shakingMediumLow
BundleDynamic importsMediumLow
StateLocal stateMediumLow
StateState colocationMediumLow
StateuseReducer for complex stateMediumMedium
NetworkData cachingHighMedium
NetworkPrefetchingMediumLow
NetworkLazy loading imagesMediumLow
CSSCSS animationsMediumLow
CSSwill-change propertyLowLow
ImagesResponsive imagesMediumLow
ImagesWebP/AVIF formatMediumLow
ImagesLazy loadingMediumLow

React 18 Concurrent Features

React 18 introduced several performance improvements through concurrent features. Automatic batching groups multiple state updates into a single re-render, reducing the number of DOM updates. The startTransition API lets you mark updates as non-urgent, allowing React to keep the interface responsive during heavy computations. useDeferredValue lets you defer updating a part of the UI, showing stale content while new content loads in the background. Suspense boundaries enable streaming server-side rendering, sending HTML to the client as components become ready. These concurrent features work together to keep your application responsive even under heavy load, but they require understanding the priority model to use effectively.

import { useTransition, useDeferredValue, useState } from 'react';
 
function SearchPage({ items }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
 
  // Urgent: update input immediately
  const handleChange = (e) => {
    setQuery(e.target.value);
 
    // Non-urgent: defer the expensive filtering
    startTransition(() => {
      setFilteredResults(filterItems(items, e.target.value));
    });
  };
 
  // Alternative: useDeferredValue defers the value itself
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
 
  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <div style={{ opacity: isStale ? 0.7 : 1 }}>
        <Results query={deferredQuery} items={items} />
      </div>
    </div>
  );
}

useTransition is ideal when you control the state update and want to mark it as low priority. useDeferredValue is better when the value comes from a prop or an external source you don't control. Both prevent the UI from blocking during expensive re-renders.

Streaming SSR with Suspense

React 18's streaming SSR sends HTML to the client progressively as components resolve:

// Server: stream components as they become ready
import { renderToPipeableStream } from 'react-dom/server';
 
app.get('/', (req, res) => {
  const { pipe } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/client.js'],
    onShellReady() {
      // Start streaming the shell
      res.statusCode = 200;
      pipe(res);
    },
    onError(err) {
      console.error(err);
      res.statusCode = 500;
    },
  });
});
 
// Client: Suspense boundaries show fallbacks while loading
function App() {
  return (
    <Layout>
      <Suspense fallback={<HeaderSkeleton />}>
        <Header />
      </Suspense>
      <Suspense fallback={<ContentSkeleton />}>
        <SlowContent />
      </Suspense>
    </Layout>
  );
}

The server sends the shell immediately, then streams the content of each Suspense boundary as it resolves. Users see meaningful content faster because the browser doesn't wait for the entire page to render on the server.

Production Monitoring

Monitor React performance in production using Real User Monitoring tools. Track component render counts, interaction latency, and bundle load times. Use the Web Vitals library to measure Core Web Vitals and correlate them with React-specific metrics. Set up alerts for performance regressions so your team can address issues before they affect users. Profile production builds using React DevTools to identify slow components in real-world scenarios.

// Track React-specific metrics in production
import { onCLS, onFID, onLCP } from 'web-vitals';
 
function reportMetric(metric) {
  console.log(`${metric.name}: ${metric.value}`);
  // Send to your analytics service
  analytics.track('web-vital', {
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
    id: metric.id
  });
}
 
onCLS(reportMetric);
onFID(reportMetric);
onLCP(reportMetric);

React Compiler: The Future of Performance

The React Compiler (formerly React Forget) represents the next evolution of React performance. Instead of manually applying useMemo, useCallback, and memo, the compiler automatically memoizes components and values at build time. This eliminates the most common source of performance bugsβ€”forgetting to memoize or over-memoizing.

The compiler analyzes your component's data flow and determines the minimal set of re-renders needed. It understands when values are stable (derived from props or state that haven't changed) and automatically wraps them in memoization. This means developers can write simple, straightforward code and get optimal performance without manual optimization.

Combining memoization, code splitting, virtualization, server components, and compiler-driven optimization creates a comprehensive performance strategy that addresses rendering efficiency, bundle size optimization, and DOM manipulation overhead simultaneously. When these techniques are applied methodically based on profiling data, React applications can handle complex UIs with thousands of interactive elements while maintaining smooth, responsive user experiences.

Conclusion

React performance optimization is about understanding how React renders and applying the right technique for each bottleneck. Memoization prevents unnecessary re-renders, code splitting reduces bundle size, virtualization handles large lists, and proper state management keeps updates efficient. React 18's concurrent features add a new dimension by allowing you to prioritize updates, keeping the interface responsive even during heavy computations.

The most important takeaway: measure first. Use the React DevTools Profiler to identify actual bottlenecks before optimizing. Premature optimization adds complexity without guaranteed benefit. A component that re-renders unnecessarily but completes in 0.1ms is not worth memoizing β€” focus your effort on the components that actually impact user experience.

Key takeaways:

  1. Profile first β€” Use React DevTools to find actual bottlenecks
  2. React.memo β€” Prevents re-renders when props haven't changed
  3. useMemo/useCallback β€” Stabilize computations and references
  4. Code splitting β€” lazy() + Suspense for route-based splitting
  5. Virtualization β€” react-window for lists with thousands of items
  6. Keep state local β€” Don't lift state higher than necessary
  7. CSS animations β€” Avoid JavaScript-driven animations
  8. Analyze bundles β€” Find and remove unnecessary dependencies
  9. Use React 18 concurrent features β€” startTransition and useDeferredValue for non-urgent updates
  10. Monitor production performance β€” Track render counts and interaction latency with RUM tools
  11. Leverage concurrent features β€” Use useTransition and useDeferredValue to keep the UI responsive during heavy updates