6 min read

React Performance Patterns That Actually Matter

Cutting through the noise — the React performance techniques that move the needle in real apps, and the ones that are mostly cargo cult.

February 14, 2026
ReactTypeScriptPerformanceOptimization

Most React Performance Advice Is Wrong

Or rather, it's right but applied to the wrong problems. I see teams wrapping every component in React.memo, throwing useMemo on every value, and useCallback on every function — then wondering why their app is still slow.

The actual performance problems in React apps are almost never "too many re-renders of leaf components". They're data fetching waterfalls, layout thrashing, and bundle size.

What Actually Matters

1. Data Fetching Waterfalls

The most common performance killer: sequential data fetching. Component A fetches user data, then renders Component B, which fetches permissions, which renders Component C that fetches the actual content.

Fix: fetch everything in parallel at the route level. With React Query:

function useDashboardData(userId: string) {
  return useQueries({
    queries: [
      { queryKey: ['user', userId], queryFn: () => fetchUser(userId) },
      { queryKey: ['permissions', userId], queryFn: () => fetchPermissions(userId) },
      { queryKey: ['content', userId], queryFn: () => fetchContent(userId) },
    ],
  });
}

Three requests fire in parallel. The dashboard renders when all three resolve instead of waiting sequentially.

2. Virtualization for Long Lists

If you're rendering more than 50-100 items in a list, virtualize. Only render what's visible:

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

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <Item data={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Going from rendering 500 DOM nodes to 10 visible ones is a 50x improvement. No amount of memoization beats this.

3. Code Splitting at Route Level

Ship only the JavaScript each route actually needs:

const BlogPage = lazy(() => import('./pages/BlogPage'));
const BlogPostPage = lazy(() => import('./pages/BlogPostPage'));

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/blog" element={<BlogPage />} />
        <Route path="/blog/:slug" element={<BlogPostPage />} />
      </Routes>
    </Suspense>
  );
}

For a portfolio with a blog, the blog reader code doesn't need to load on the homepage.

When Memoization Actually Helps

useMemo is worth it when: the computation is provably expensive (sort/filter over 1000+ items), and the deps rarely change.

const filteredPosts = useMemo(
  () =>
    blogPosts
      .filter((post) =>
        post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
        post.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
      )
      .filter((post) => activeCategory === 'All' || post.category === activeCategory),
  [blogPosts, searchQuery, activeCategory]
);

This is the right use. Filtering an array of objects on every keystroke with useMemo makes sense. Memoizing a string concatenation does not.

The Rule

Profile first. Use Chrome DevTools Performance tab or React DevTools Profiler. Fix what the profiler shows, not what you assume is slow.

Measure, don't guess.