Back to blog
Frontend Engineeringadvanced

React Performance Optimization: Profiling, Memoization & Concurrent Features

Identify and fix real React performance bottlenecks — using the Profiler, fixing unnecessary re-renders, virtualization for large lists, Web Vitals, and React 18 concurrent features.

LearnixoApril 13, 20269 min read
ReactPerformanceProfilerReact.memoVirtualizationWeb VitalsConcurrent React
Share:š•

Measure Before You Optimize

The most common React performance mistake is applying optimization hooks everywhere without profiling first. useMemo and useCallback add overhead — they only help when the saved computation or re-render cost outweighs the hook cost.

Profile first. Optimize second. Measure again.

React DevTools Profiler

The Profiler records which components rendered, why, and for how long.

  1. Install React DevTools browser extension
  2. Open DevTools → Profiler tab
  3. Click Record → Interact with your app → Stop recording
  4. Inspect the flame chart
Flame chart reading:
- Wide bars = components that took long to render
- Yellow bars = components that rendered but didn't need to
- Gray bars = components that were bailed out (skipped re-render)

Programmatic Profiling

TSX
import { Profiler } from "react";

function onRenderCallback(
  id,           // component name passed to Profiler
  phase,        // "mount" or "update"
  actualDuration,  // Time spent rendering this update
  baseDuration,    // Estimated time without memoization
  startTime,
  commitTime
) {
  if (actualDuration > 16) {  // Flag renders > 1 frame (16ms)
    console.warn(`Slow render in ${id}: ${actualDuration.toFixed(2)}ms (${phase})`);
    // Send to analytics/monitoring
    analytics.track("slow_render", { component: id, duration: actualDuration });
  }
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <Dashboard />
    </Profiler>
  );
}

Understanding Re-Renders

A component re-renders when:

  1. Its own state changes
  2. Its parent re-renders (even if props didn't change)
  3. A context it consumes changes
TSX
// Demonstration: why parent re-renders cause child re-renders
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>
        Parent Count: {count}
      </button>
      {/* Child re-renders every time Parent does — even though it has no props */}
      <Child />
    </div>
  );
}

function Child() {
  console.log("Child rendered");
  return <div>I'm a child</div>;
}

// Fix: wrap in React.memo
const Child = React.memo(function Child() {
  console.log("Child rendered");
  return <div>I'm a child</div>;
});

The Re-render Audit Pattern

TSX
// Temporary debugging: log every render with why
function useWhyDidYouUpdate(name, props) {
  const previousProps = useRef({});

  useEffect(() => {
    if (previousProps.current) {
      const changedProps = Object.entries(props).reduce((acc, [key, value]) => {
        if (previousProps.current[key] !== value) {
          acc[key] = {
            from: previousProps.current[key],
            to: value,
          };
        }
        return acc;
      }, {});

      if (Object.keys(changedProps).length > 0) {
        console.log(`[${name}] Re-rendered due to:`, changedProps);
      }
    }
    previousProps.current = props;
  });
}

// Usage: wrap any component temporarily
function ProductCard({ product, onAddToCart, isSelected }) {
  useWhyDidYouUpdate("ProductCard", { product, onAddToCart, isSelected });
  // ...
}

Fixing Unnecessary Re-Renders

Pattern 1: Unstable Object/Array Props

TSX
// āŒ New object reference every render — breaks React.memo
function App() {
  return (
    <MemoizedChart
      config={{ color: "blue", size: "large" }}  // New object every render!
      data={[1, 2, 3]}                            // New array every render!
    />
  );
}

// āœ… Stable references
const CHART_CONFIG = { color: "blue", size: "large" };
const DEFAULT_DATA = [1, 2, 3];

function App() {
  return <MemoizedChart config={CHART_CONFIG} data={DEFAULT_DATA} />;
}

// Or useMemo for dynamic values
function App({ userId }) {
  const config = useMemo(
    () => ({ color: "blue", size: "large", userId }),
    [userId]
  );
  return <MemoizedChart config={config} />;
}

Pattern 2: Context Causing Global Re-renders

TSX
// āŒ Any user state change re-renders EVERYTHING consuming this context
const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [theme, setTheme] = useState("light");

  return (
    <AppContext.Provider value={{ user, notifications, theme, setUser, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

// āœ… Split by update frequency
function AppProviders({ children }) {
  return (
    <ThemeProvider>       {/* Changes rarely */}
      <AuthProvider>      {/* Changes on login/logout */}
        <NotificationProvider>  {/* Changes frequently */}
          {children}
        </NotificationProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}

Pattern 3: State Too High in the Tree

TSX
// āŒ Filter state in parent causes full list re-render
function ProductPage() {
  const [filter, setFilter] = useState("");
  const [products, setProducts] = useState([]);

  return (
    <>
      <FilterBar filter={filter} onChange={setFilter} />
      <ProductGrid products={products} filter={filter} />
    </>
  );
}

// āœ… Move local state down — only FilterBar re-renders on input
function ProductPage() {
  const [products] = useState([]);
  return (
    <>
      <FilterBar products={products} />  {/* Manages its own filter state */}
    </>
  );
}

function FilterBar({ products }) {
  const [filter, setFilter] = useState("");
  const filtered = useMemo(
    () => products.filter((p) => p.name.includes(filter)),
    [products, filter]
  );
  return (
    <>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />
      <ProductGrid products={filtered} />
    </>
  );
}

Virtualization: Rendering Large Lists

Rendering 10,000 rows causes 10,000 DOM nodes. Virtualization renders only visible rows.

Bash
npm install @tanstack/react-virtual
TSX
import { useVirtualizer } from "@tanstack/react-virtual";

function VirtualizedList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72,  // Estimated row height in px
    overscan: 5,             // Render 5 extra rows above/below viewport
  });

  return (
    <div
      ref={parentRef}
      style={{ height: "600px", overflow: "auto" }}
    >
      {/* Total scroll height = number of items Ɨ estimated row height */}
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.index}
            data-index={virtualRow.index}
            ref={virtualizer.measureElement}  // Measures actual height
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            <UserRow user={items[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

// Variable height items
const UserRow = React.memo(function UserRow({ user }) {
  return (
    <div className="user-row">
      <img src={user.avatar} alt={user.name} />
      <div>
        <strong>{user.name}</strong>
        <p>{user.email}</p>
      </div>
    </div>
  );
});

Web Vitals: What Actually Matters for Users

LCP (Largest Contentful Paint) — Loading performance
  Target: < 2.5s
  Caused by: large images, slow server response, render-blocking JS

FID (First Input Delay) / INP (Interaction to Next Paint) — Responsiveness
  Target: < 100ms (FID), < 200ms (INP)
  Caused by: long tasks blocking the main thread

CLS (Cumulative Layout Shift) — Visual stability
  Target: < 0.1
  Caused by: images without dimensions, dynamically injected content
TSX
import { onCLS, onFID, onLCP, onINP, onFCP, onTTFB } from "web-vitals";

function sendToAnalytics({ name, value, id, rating }) {
  // rating is "good", "needs-improvement", or "poor"
  analytics.track("web_vital", {
    metric: name,
    value: Math.round(name === "CLS" ? value * 1000 : value),
    rating,
    id,
    page: window.location.pathname,
  });
}

// Measure and report
onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onLCP(sendToAnalytics);
onINP(sendToAnalytics);

// Fixing CLS: always specify image dimensions
// āŒ Layout shift as image loads
<img src={product.image} alt={product.name} />

// āœ… Reserve space with aspect ratio
<div style={{ aspectRatio: "4/3" }}>
  <img
    src={product.image}
    alt={product.name}
    width={400}
    height={300}
    style={{ width: "100%", height: "auto" }}
  />
</div>

React 18 Concurrent Features

useTransition — Keep UI Responsive During Heavy Updates

TSX
function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    setQuery(value);  // Urgent — updates input immediately

    startTransition(() => {
      // Non-urgent — can be interrupted by more urgent updates
      // React may pause this if the user types again
      setResults(expensiveSearch(value, allProducts));
    });
  };

  return (
    <div>
      <input
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search 10,000 products..."
      />
      {isPending && <LinearProgress />}  {/* Show while transition is pending */}
      <SearchResults results={results} query={query} />
    </div>
  );
}

useDeferredValue — Debouncing Without setTimeout

TSX
function FilteredList({ filter }) {
  // Defers updates to filter — UI stays responsive
  const deferredFilter = useDeferredValue(filter);
  const isStale = deferredFilter !== filter;

  const filtered = useMemo(
    () => items.filter((item) => item.name.toLowerCase().includes(deferredFilter.toLowerCase())),
    [deferredFilter]
  );

  return (
    // Show stale state dimmed while deferred value catches up
    <div style={{ opacity: isStale ? 0.5 : 1 }}>
      {filtered.map((item) => (
        <ListItem key={item.id} item={item} />
      ))}
    </div>
  );
}

Image and Bundle Optimization

TSX
// Lazy load images below the fold
function ProductImage({ src, alt }) {
  return (
    <img
      src={src}
      alt={alt}
      loading="lazy"          // Native lazy loading
      decoding="async"        // Non-blocking decode
      fetchPriority="low"     // Hint to browser
    />
  );
}

// Priority image (above the fold — hero, LCP candidate)
function HeroImage({ src, alt }) {
  return (
    <img
      src={src}
      alt={alt}
      fetchPriority="high"    // Prioritize this load
      loading="eager"
    />
  );
}

// Code split aggressively by route
const routes = [
  { path: "/", component: lazy(() => import("./pages/Home")) },
  { path: "/products", component: lazy(() => import("./pages/Products")) },
  { path: "/checkout", component: lazy(() => import("./pages/Checkout")) },
  // Admin panel: separate chunk, loaded only for admins
  { path: "/admin", component: lazy(() => import("./pages/Admin")) },
];

Memoization Decision Tree

Is the component rendering unnecessarily?
  → Yes: Can I move state down or lift content up first?
    → Yes: Do that — no memoization needed
    → No: Use React.memo

Are you passing a function as prop to a memoized child?
  → Yes: Use useCallback

Are you computing an expensive value?
  → Yes: Is it > 1ms? Does it run often?
    → Yes to both: Use useMemo
    → No: Skip it

Is the same computation in multiple components?
  → Yes: Use createSelector (Reselect) or move to a custom hook with useMemo

Interview Questions Answered

Q: What is the difference between useMemo and React.memo?

React.memo memoizes a component — wraps it so it skips re-rendering if props haven't changed. useMemo memoizes a computed value inside a component — avoids recomputing an expensive derivation on every render. They're complementary: React.memo for components, useMemo for values inside components.

Q: What is reconciliation and how does React's diffing algorithm work?

Reconciliation is the process React uses to update the DOM efficiently. The diffing algorithm works by: (1) comparing trees level by level top-down, (2) assuming elements of different types produce different trees (full replace), (3) using key props to match list items across renders. This makes the algorithm O(n) rather than O(n³) for generic tree diffing.

Q: What is the purpose of the key prop during reconciliation?

Keys help React match elements in a list between renders. Without keys, React matches by position — inserting an element at the start would shift all indexes and force full re-renders. With stable keys, React knows exactly which element is which, and only re-renders what actually changed.

Q: What is concurrent rendering in React 18?

Concurrent rendering lets React prepare multiple versions of the UI simultaneously. The render phase becomes interruptible — React can pause an in-progress render to handle a higher-priority update (like typing in an input), then resume or discard the paused work. This enables useTransition, useDeferredValue, and Suspense for data fetching.

Enjoyed this article?

Explore the Frontend Engineering learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.