Skip to main content
ARCHITECTVI

Software Engineer

Available for work

Open to opportunities
Frontend

React Performance Optimization

Techniques for identifying and fixing performance bottlenecks in React applications.

Feb 20, 2024·9 min read
ReactPerformanceOptimization

Most React performance problems share a root cause: unnecessary re-renders and excessive JavaScript on the main thread. This guide covers the profiling workflow and the targeted fixes that actually matter — skipping the premature micro-optimisations that slow down development without improving UX.

Profile Before You Optimise

Never guess at performance problems. Open React DevTools → Profiler, record an interaction, and look at which components render, how often, and why. The "why did this render?" feature is invaluable.

Lighthouse scores measure perceived performance (LCP, FID, CLS), not React render frequency. A component that re-renders 100 times might not show up in Lighthouse at all — use the React Profiler for component-level analysis.

React.memo: When It Helps and When It Doesn't

React.memo prevents a component from re-rendering when its props are referentially equal to the previous render. But it only helps when: (1) the component is expensive to render, and (2) its parent re-renders frequently with the same prop values.

typescript
// ❌ memo is useless here — inline objects are new references every render
function Parent() {
  return <ExpensiveChild config={{ theme: 'dark' }} />;
}

// ✅ Stable reference — memo can now bail out
const config = { theme: 'dark' }; // outside component, or useMemo

function Parent() {
  return <ExpensiveChild config={config} />;
}

const ExpensiveChild = React.memo(({ config }: { config: Config }) => {
  // Only re-renders when config reference changes
});

useMemo and useCallback: The 80/20 Rule

Both hooks have a cost: they allocate closures and run comparison logic every render. Only use them when: (1) you're passing a value to a memo'd component, (2) the computation is genuinely expensive (> 1ms), or (3) the value is a dependency of another hook.

typescript
// ✅ useMemo for expensive derivation
const sortedItems = useMemo(
  () => items.slice().sort((a, b) => a.score - b.score),
  [items] // only re-sort when items changes
);

// ✅ useCallback for stable event handler passed to memo'd child
const handleSelect = useCallback(
  (id: string) => dispatch({ type: 'SELECT', id }),
  [dispatch] // dispatch from useReducer is stable
);

// ❌ Don't bother memoising cheap values
const label = useMemo(() => `Hello, ${name}`, [name]); // wasteful

Virtualisation for Long Lists

Rendering 10,000 DOM nodes is slow regardless of how well your React code is written. Virtual lists render only the visible subset — typically 20-50 items — and recycle DOM nodes as the user scrolls. Use @tanstack/react-virtual or react-window.

typescript
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualiser = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72, // estimated row height in px
  });

  return (
    <div ref={parentRef} style={{ overflow: 'auto', height: '600px' }}>
      <div style={{ height: virtualiser.getTotalSize() }}>
        {virtualiser.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{ transform: `translateY(${virtualItem.start}px)` }}
          >
            <ItemRow item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Code Splitting and Lazy Loading

The fastest code is code that never loads. Use next/dynamic (Next.js) or React.lazy for routes and heavy components that aren't needed on initial load.

typescript
// next/dynamic with no SSR for client-only heavy libraries
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
  ssr: false,
  loading: () => <EditorSkeleton />,
});

// Only loaded when the user opens the modal
const HeavyModal = dynamic(() => import('@/components/HeavyModal'));

Concurrent Features: useTransition and useDeferredValue

React 18's concurrent features let you mark state updates as non-urgent, keeping the UI responsive during expensive renders. Use useTransition for navigation/filter updates, and useDeferredValue to debounce derived expensive renders.

typescript
const [isPending, startTransition] = useTransition();

function handleSearch(query: string) {
  setInputValue(query); // urgent — update input immediately
  startTransition(() => {
    setSearchQuery(query); // non-urgent — can be interrupted
  });
}

// In the component that renders search results
const deferredQuery = useDeferredValue(searchQuery);
// Results render with the old query while new results compute

Written by

Md. Saniuzzaman Robin

Full-Stack Software Engineer

More Articles →