Shortcuts
BlogAugust 20, 2025

Mohamed Elbarry
React Performance: When to Memoize and When to Lazy Load
We had a list that re-rendered on every keystroke in a filter and felt sluggish. The fix wasn’t more React.memo everywhere—it was memoizing the filtered list and keeping the list component from re-rendering when the parent’s callback reference changed. That taught me to measure first and only then add memoization where it shows up in the profiler. Here’s how I approach it. That came up on a search UI for Lumin Search—measure first, then optimize. I use React.memo when a component is expensive to render and its parent re-renders often with the same props. Memo has its own cost: it stores previous props and runs a shallow comparison every time the parent renders, so for cheap components (e.g. a simple button) that overhead can outweigh any gain. A rough rule of thumb: if the component doesn’t take at least a few milliseconds to render, memo usually isn’t worth it. Wrapping every component in memo adds noise and can hide real issues; I add it only after I see unnecessary work in React DevTools Profiler.
Tsx
// Parent re-renders often; this list is heavy and props are stable
function ExpensiveComponent({ data, onUpdate }) {
  return (
    <div>
      {data.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
}

const MemoizedExpensiveComponent = React.memo(ExpensiveComponent);
If the parent passes a new callback every render (e.g. an inline function), the memo doesn’t help until you stabilize the callback with useCallback. For expensive derivations (filter + sort, heavy computations) I use useMemo so we don’t recompute on every render when the inputs haven’t changed. For cheap operations I skip it.
Tsx
function ProductList({ products, filter, sortBy }) {
  const filteredAndSortedProducts = useMemo(() => {
    return products
      .filter(product =>
        product.name.toLowerCase().includes(filter.toLowerCase())
      )
      .sort((a, b) => {
        if (sortBy === 'name') return a.name.localeCompare(b.name);
        if (sortBy === 'price') return a.price - b.price;
        return 0;
      });
  }, [products, filter, sortBy]);

  return (
    <div>
      {filteredAndSortedProducts.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
When a memoized child receives a callback and the parent re-renders a lot, I wrap the callback in useCallback so the reference stays stable and the child doesn’t re-render unnecessarily. I only do this when the child is actually memoized and the callback is in the dependency list.
Tsx
function ParentComponent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([]);

  const memoizedHandleAddItem = useCallback((item) => {
    setItems(prev => [...prev, item]);
  }, []);

  return (
    <div>
      <Counter count={count} onIncrement={() => setCount((c) => c + 1)} />
      <ItemList items={items} onAddItem={memoizedHandleAddItem} />
    </div>
  );
}
Heavy screens (dashboards with charts, admin panels) I load with React.lazy and wrap in Suspense so the initial bundle stays smaller. The trade-off is an extra loading state; I use it for below-the-fold or route-level chunks.
Tsx
import { Suspense, lazy } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading chart...</div>}>
        <HeavyChart />
      </Suspense>
      <Suspense fallback={<div>Loading table...</div>}>
        <DataTable />
      </Suspense>
    </div>
  );
}
When we had thousands of rows, rendering them all was the bottleneck. I use a virtual list so only visible rows (plus a small buffer) are in the DOM. Libraries like react-window or TanStack Virtual are worth it; here’s the idea in minimal form:
Tsx
function VirtualList({ items, itemHeight, containerHeight, renderItem }) {
  const [scrollTop, setScrollTop] = useState(0);

  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(startIndex + visibleCount + 1, items.length);
  const visibleItems = items.slice(startIndex, endIndex);
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: items.length * itemHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((item, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>
              {renderItem(item)}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
I use React DevTools Profiler to record a session and see which components take the most time and why they re-rendered. That’s how I decide where to add memo or useMemo instead of guessing.
Tsx
import { Profiler } from 'react';

function onRenderCallback(id, phase, actualDuration, baseDuration, startTime, commitTime) {
  console.log('Component:', id);
  console.log('Phase:', phase);
  console.log('Actual duration:', actualDuration);
  console.log('Base duration:', baseDuration);
}

function App() {
  return (
    <Profiler id='App' onRender={onRenderCallback}>
      <YourComponent />
    </Profiler>
  );
}
Profile first; only add memoization where the profiler shows real cost. Keep state as close as possible to where it’s used to limit re-render scope. Use stable keys for list items and avoid creating new object/function references in render when passing them to memoized children. Lazy-load heavy route or feature chunks; use virtual scrolling for very long lists. One thing to try: run the profiler while doing the slow interaction and see which component dominates—then optimize that one. React docs on profiling and react-window are good next steps. Hope that helps. I'm currently looking for new challenges in the AI and Full Stack space. If you're building something interesting, let's chat.
Share this post: