Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Performance Optimization

React is fast by default, but complex applications can slow down. This chapter covers techniques to identify bottlenecks and optimize your React apps. A critical mindset shift: Optimization in React is not about making every component fast — it is about making slow components faster. The Virtual DOM already prevents unnecessary real DOM updates. Most performance problems come from components re-rendering too often or doing too much work during a render. The tools in this chapter help you identify and fix those specific bottlenecks.

When to Optimize

Premature optimization is the root of all evil. Always measure first! Only optimize when you:
  1. Have actual performance issues users notice
  2. Have profiled and identified the bottleneck
  3. Can measure the improvement
Most React apps don’t need heavy optimization.

Understanding Re-renders

Every time state or props change, React re-renders components. Understanding this is key to optimization.
┌─────────────────────────────────────────────────────────────┐
│                  Component Re-render Flow                   │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  State/Props Change                                         │
│         │                                                   │
│         ▼                                                   │
│  Component function runs again                              │
│         │                                                   │
│         ▼                                                   │
│  New JSX created                                            │
│         │                                                   │
│         ▼                                                   │
│  React diffs Virtual DOM                                    │
│         │                                                   │
│         ▼                                                   │
│  Only actual changes update real DOM                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

What Causes Re-renders?

  1. Component’s state changes (useState, useReducer)
  2. Parent component re-renders — this is the biggest surprise for beginners. When a parent re-renders, all of its children re-render by default, even if their props have not changed.
  3. Context value changes — every component that calls useContext re-renders when the provider’s value changes.
  4. Custom hook state changes — if a hook uses useState internally, the component calling that hook re-renders when that state changes.
Important clarification: Re-rendering does not mean the real DOM is updated. React’s diffing algorithm compares the new Virtual DOM with the old one and only applies actual DOM changes for the differences. Re-renders are usually fast. The problem arises when a re-render triggers expensive computations (filtering large arrays, complex calculations) or when thousands of components re-render unnecessarily.

React DevTools Profiler

The Profiler helps identify what’s rendering and why.

Using the Profiler

  1. Install React DevTools browser extension
  2. Open DevTools → Profiler tab
  3. Click “Record” and interact with your app
  4. Analyze the flame graph

What to Look For

  • Frequent re-renders of the same component
  • Slow components (long render times)
  • Cascade re-renders (parent update causing all children to update)

React.memo - Prevent Unnecessary Re-renders

React.memo is a higher-order component that memoizes functional components.

Basic Usage

// Without memo - re-renders whenever parent renders
function ExpensiveList({ items }) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

// With memo - only re-renders when `items` changes
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
});

Custom Comparison

const UserCard = React.memo(
  function UserCard({ user, onSelect }) {
    return (
      <div onClick={() => onSelect(user.id)}>
        <h3>{user.name}</h3>
        <p>{user.email}</p>
      </div>
    );
  },
  // Custom comparison function
  (prevProps, nextProps) => {
    return prevProps.user.id === nextProps.user.id &&
           prevProps.user.name === nextProps.user.name;
  }
);
React.memo does a shallow comparison by default. If you pass new object/array references every render, memoization won’t work!
// ❌ Bad - new array on every render
<MemoizedComponent items={data.filter(x => x.active)} />

// ✅ Good - memoize the filtered data
const activeItems = useMemo(() => data.filter(x => x.active), [data]);
<MemoizedComponent items={activeItems} />

useMemo - Memoize Expensive Calculations

useMemo caches the result of expensive computations so they only run when their inputs change. Analogy: useMemo is like a spreadsheet cell with a formula. The cell only recalculates when the cells it references change — not every time you look at the spreadsheet. useCallback works the same way but for functions instead of values — it is literally useMemo(() => fn, deps) under the hood.
function ProductList({ products, filter }) {
  // ❌ Without useMemo - runs on every render, even if
  // products and filter have not changed. For 10 items this
  // is fine, but for 10,000 items with complex sorting, it hurts.
  const filteredProducts = products
    .filter(p => p.category === filter)
    .sort((a, b) => a.price - b.price);

  // ✅ With useMemo - only recalculates when dependencies change.
  // React stores the previous result and returns it if
  // [products, filter] are the same as last render.
  const filteredProducts = useMemo(() => {
    console.log('Filtering and sorting...');
    return products
      .filter(p => p.category === filter)
      .sort((a, b) => a.price - b.price);
  }, [products, filter]);

  return (/* render list */);
}

When to Use useMemo

Use useMemoDon’t Use useMemo
Expensive calculationsSimple math/string operations
Large array operationsSmall arrays (fewer than 100 items)
Object creation for memoized childrenObjects used once
Derived data from props/stateValues passed to native elements

useCallback - Memoize Functions

useCallback caches function references to prevent unnecessary re-renders of child components.
function Parent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // ❌ New function created every render
  const handleClick = () => {
    console.log('Clicked!');
  };

  // ✅ Same function reference unless dependencies change
  const handleClick = useCallback(() => {
    console.log('Clicked!');
  }, []);

  // With dependencies
  const handleSubmit = useCallback(() => {
    submitForm(text);
  }, [text]);

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <Counter count={count} />
      <MemoizedButton onClick={handleClick} />
    </div>
  );
}

const MemoizedButton = React.memo(function Button({ onClick }) {
  console.log('Button rendered');
  return <button onClick={onClick}>Click me</button>;
});
useCallback + React.memo = Optimization Power CombouseCallback alone doesn’t prevent re-renders. It only helps when:
  1. Passed to a React.memo-wrapped child component
  2. Used as a dependency in other hooks

Code Splitting with React.lazy

Split your bundle so users only download what they need.
import { lazy, Suspense } from 'react';

// Lazy load components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Named Exports with Lazy

// For named exports, create an intermediate module
// utils/lazyImport.js
export function lazyImport(factory, name) {
  return lazy(() => 
    factory().then(module => ({ default: module[name] }))
  );
}

// Usage
const Dashboard = lazyImport(
  () => import('./pages/Dashboard'),
  'Dashboard'
);

Virtualization for Long Lists

Only render visible items in long lists.

Using react-window

npm install react-window
import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={400}
      width="100%"
      itemCount={items.length}
      itemSize={50}
    >
      {Row}
    </FixedSizeList>
  );
}

Variable Size List

import { VariableSizeList } from 'react-window';

function VariableList({ items }) {
  const getItemSize = (index) => {
    return items[index].type === 'header' ? 80 : 50;
  };

  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].content}
    </div>
  );

  return (
    <VariableSizeList
      height={400}
      width="100%"
      itemCount={items.length}
      itemSize={getItemSize}
    >
      {Row}
    </VariableSizeList>
  );
}

Debouncing and Throttling

Limit how often functions are called.

Debouncing (Wait for pause)

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Usage
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]);

  return (
    <input 
      value={query} 
      onChange={e => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

Throttling (Limit frequency)

function useThrottle(value, limit) {
  const [throttledValue, setThrottledValue] = useState(value);
  const lastRan = useRef(Date.now());

  useEffect(() => {
    const handler = setTimeout(() => {
      if (Date.now() - lastRan.current >= limit) {
        setThrottledValue(value);
        lastRan.current = Date.now();
      }
    }, limit - (Date.now() - lastRan.current));

    return () => clearTimeout(handler);
  }, [value, limit]);

  return throttledValue;
}

Image Optimization

Lazy Loading Images

function LazyImage({ src, alt, ...props }) {
  return (
    <img 
      src={src} 
      alt={alt} 
      loading="lazy"  // Native lazy loading
      {...props}
    />
  );
}

Progressive Loading with Blur

function ProgressiveImage({ lowQualitySrc, highQualitySrc, alt }) {
  const [loaded, setLoaded] = useState(false);

  return (
    <div className="progressive-image">
      <img 
        src={lowQualitySrc}
        alt={alt}
        className={`low-quality ${loaded ? 'hidden' : ''}`}
        style={{ filter: 'blur(10px)' }}
      />
      <img 
        src={highQualitySrc}
        alt={alt}
        className={`high-quality ${loaded ? '' : 'hidden'}`}
        onLoad={() => setLoaded(true)}
      />
    </div>
  );
}

Web Vitals

Key metrics for user experience:
MetricDescriptionTarget
LCP (Largest Contentful Paint)Main content visible< 2.5s
FID (First Input Delay)Time to interactive< 100ms
CLS (Cumulative Layout Shift)Visual stability< 0.1
TTFB (Time to First Byte)Server response time< 600ms

Measuring Web Vitals

import { getCLS, getFID, getLCP } from 'web-vitals';

getCLS(console.log);
getFID(console.log);
getLCP(console.log);

// Or send to analytics
function sendToAnalytics({ name, delta, id }) {
  analytics.send({
    metric: name,
    value: delta,
    id
  });
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);

Production Build Optimization

Vite Production Build

npm run build
npm run preview  # Preview production build locally

Analyze Bundle Size

# Install bundle analyzer
npm install -D rollup-plugin-visualizer

# vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({ open: true })
  ]
});

Environment-Specific Code

// Remove development-only code in production
if (import.meta.env.DEV) {
  console.log('Debug info:', data);
}

Deployment

npm install -g vercel
vercel
Or connect your GitHub repo at vercel.com.

Netlify

Create netlify.toml:
[build]
  command = "npm run build"
  publish = "dist"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Docker

# Build stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Optimization Pitfalls

Pitfall 1 — Memoizing everything “just in case”: Wrapping every component in React.memo and every function in useCallback is not free. Each memoization adds overhead: React must store the previous props/value and run a comparison on every render. For simple components that render quickly, the comparison itself can cost more than just re-rendering. Only memoize when you have evidence of a performance problem.Pitfall 2 — Stale closures in useCallback: When you use useCallback with an empty dependency array, the function captures the initial values of any variables from the component scope. If those values change, the memoized function still sees the old ones.
function SearchForm({ onSearch }) {
  const [query, setQuery] = useState('');

  // BUG: query is always '' inside this callback because
  // the empty dependency array means it never recaptures query.
  const handleSubmit = useCallback(() => {
    onSearch(query);
  }, []); // Missing dependency: query

  // FIX: include query in the dependency array
  const handleSubmit = useCallback(() => {
    onSearch(query);
  }, [query, onSearch]);
}
Pitfall 3 — Inline objects/arrays defeating React.memo: Even if a child component is wrapped in React.memo, passing an object or array literal as a prop creates a new reference every render, so the shallow comparison always fails.
// The style object is recreated every render -- memo is useless
<MemoizedBox style={{ color: 'red' }} />

// Fix: define outside the component or use useMemo
const boxStyle = useMemo(() => ({ color: 'red' }), []);
<MemoizedBox style={boxStyle} />
Pitfall 4 — Forgetting that useCallback needs React.memo on the child: useCallback alone does not prevent re-renders. It stabilizes a function reference so that a React.memo-wrapped child can skip re-rendering. Without React.memo on the child, useCallback has zero effect.

🎯 Practice Exercises

// Before: Slow component
function ProductList({ products, onSelect }) {
  const [filter, setFilter] = useState('');

  // Recalculates on every keystroke
  const filtered = products.filter(p => 
    p.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
      />
      {filtered.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onSelect={() => onSelect(product)}
        />
      ))}
    </div>
  );
}

// After: Optimized
const ProductCard = React.memo(function ProductCard({ product, onSelect }) {
  return (
    <div onClick={onSelect}>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
});

function ProductList({ products, onSelect }) {
  const [filter, setFilter] = useState('');
  const debouncedFilter = useDebounce(filter, 300);

  const filtered = useMemo(() => 
    products.filter(p => 
      p.name.toLowerCase().includes(debouncedFilter.toLowerCase())
    ),
    [products, debouncedFilter]
  );

  const handleSelect = useCallback((product) => {
    onSelect(product);
  }, [onSelect]);

  return (
    <div>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
      />
      {filtered.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onSelect={() => handleSelect(product)}
        />
      ))}
    </div>
  );
}

Summary

TechniqueUse Case
React.memoPrevent child re-renders when props unchanged
useMemoCache expensive calculations
useCallbackCache function references
React.lazyCode split by route/component
VirtualizationLong lists (100+ items)
DebouncingSearch inputs, resize handlers
Lazy loadingImages below the fold
Bundle analysisIdentify large dependencies
Web VitalsMeasure real user experience

Next Steps

In the next chapter, you’ll learn about Authentication & Protected Routes — securing your React applications!

Interview Deep-Dive

Strong Answer: These three tools form an optimization chain, and using one without the others often provides zero benefit.React.memo wraps a component and skips re-rendering when its props have not changed (by shallow comparison). But it only works if the props are actually the same by reference. If the parent passes a new object or function reference every render, React.memo compares the old and new references, finds them different, and re-renders anyway.useMemo fixes the object reference problem: const config = useMemo(() => ({ theme: 'dark' }), []) returns the same object reference across renders as long as dependencies are stable.useCallback fixes the function reference problem: const handleClick = useCallback(() => doSomething(id), [id]) returns the same function reference as long as id has not changed.When you need all three together: a parent renders a memoized child that receives both an object prop and a callback prop. Without useMemo on the object and useCallback on the callback, React.memo on the child is useless because it receives new references every render.When each alone is insufficient: React.memo alone fails when props contain unstable references. useMemo alone is pointless if the child component is not memoized. useCallback alone is pointless if the child is not wrapped in React.memo. The anti-pattern I see most often: developers wrap every function in useCallback “for performance” without memoizing the child component. The useCallback adds overhead with zero benefit.Follow-up: How do you measure whether memoization is actually helping? What tools do you use?React DevTools Profiler is the primary tool. Record an interaction, then examine the flame graph. Components highlighted in gray did not render (memoization worked). The “Why did this render?” feature tells you exactly which props changed, confirming whether your memoization is preventing re-renders.For precise measurement, use the React.Profiler component with an onRender callback that logs actualDuration. Compare before and after memoization across multiple interactions.The rule: if you cannot measure a difference, do not memoize. The overhead of useMemo and useCallback (dependency comparison, closure storage) is small but nonzero. For trivial computations, memoization is slower than recomputing.
Strong Answer: Step 1: Identify why every item re-renders. Open React DevTools Profiler, record the interaction, and check “Why did this render?” for the list items. Common causes: the parent re-renders and children are not memoized, or a new callback reference is passed as a prop on every render.Step 2: Extract the list item into its own component if it is not already. You cannot memoize inline JSX in a map callback.Step 3: Wrap the list item component in React.memo. This is the biggest single win — now each item only re-renders when its own props change.Step 4: Stabilize callback props. If each item receives onClick={() => handleSelect(item.id)}, that arrow function is new on every render, defeating React.memo. Refactor so the item receives a stable onSelect via useCallback and the item ID as a separate prop, letting the item call onSelect(id) internally.Step 5: If the list data is derived (filtered, sorted), wrap the derivation in useMemo so the filtered array reference stays stable when inputs have not changed.Step 6: For very long lists (1000+ items), add virtualization with react-window or react-virtuoso. This caps rendered components at the visible window.Step 7: Verify with the Profiler that only the changed item re-renders. The flame graph should show 499 gray items and 1 highlighted item.Follow-up: React.memo uses shallow comparison. What happens when list items receive nested objects as props?Shallow comparison checks reference equality for each prop. If a prop is a nested object recreated every render, React.memo sees a new reference and re-renders even if all values are identical.Three solutions: First, stabilize the object with useMemo in the parent. Second, flatten the props — pass userName and userCity as separate primitive props instead of a nested object. Primitives are compared by value. Third, provide a custom comparison function to React.memo that checks the relevant fields. The custom comparator is powerful but dangerous if you forget to compare a prop that affects rendering.
Strong Answer: React Server Components (RSC) execute on the server and send their rendered output to the client as a serialized component tree. They never run in the browser — their JavaScript is not included in the client bundle. This fundamentally changes the optimization model because the primary concern shifts from “minimize client-side re-renders” to “minimize what ships to the client.”In the traditional SPA model, every component’s code is bundled, downloaded, parsed, and executed in the browser. Optimization means reducing re-renders (memo, useMemo, useCallback) and reducing bundle size (code splitting, tree shaking).With RSC, a component that fetches data, processes it, and renders HTML can run entirely on the server. The client receives the rendered output with no JavaScript for that component. For a page that imports a 200KB markdown parser to render blog content, RSC eliminates that 200KB from the client bundle entirely.The new optimization model: make as many components as possible Server Components (the default in Next.js App Router). Only add “use client” when you need interactivity (state, effects, event handlers, browser APIs). The boundary between server and client components is where you optimize.The tradeoff: Server Components cannot use hooks, cannot manage state, and cannot respond to user events. They are purely for rendering. This forces a clean separation between data fetching (server) and interactivity (client).Follow-up: How does streaming with Suspense boundaries improve perceived performance with Server Components?Without streaming, the server processes the entire page, then sends the complete HTML in one response. The user sees a blank page until the slowest data fetch completes. With streaming, the server sends HTML for fast-to-render parts immediately and streams in slower parts as they become ready.Suspense boundaries mark the streaming cut points. A Server Component wrapped in Suspense sends the skeleton HTML immediately and streams the real content when the data fetch resolves. The browser progressively replaces skeletons with content without any JavaScript.For the user, a page that takes 3 seconds to fully load feels near-instant because the layout, navigation, and above-the-fold content appear in 200ms. Slower sections stream in later. Client Components within the stream are hydrated selectively as their code loads, so interactivity arrives progressively too.