React performance problems fall into three categories with different fixes: rendering too often (components re-rendering when their output wouldn't actually change), rendering too much (huge lists or trees mounted all at once), and loading too much upfront (a bundle that ships code the user won't touch for minutes, if ever). This module covers the concrete pattern for each category, in the order you should reach for them — profile first, then apply the cheapest fix that addresses the measured bottleneck.


React.memo for Component-Level Memoization

By default, when a parent re-renders, every child re-renders too, regardless of whether its own props changed. React.memo wraps a component in a shallow-equality check on props — if every prop is Object.is-equal to the previous render's, React skips re-rendering that component and reuses its last output.

src/components/CartLineItem.tsxtypescript
import { memo } from 'react'

interface CartLineItemProps {
  productId: string
  name: string
  price: number
  quantity: number
  onQuantityChange: (productId: string, quantity: number) => void
}

// Wrapping in memo only pays off if onQuantityChange has a stable
// reference across parent re-renders (see useCallback below) — otherwise
// every prop "changes" every time and the shallow check always fails.
const CartLineItem = memo(function CartLineItem({
  productId,
  name,
  price,
  quantity,
  onQuantityChange
}: CartLineItemProps) {
  return (
    <div className="d-flex justify-content-between align-items-center py-2 border-bottom">
      <span>{name}</span>
      <input
        type="number"
        className="form-control form-control-sm"
        style={{ width: 70 }}
        min={1}
        value={quantity}
        onChange={(e) => onQuantityChange(productId, Number(e.target.value))}
      />
      <span className="fw-bold">${(price * quantity).toFixed(2)}</span>
    </div>
  )
})

export default CartLineItem
src/components/CartList.tsx (stable callback via useCallback)typescript
import { useCallback } from 'react'
import { useAppDispatch, useAppSelector } from '../app/hooks'
import { quantityChanged, selectCartItems } from '../features/cart/cartSlice'
import CartLineItem from './CartLineItem'

function CartList() {
  const dispatch = useAppDispatch()
  const items = useAppSelector(selectCartItems)

  // Stable reference across renders — dispatch from useAppDispatch never
  // changes, so this callback is created once and reused forever. Without
  // useCallback here, CartLineItem's memo check would fail on every
  // CartList render, defeating the memoization entirely.
  const handleQuantityChange = useCallback(
    (productId: string, quantity: number) => {
      dispatch(quantityChanged({ productId, quantity }))
    },
    [dispatch]
  )

  return (
    <div>
      {items.map((item) => (
        <CartLineItem
          key={item.productId}
          productId={item.productId}
          name={item.name}
          price={item.price}
          quantity={item.quantity}
          onQuantityChange={handleQuantityChange}
        />
      ))}
    </div>
  )
}

export default CartList

Splitting Context to Limit Consumer Re-renders

A single Context holding both frequently-changing and rarely-changing values forces every consumer to re-render on any change to either. Splitting into two Contexts lets consumers subscribe only to what they actually need.

src/context/splitting-example.tsxtypescript
// BEFORE: one context mixes rarely-changing user data with a frequently
// updating field. A component that only reads `user` still re-renders
// every time `lastActivityTimestamp` ticks.
interface CombinedContextValue {
  user: { id: string; name: string } | null
  lastActivityTimestamp: number
}

// AFTER: two contexts, subscribed to independently. A component reading
// only UserContext is now immune to activity-timestamp updates entirely.
const UserContext = createContextPlaceholder<{ id: string; name: string } | null>()
const ActivityContext = createContextPlaceholder<number>()

// (createContextPlaceholder stands in for React's real createContext here —
// the point is structural: separate volatile and stable data into
// separate Providers, not the exact API call.)
function createContextPlaceholder<T>() {
  return null as unknown as T
}

Every component imported at the top of your entry file ships in the initial bundle, whether the user ever navigates to it or not. React.lazy combined with Suspense defers a component's code (and its dependencies) to a separate chunk that loads only when it's actually rendered, which is exactly how Vite's Rollup build splits output into multiple files automatically at each lazy() boundary.

src/App.tsx (route-level code splitting)typescript
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'

// Each lazy() call becomes its own chunk in the production build — the
// AdminDashboard bundle (charting libraries, heavy tables) never downloads
// for a customer who only ever visits the storefront.
const ProductCatalog = lazy(() => import('./components/ProductCatalog'))
const AdminDashboard = lazy(() => import('./components/AdminDashboard'))
const OrderHistory = lazy(() => import('./components/OrderHistory'))

function PageFallback() {
  return (
    <div className="d-flex justify-content-center py-5">
      <div className="spinner-border text-primary" role="status">
        <span className="visually-hidden">Loading…</span>
      </div>
    </div>
  )
}

function App() {
  return (
    <BrowserRouter>
      {/* Suspense catches the pending state of any lazy component rendered
          beneath it and shows the fallback until that chunk finishes
          downloading and evaluating. */}
      <Suspense fallback={<PageFallback />}>
        <Routes>
          <Route path="/" element={<ProductCatalog />} />
          <Route path="/orders" element={<OrderHistory />} />
          <Route path="/admin" element={<AdminDashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

export default App

Rendering 5,000 DOM nodes for a 5,000-row product table is expensive regardless of memoization — the browser still has to lay out, paint, and hold all of them, and almost none are visible at once. Virtualization renders only the rows currently in (or near) the viewport, recycling a small, fixed pool of DOM nodes as the user scrolls.

terminalbash
npm install @tanstack/react-virtual
src/components/VirtualizedProductTable.tsxtypescript
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'

interface Product {
  id: string
  name: string
  price: number
}

interface VirtualizedProductTableProps {
  products: Product[]
}

function VirtualizedProductTable({ products }: VirtualizedProductTableProps) {
  const scrollContainerRef = useRef<HTMLDivElement>(null)

  // Only rows within the visible range (plus a small overscan buffer) are
  // ever mounted — scrolling through 5,000 items costs the same as
  // scrolling through 20, because the DOM node count stays constant.
  const rowVirtualizer = useVirtualizer({
    count: products.length,
    getScrollElement: () => scrollContainerRef.current,
    estimateSize: () => 48, // px per row
    overscan: 8
  })

  return (
    <div ref={scrollContainerRef} className="border rounded" style={{ height: 480, overflow: 'auto' }}>
      <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const product = products[virtualRow.index]
          return (
            <div
              key={product.id}
              className="d-flex justify-content-between align-items-center px-3 border-bottom"
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: virtualRow.size,
                transform: `translateY(${virtualRow.start}px)`
              }}
            >
              <span>{product.name}</span>
              <span className="fw-bold">${product.price.toFixed(2)}</span>
            </div>
          )
        })}
      </div>
    </div>
  )
}

export default VirtualizedProductTable

useAppSelector re-runs its selector on every dispatched action, and the component re-renders if the returned value is not === to the previous result. A selector that returns a new array or object literal every call (.filter(...), .map(...)) breaks this — even if the underlying data is unchanged, the new reference triggers a re-render every single time. createSelector from Redux Toolkit (re-exported from Reselect) memoizes the output based on its input selectors, only recomputing when an actual input changed.

src/features/products/productsSelectors.tstypescript
import { createSelector } from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'

const selectAllProducts = (state: RootState) => state.products.items
const selectCategoryFilter = (state: RootState) => state.products.categoryFilter

// WITHOUT createSelector, this .filter() call would run on every dispatch
// to the store — including unrelated actions like cart updates — and
// return a brand-new array reference every time, forcing every subscribed
// component to re-render even when the filtered result is identical.
export const selectFilteredProducts = createSelector(
  [selectAllProducts, selectCategoryFilter],
  (products, categoryFilter) => {
    if (categoryFilter === 'all') return products
    return products.filter((product) => product.category === categoryFilter)
  }
)

// createSelector also composes — build derived selectors from other
// memoized selectors without recomputing shared work.
export const selectFilteredProductCount = createSelector(
  [selectFilteredProducts],
  (filteredProducts) => filteredProducts.length
)

Storing a nested array of order objects, each with a nested array of line items, each referencing a full nested product object, makes updating any single product an O(n) deep-search-and-replace, and duplicates the same product data across every order that references it. Normalizing — storing entities in id-keyed maps and referencing by id — turns updates into O(1) lookups and eliminates duplication. RTK's createEntityAdapter generates the boilerplate for this shape automatically.

src/features/products/productsSlice.ts (normalized with createEntityAdapter)typescript
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'

interface Product {
  id: string
  name: string
  price: number
  category: string
}

// createEntityAdapter generates normalized CRUD reducers and a set of
// baseline selectors (selectAll, selectById, selectIds) for a collection
// keyed by id — replacing hand-written array find/filter/splice logic
// that's easy to get subtly wrong under concurrent updates.
const productsAdapter = createEntityAdapter<Product>()

export const fetchProducts = createAsyncThunk('products/fetchProducts', async () => {
  const response = await fetch('/api/products')
  return (await response.json()) as Product[]
})

const productsSlice = createSlice({
  name: 'products',
  // getInitialState() seeds { ids: [], entities: {} } — the normalized
  // shape — plus any extra fields passed here.
  initialState: productsAdapter.getInitialState({
    status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed'
  }),
  reducers: {
    // upsertOne / removeOne / updateOne are O(1) against the entities map —
    // no array scan required, unlike a hand-rolled `.find()` + splice.
    productUpdated: productsAdapter.updateOne
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchProducts.pending, (state) => {
        state.status = 'loading'
      })
      .addCase(fetchProducts.fulfilled, (state, action) => {
        state.status = 'succeeded'
        productsAdapter.setAll(state, action.payload)
      })
  }
})

export const { productUpdated } = productsSlice.actions

// Generated selectors — selectAll returns a stable-shape array from the
// normalized map; selectById is an O(1) lookup instead of an O(n) find.
export const {
  selectAll: selectAllProducts,
  selectById: selectProductById,
  selectIds: selectProductIds
} = productsAdapter.getSelectors<RootState>((state) => state.products)

export default productsSlice.reducer

The React DevTools browser extension's Profiler tab records a flame graph of every render during an interaction: which components rendered, how long each took, and — critically — why each one rendered (props changed, state changed, a parent re-rendered, or a Context value changed). This 'why did this render' data is what turns a guess into a targeted fix: it tells you whether a slow list is slow because of an expensive render function (candidate for useMemo inside the component) or because it re-renders too often (candidate for React.memo or a Context split).

src/components/ProfiledSection.tsx (React.Profiler API, for automated regression checks)typescript
import { Profiler, type ProfilerOnRenderCallback } from 'react'
import ProductCatalog from './ProductCatalog'

// The Profiler component's onRender callback fires after every commit
// within its subtree, reporting actual render duration. Useful for wiring
// automated performance regression checks (e.g. failing a test if
// actualDuration exceeds a budget) beyond manual DevTools inspection.
const handleRender: ProfilerOnRenderCallback = (
  id,
  phase, // 'mount' | 'update' | 'nested-update'
  actualDuration // ms spent rendering this commit
) => {
  if (actualDuration > 16) {
    // Longer than a single 60fps frame budget — worth investigating.
    console.warn(`[perf] ${id} (${phase}) took ${actualDuration.toFixed(1)}ms`)
  }
}

function ProfiledSection() {
  return (
    <Profiler id="ProductCatalog" onRender={handleRender}>
      <ProductCatalog />
    </Profiler>
  )
}

export default ProfiledSection

SymptomPatternCost of applying itWhen to skip it
Child re-renders when its props didn't meaningfully changeReact.memo + useCallback/useMemo for the props it receivesShallow-comparison cost every render; extra memory for cached callbacksComponent is cheap to render, or props genuinely change almost every render anyway
Every consumer of a Context re-renders on any field changingSplit into multiple Contexts by change frequencyMore Provider nesting, more files to wire togetherContext value changes rarely across the whole app (e.g. theme only)
Initial bundle is large; user waits for code they may never useReact.lazy + Suspense at route or feature boundariesA loading-state flash on first navigation to that chunkThe component is small, or is needed immediately on first paint
Thousands of DOM nodes for a long list, scroll is jankyList virtualization (@tanstack/react-virtual or similar)Absolute positioning complexity; degraded native find-in-page for offscreen rowsList has at most a few hundred items
useAppSelector triggers re-renders even though derived data is unchangedcreateSelector for any selector that filters/maps/transformsOne extra memoization layer per derived selectorSelector is a direct, un-transformed field read (state.cart.items)
Updating one entity requires scanning/rebuilding a nested arraycreateEntityAdapter for normalized, id-keyed stateSlightly more setup than a plain array; generated selectors to learnCollection is small and rarely updated (e.g. a fixed list of countries)

Module 6 Takeaways

  • Profile with React DevTools before applying any optimization — 'why did this render' data tells you whether the fix is memoization, a Context split, or something else entirely.
  • React.memo only helps when paired with stable prop references (useCallback/useMemo upstream) — otherwise the shallow-equality check always fails and the wrapper adds pure overhead.
  • React.lazy + Suspense defers code to separate bundle chunks, cutting initial load weight for routes or features not needed on first paint.
  • List virtualization keeps the mounted DOM node count constant regardless of list length — reach for it only once profiling confirms list rendering is the actual bottleneck.
  • createSelector prevents derived Redux selectors (filter/map/transform) from returning a new reference on every call, which otherwise forces needless re-renders on every dispatch.
  • createEntityAdapter normalizes collection state into an id-keyed map, turning array scans into O(1) lookups and eliminating duplicated nested entity data.