Jan 20, 202652 min read
–––

React Performance Optimization: Patterns and Best Practices

React Performance Optimization: Patterns and Best Practices

React makes it easy to build user interfaces, but building fast React apps requires understanding how React actually rerenders components and which performance patterns truly matter in production. This comprehensive guide covers all the essential React performance optimization techniques used by senior engineers.


Understanding Rerendering

Most performance concerns in React come from one thing: rerendering. Understanding how rerendering works is the foundation for all React performance optimization techniques.

What is Rerendering?

Rerendering occurs when React updates the UI to reflect changes in your application's state or props. React rerenders components in the following scenarios:

  1. State changes - When useState or useReducer updates state
  2. Props changes - When parent components pass new prop values
  3. Context value changes - When context providers update their values
  4. Parent component rerenders - When a parent component rerenders, all its children rerender by default

The Rerendering Problem

Let's examine a common scenario where rerendering causes performance issues:

import { useState } from 'react'
import RenderTracker from './RenderTracker';

const RenderTrackerDemo = () => {
  const [value, setValue] = useState("");

  return (
    <div className="p-2 border rounded">
      <RenderTracker />
      <input
        className="border rounded p-1"
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
    </div>
  );
}

export default RenderTrackerDemo

In this example, every keystroke in the input field causes the parent component to rerender, which in turn forces the RenderTracker child component to rerender, even though it doesn't depend on the input value.

Why Rerendering Happens Twice in Development

You might notice that components rerender twice in development mode. This is because React runs in Strict Mode during development, which intentionally double-invokes components to help detect side effects. This behavior doesn't occur in production builds.

The Impact of Unnecessary Rerenders

Unnecessary rerenders can cause:

  • Performance degradation - Especially with expensive computations
  • Poor user experience - UI feels sluggish or unresponsive
  • Increased memory usage - More objects created and garbage collected
  • Battery drain - On mobile devices

Understanding the Component Tree

When a parent component rerenders, React's default behavior is to rerender all children in the component tree. This is by design and follows React's philosophy of declarative updates, but it can lead to performance issues if not managed properly.

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <ChildA /> {/* Rerenders when count changes */}
      <ChildB /> {/* Rerenders when count changes, even if unrelated */}
    </div>
  );
}

Key Takeaways

  1. Rerendering is React's default behavior - It's not a bug, it's a feature
  2. Parent rerenders trigger child rerenders - This is how React ensures UI consistency
  3. Not all rerenders are bad - Only unnecessary rerenders cause performance issues
  4. Performance optimization is about controlling rerenders - Not preventing them entirely

Memoization Techniques

Memoization is a computer programming optimization technique that speeds up your program by storing the result of a function's execution and reusing the cached result when the input to the function doesn't change.

In React, memoization helps prevent unnecessary rerenders and expensive recalculations. React provides three main memoization techniques: React.memo, useCallback, and useMemo.

What is Memoization?

Memoization is a caching technique where you cache the output value of a function and always return the same value as long as the input remains the same. If the input changes, the function recalculates and caches the new result.

Conceptual Example:

f(x) → y (cached)
If x remains the same → return cached y
If x changes to p → calculate new result z and cache it

React.memo: Memoizing Components

React.memo is a higher-order component that memoizes the result of a component. It only rerenders the component if its props have changed.

Example: Preventing Unnecessary Rerenders

import { useState } from "react";
import MemoizedCard from "./ProfileTracker";

const MemoizedProfileTracker = () => {
    const [value, setValue] = useState("");

    return (
        <div className="p-2 border rounded">
            <input
                className="border rounded p-1"
                value={value}
                onChange={(e) => setValue(e.target.value)}
            />
            <MemoizedCard name="John Doe" />
        </div>
    );
};

export default MemoizedProfileTracker;

Result: Without memo, the ProfileCard component rerenders on every keystroke. With memo, it only rerenders if the name prop changes.

How React.memo Works

React.memo performs a shallow comparison of props. If all props are the same (by reference for objects/arrays, by value for primitives), React skips rerendering the component.

useCallback: Memoizing Functions

useCallback is a hook that memoizes function references. It's essential when passing functions as props to memoized components.

The Problem: Inline Functions Break Memoization

When you pass an inline function to a memoized component, it creates a new function reference on every render, breaking the memoization.

Solution: useCallback

import { useCallback, useState } from "react";
import Child from "./Child";
const ChildDemo = () => {
    const [count, setCount] = useState(0);

    const handleClick = useCallback(() => {
        console.log("Child Clicked!");
    }, []);

    console.log("App Rendered");

    return (
        <div className="p-2 border rounded">
            <p>Count: {count}</p>
            <div className="space-x-2">
                <button onClick={() => setCount(count + 1)}>Increment</button>

                <Child onClick={handleClick} />
            </div>
        </div>
    );
};

export default ChildDemo;

Result: Without useCallback, the Child component rerenders every time the parent rerenders because a new function reference is created. With useCallback, the function reference is stable, so Child only rerenders when its props actually change.

useMemo: Memoizing Values

useMemo is a hook that memoizes the result of expensive computations. It only recalculates when its dependencies change.

Problem: Expensive Calculations on Every Render

Without useMemo, expensive operations like sorting large arrays run on every render, even when the data hasn't changed.

Solution: useMemo

import { useState } from "react";
import { getUsers } from "../../../utils";
import Users from "./Users";

const UsersSortingDemo = () => {
    const [count, setCount] = useState(0);
    const [users] = useState(() => getUsers()); // assume it returns 10,000 users

    return (
        <>
            <p>{count}</p>
            <button onClick={() => setCount((c) => c + 1)}>Increment</button>
            <Users list={users} />
        </>
    );
};

export default UsersSortingDemo;

Result: When you click the increment button, the Users component rerenders, but the expensive sorting operation only runs if the list prop changes. Without useMemo, sorting would run on every render.

Important: Avoid Mutation

Always create a copy before sorting to avoid mutating the original array:

// ❌ Bad: Mutates original array
const sorted = list.sort((a, b) => a.localeCompare(b));

// ✅ Good: Creates copy first
const sorted = [...list].sort((a, b) => a.localeCompare(b));

When to Use Each Technique

Use React.memo when:

  • Component receives props that don't change often
  • Component is expensive to render
  • Parent rerenders frequently but child props are stable

Use useCallback when:

  • Passing functions to memoized components
  • Functions are dependencies in other hooks
  • Function identity matters for child components

Use useMemo when:

  • Performing expensive calculations (sorting, filtering, transformations)
  • Creating objects/arrays that are used as dependencies
  • Computing derived values from props or state

Common Pitfalls

1. Over-memoization

Don't memoize everything. Memoization has its own overhead:

// ❌ Unnecessary memoization
const value = useMemo(() => count * 2, [count]); // Simple multiplication

// ✅ Only memoize expensive operations
const expensiveValue = useMemo(() => {
  return heavyComputation(data);
}, [data]);

2. Incorrect Dependencies

Always include all dependencies in dependency arrays:

// ❌ Missing dependency
const result = useMemo(() => {
  return data.filter(item => item.category === category);
}, [data]); // Missing 'category'

// ✅ Correct dependencies
const result = useMemo(() => {
  return data.filter(item => item.category === category);
}, [data, category]);

3. Memoizing Primitives

Don't memoize simple primitive values:

// ❌ Unnecessary
const doubled = useMemo(() => count * 2, [count]);

// ✅ Just compute directly
const doubled = count * 2;

Best Practices

  1. Profile first - Use React DevTools Profiler to identify actual performance issues
  2. Memoize strategically - Only memoize components/values that are actually expensive
  3. Keep dependencies accurate - Always include all dependencies in dependency arrays
  4. Avoid premature optimization - Don't memoize until you've identified a performance problem
  5. Consider React Compiler - In React 19+, the compiler can handle many memoization cases automatically

Derived State Anti-pattern

Derived state is any value that can be computed from existing state or props. Storing derived state in React state is a common anti-pattern that causes unnecessary rerenders and synchronization issues.

What is Derived State?

A derived state is any value that can be computed from existing state or props. Examples include:

  • Filtered lists - Computed from a list and filter criteria
  • Cart totals - Computed from cart items and their prices
  • Text length - Computed from text content
  • Boolean flags - Computed from other values (e.g., isAdult from age)

The Anti-pattern: Storing Derived State

Here's a common mistake developers make:

import { useEffect, useState } from "react";

const Cart = ({ items }) => {
    const [total, setTotal] = useState(0);

    useEffect(() => {
        const sum = items.reduce((acc, item) => acc + item.price, 0);
        setTotal(sum);
    }, [items]);

    return <h2>Total: {total}</h2>;
};

export default Cart;

Problems with This Approach

  1. Unnecessary rerenders - The component rerenders twice: once when items changes, and again when total state updates
  2. Synchronization issues - You must manually keep total in sync with items
  3. Extra complexity - More code to maintain and more potential for bugs
  4. Performance overhead - Additional state updates and effect executions

The Correct Approach: Compute Directly

Instead of storing derived state, compute it directly:

function BetterCart({ items }) {
    const total = items.reduce((acc, item) => acc + item.price, 0);

    return <h2>Total: {total}</h2>;
}

export default BetterCart;

Benefits:

  • ✅ No extra state to manage
  • ✅ Always in sync with source data
  • ✅ Simpler code
  • ✅ Fewer rerenders

Common Examples

Example 1: Filtered Lists

import { useMemo } from "react";

// ✅ Good: Compute directly with useMemo for expensive operations
export function Users({ list }) {
    const activeUsers = useMemo(() => {
        console.log("Filtering expensive list…");
        return list.filter((user) => user.active);
    }, [list]);

    return (
        <>
            <h2>Active Users: {activeUsers.length}</h2>
            {activeUsers.map((user) => (
                <div key={user.id}>{user.name}</div>
            ))}
        </>
    );
}

Example 2: Boolean Flags

// ❌ Bad: Storing derived boolean in state
export function Actor({ age }) {
    const [isAdult, setIsAdult] = useState(false);

    useEffect(() => {
        setIsAdult(age >= 18);
    }, [age]);

    return <>{isAdult ? <p>Adult</p> : <p>Minor</p>}</>;
}

// ✅ Good: Compute directly
// function Actor({ age }) {
//     const isAdult = age >= 18;
//     return <>{isAdult ? <p>Adult</p> : <p>Minor</p>}</>;
// }

Example 3: Form Validation

// ❌ Bad: Storing validation state
export function Form({ name, email }) {
    const [isValid, setIsValid] = useState(false);

    useEffect(() => {
        setIsValid(name !== "" && email.includes("@"));
    }, [name, email]);

    return <>{isValid ? <p>All Good</p> : <p>Bad!!</p>}</>;
}

// ✅ Good: Compute directly
// function Form({ name, email }) {
//     const isValid = name !== "" && email.includes("@");
//     return <>{isValid ? <p>All Good</p> : <p>Bad!!</p>}</>;
// }

When to Use useMemo for Derived Values

For expensive computations, use useMemo to cache the result:

function ExpensiveComponent({ largeDataSet }) {
  // Expensive operation: filtering 100,000+ items
  const filteredData = useMemo(() => {
    return largeDataSet
      .filter(item => item.category === 'active')
      .sort((a, b) => a.date - b.date)
      .map(item => transformItem(item));
  }, [largeDataSet]);

  return <DataDisplay data={filteredData} />;
}

Key Point: useMemo is for performance optimization, not for storing derived state. The value is still computed, just cached.

Best Practices

  1. Compute directly - Calculate derived values in the render function
  2. Use useMemo for expensive operations - Cache results of heavy computations
  3. Avoid useEffect for derived state - Don't use effects to sync derived values
  4. Keep it simple - Simpler code is easier to maintain and less error-prone
  5. Profile performance - Only optimize when you've identified actual issues

Key Rules:

  1. Derived state should not be stored in React state
  2. Compute values directly from props or state
  3. Use useMemo for expensive computations, not for storing state
  4. Avoid useEffect for synchronizing derived values

Remember: If you can compute it, don't store it. This principle will help you write more performant and maintainable React code.


Debouncing

Debouncing is a technique that delays the execution of a function until a specified time has passed since the last time it was invoked. It's essential for optimizing expensive operations like API calls, search functionality, and form validations.

What is Debouncing?

Debouncing delays function execution by a specified duration. If the function is called again before the delay expires, the timer resets. The function only executes after the user stops triggering it for the specified duration.

Key Concept: Debouncing doesn't prevent events from firing—it controls when the function executes in response to those events.

The Problem: Too Many API Calls

Consider a search input that makes API calls on every keystroke. Typing "react" triggers 5 API calls (r, e, a, c, t), which is expensive, slow, and wasteful.

Solution: Custom useDebounce Hook

Here's a reusable useDebounce hook:

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const id = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(id);
  }, [value, delay]);

  return debouncedValue;
}

How It Works

  1. Initial value - debouncedValue starts with the input value
  2. Timer setup - When value changes, a timer is set for delay milliseconds
  3. Reset on change - If value changes again before the timer expires, the previous timer is cleared and a new one starts
  4. Update after delay - Only after the delay expires without new changes does debouncedValue update
import { useEffect, useState } from "react";
import { useDebounce } from "./hooks/useDebounce";

export default function SearchBox() {
    const [query, setQuery] = useState("");
    const debouncedQuery = useDebounce(query, 600);

    useEffect(() => {
        if (!debouncedQuery) return;

        console.log("API Call with:", debouncedQuery);

        // Simulated API:
        // fetch(`/api?q=${debouncedQuery}`)
    }, [debouncedQuery]);

    return (
        <div>
            <h3>Search User</h3>
            <input
                className="border rounded p-1"
                type="text"
                value={query}
                placeholder="Type to search…"
                onChange={(e) => setQuery(e.target.value)}
            />
        </div>
    );
}

Result: Typing "react" now triggers only 1 API call (after 600ms of no typing), instead of 5.

Visual Example

User types: "r" → "re" → "rea" → "reac" → "react"
Timeline:    |----600ms----|----600ms----|----600ms----|----600ms----|----600ms----| API call

Each keystroke resets the timer. Only after 600ms of no typing does the API call execute.

Choosing the Right Delay

The optimal delay depends on your use case:

  • Search inputs: 300-600ms (balance between responsiveness and API calls)
  • Form validation: 500-1000ms (users expect immediate feedback)
  • Resize handlers: 100-250ms (smooth UI updates)
  • API calls: 500-1000ms (reduce server load)

Debouncing vs Throttling

Debouncing: Execute after a pause in activity

  • Best for: Search inputs, form validation, API calls
  • Example: Wait 500ms after user stops typing

Throttling: Execute at most once per time period

  • Best for: Scroll handlers, resize handlers, mouse move
  • Example: Execute at most once every 100ms

Best Practices

  1. Always cleanup timers - Use clearTimeout in the cleanup function
  2. Choose appropriate delays - Balance user experience with performance
  3. Show loading states - Indicate when debouncing is active
  4. Handle edge cases - Consider empty values, rapid changes
  5. Test thoroughly - Ensure debouncing works correctly in all scenarios

Throttling

Throttling is a technique that limits how often a function can be executed. Unlike debouncing (which waits for a pause), throttling ensures a function executes at most once within a specified time period, regardless of how many times it's called.

What is Throttling?

Throttling limits function execution to at most once per specified time period. If the function is called multiple times within that period, only the first call (or last, depending on implementation) executes, and subsequent calls are ignored until the time period expires.

Key Concept: Throttling ensures regular, controlled execution, while debouncing waits for activity to stop.

The Problem: Too Many Function Calls

Consider a scroll handler that updates UI on every scroll event. Scroll events fire dozens of times per second, causing performance degradation, janky scrolling, unnecessary rerenders, and battery drain on mobile devices.

Solution: Custom useThrottle Hook

Here's a reusable useThrottle hook:

import { useEffect, useRef, useState } from "react";

export function useThrottle(value, delay = 300) {
    const [throttledValue, setThrottledValue] = useState(value);
    const lastExecuted = useRef(Date.now());

    useEffect(() => {
        const handler = setTimeout(() => {
            const now = Date.now();

            if (now - lastExecuted.current >= delay) {
                console.log("Do DOM Manipulation");
                setThrottledValue(value);
                lastExecuted.current = now;
            }
        }, delay - (Date.now() - lastExecuted.current));

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

    return throttledValue;
}

How It Works

  1. Track last execution - Store timestamp of last update
  2. Check time elapsed - Compare current time with last execution
  3. Immediate or delayed - Update immediately if enough time passed, otherwise schedule
  4. Cleanup - Clear scheduled timer if value changes

Using useThrottle for Scroll

import { useEffect, useState } from "react";
import { useThrottle } from "./hooks/useThrottle";

export default function ScrollTracker() {
    const [scrollY, setScrollY] = useState(0);
    const throttledY = useThrottle(scrollY, 3000);

    useEffect(() => {
        const handleScroll = () => {
            setScrollY(window.scrollY);
        };

        window.addEventListener("scroll", handleScroll);
        return () => window.removeEventListener("scroll", handleScroll);
    }, []);

    return (
        <div className="border h-48 p-1">
            <h2 className="text-xl">Scroll Position (Throttled)</h2>
            <p>{throttledY}</p>
        </div>
    );
}

Result: The scroll position updates at most once every 3 seconds, dramatically reducing DOM manipulations and improving scroll performance.

Visual Example

Time:     0ms    50ms   100ms  150ms  200ms  250ms  300ms
Events:   |------|------|------|------|------|------|
          Call1  Call2  Call3  Call4  Call5  Call6  Call7
          Exec   Skip   Exec   Skip   Skip   Exec   Skip

With 100ms throttling, only calls at 0ms, 100ms, and 300ms execute.

Choosing the Right Delay

The optimal throttle delay depends on your use case:

  • Scroll handlers: 100-250ms (smooth but not excessive)
  • Resize handlers: 250-500ms (resize is less frequent)
  • Mouse move: 16ms (~60fps for smooth animations)
  • API calls: 1000ms+ (reduce server load)
  • Input handlers: Usually use debouncing instead

Throttling vs Debouncing

AspectThrottlingDebouncing
ExecutionAt most once per periodAfter pause in activity
Use caseScroll, resize, mouse moveSearch, form validation
TimingRegular intervalsAfter user stops
ExampleUpdate every 100msUpdate 500ms after last input

When to use throttling:

  • Events that fire continuously (scroll, resize, mousemove)
  • You need regular updates at controlled intervals
  • Smooth animations or UI updates

When to use debouncing:

  • User input (search, typing)
  • You want to wait for activity to stop
  • API calls that should wait for complete input

Best Practices

  1. Choose appropriate delays - Balance responsiveness with performance
  2. Clean up timers - Always clear timeouts in cleanup functions
  3. Use for continuous events - Throttle scroll, resize, mousemove
  4. Consider requestAnimationFrame - For animations, requestAnimationFrame may be better
  5. Test performance - Profile to ensure throttling improves performance

React Compiler

React 19 introduces the React Compiler, a revolutionary feature that automatically memoizes components, stabilizes function references, and prevents unnecessary rendering without requiring manual useMemo, useCallback, or React.memo from developers.

What is React Compiler?

The React Compiler is a build-time optimization tool that automatically applies memoization to your React components. It analyzes your code and automatically:

  • Memoizes component outputs
  • Stabilizes function references
  • Prevents unnecessary rerenders
  • Optimizes expensive computations

Key Benefit: You write less code, and React optimizes more automatically.

Before React Compiler

In React 18 and earlier, developers had to manually optimize performance:

import { memo, useMemo, useCallback } from 'react';

function ProductCard({ product, onAddToCart }) {
  // Manual memoization required
  const discountedPrice = useMemo(() => {
    return product.price * 0.9;
  }, [product.price]);

  const handleClick = useCallback(() => {
    onAddToCart(product.id);
  }, [product.id, onAddToCart]);

  return (
    <div>
      <h3>{product.name}</h3>
      <p>Price: ${discountedPrice}</p>
      <button onClick={handleClick}>Add to Cart</button>
    </div>
  );
}

// Manual memoization required
export default memo(ProductCard);

Problems:

  • Lots of boilerplate code
  • Easy to forget optimization
  • Dependency arrays can be error-prone
  • Over-optimization or under-optimization

After React Compiler

With React Compiler, you write clean, simple code:

function ProductCard({ product, onAddToCart }) {
  // No useMemo needed - compiler handles it
  const discountedPrice = product.price * 0.9;

  // No useCallback needed - compiler handles it
  const handleClick = () => {
    onAddToCart(product.id);
  };

  return (
    <div>
      <h3>{product.name}</h3>
      <p>Price: ${discountedPrice}</p>
      <button onClick={handleClick}>Add to Cart</button>
    </div>
  );
}

// No memo() needed - compiler handles it
export default ProductCard;

Benefits:

  • Cleaner, more readable code
  • Automatic optimization
  • No dependency arrays to manage
  • Consistent performance improvements

How React Compiler Works

The React Compiler operates at build time:

  1. Code Analysis - Analyzes your React components during build
  2. Automatic Memoization - Identifies what should be memoized
  3. Function Stabilization - Stabilizes function references automatically
  4. Component Memoization - Wraps components with memoization logic

Internal Process

Your Code → React Compiler → Optimized Code → Bundle

The compiler transforms your code to include memoization automatically, similar to how Babel transforms JSX.

Installation and Setup

Step 1: Install the Plugin

# Using npm
npm install -D babel-plugin-react-compiler

# Using yarn
yarn add -D babel-plugin-react-compiler

# Using pnpm
pnpm add -D babel-plugin-react-compiler

Step 2: Configure Your Build Tool

For Vite
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [
          ['babel-plugin-react-compiler', {}],
        ],
      },
    }),
  ],
});
For Next.js
// next.config.js
module.exports = {
  compiler: {
    reactCompiler: true,
  },
};

What Gets Optimized

1. Component Memoization

// Automatically memoized
function UserProfile({ user }) {
  return <div>{user.name}</div>;
}

2. Function References

// Automatically stabilized
function Button({ onClick, label }) {
  return <button onClick={onClick}>{label}</button>;
}

3. Expensive Computations

// Automatically memoized
function DataTable({ data }) {
  const sortedData = data.sort((a, b) => a.name.localeCompare(b.name));
  return <table>{/* render table */}</table>;
}

Opting Out of Optimization

Sometimes you may want to opt out of automatic optimization for specific components:

function SpecialComponent({ data }) {
  "use no memo"; // Opt out directive
  
  // This component won't be automatically memoized
  return <div>{data}</div>;
}

When to opt out:

  • Component has side effects that require rerenders
  • Manual control is needed for specific cases
  • Debugging optimization issues

Recommendation: Only opt out if you encounter specific issues. In most cases, let the compiler optimize.

Limitations and Considerations

1. React 19+ Required

React Compiler works best with React 19+. It can work with React 17 and 18, but React 19 provides the best experience.

2. Not a Magic Solution

React Compiler doesn't replace all optimization techniques:

  • Virtualization - Still needed for large lists
  • Code Splitting - Still needed for bundle size
  • Context Optimization - Still needed for context splitting
  • Lazy Loading - Still needed for on-demand loading

Best Practices

  1. Use React 19+ - Get the best compiler experience
  2. Write clean code - Let the compiler optimize
  3. Remove manual optimizations - Trust the compiler
  4. Profile performance - Verify improvements
  5. Opt out only when needed - Use "use no memo" sparingly

Code Splitting and Lazy Loading

Code splitting and lazy loading are essential techniques for optimizing React applications. They allow you to load only the code needed for the current view, reducing initial bundle size and improving time to interactive.

The Problem: Large Bundles

As React applications grow, bundle sizes increase:

  • Large bundles - All code loaded upfront
  • Slow initial load - Users wait for everything to download
  • Poor performance - Especially on slow networks
  • Wasted bandwidth - Loading code that may never be used

Solution: React.lazy and Suspense

React provides React.lazy for dynamic imports and Suspense for loading states.

Basic Lazy Loading

import React, { Suspense, useState } from "react";
import Light from "./Light";

const Heavy = React.lazy(() => import("./Heavy"));

const LazyLoader = () => {
    const [show, setShow] = useState(false);

    return (
        <div style={{ fontFamily: "system-ui, sans-serif", padding: 20 }}>
            <h1>Lazy Demo</h1>
            <p>
                Heavy component is loaded on demand via{" "}
                <code>React.lazy()</code>.
            </p>

            <Light />

            <button onClick={() => setShow((s) => !s)} style={{ margin: 10 }}>
                {show ? "Hide Heavy" : "Show Heavy"}
            </button>

            <Suspense
                fallback={
                    <div style={{ padding: 20 }}>Loading heavy component…</div>
                }
            >
                {show && <Heavy />}
            </Suspense>
        </div>
    );
};

export default LazyLoader;

Benefits:

  • HeavyComponent only loads when needed
  • Smaller initial bundle
  • Faster initial load time
  • Better user experience

Comparison: In NonLazyLoader, the Heavy component is bundled and loaded immediately. In LazyLoader, it's only loaded when the user clicks "Show Heavy", reducing initial bundle size.

How React.lazy Works

React.lazy takes a function that returns a dynamic import:

const MyComponent = lazy(() => import('./MyComponent'));

Behind the scenes:

  1. Dynamic import - Creates a separate chunk
  2. Promise-based - Returns a promise that resolves to the component
  3. Code splitting - Webpack/Vite automatically splits the code
  4. On-demand loading - Component loads when first rendered

Suspense for Loading States

Suspense provides a fallback UI while lazy components load:

<Suspense fallback={<LoadingSpinner />}>
  <LazyComponent />
</Suspense>

Route-Based Code Splitting

One of the most common use cases is splitting by routes:

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading page...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/contact" element={<Contact />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Result: Each route loads only when navigated to, reducing initial bundle size significantly.

Best Practices

  1. Lazy load routes - Split by route for maximum impact
  2. Lazy load heavy components - Components with large dependencies
  3. Lazy load modals/dialogs - Load only when opened
  4. Provide good fallbacks - User-friendly loading states
  5. Preload on interaction - Preload on hover/focus
  6. Monitor bundle size - Use bundle analyzers
  7. Test on slow networks - Verify improvements

When to Use Lazy Loading

Good Candidates

  • ✅ Route components
  • ✅ Heavy third-party libraries
  • ✅ Modals and dialogs
  • ✅ Charts and visualizations
  • ✅ Admin panels
  • ✅ Features behind feature flags

Not Good Candidates

  • ❌ Small, frequently used components
  • ❌ Components needed immediately
  • ❌ Critical above-the-fold content
  • ❌ Components with minimal dependencies

Component Isolation

Component isolation is a best practice that involves strategically using memoization to create render boundaries, preventing unnecessary rerenders of child components when parent components update.

The Problem: Cascading Rerenders

When a parent component rerenders, all its children rerender by default. This can cause performance issues:

import Revenue from "./Revenue";
import UserCard from "./UserCard";
import Visitors from "./Visitors";
function Dashboard({ user, stats }) {
    return (
        <>
            <UserCard user={user} />
            <Revenue stats={stats} />
            <Visitors />
        </>
    );
}

export default Dashboard;

Problem: If stats changes, all three child components rerender, even though:

  • UserCard only depends on user
  • Visitors doesn't depend on any props

Solution: Component Isolation with React.memo

Isolate components by wrapping them with React.memo:

import {memo} from "react";

import Revenue from "./Revenue";
import UserCard from "./UserCard";
import Visitors from "./Visitors";

const MUserCard = memo(UserCard);
const MRevenue = memo(Revenue);

const IsolatedDashboard = ({ user, stats }) => {
    return (
        <>
            <MUserCard user={user} />
            <MRevenue stats={stats} />
            <Visitors />
        </>
    );
};

export default IsolatedDashboard;

Result: With IsolatedDashboard, when stats changes, only MRevenue rerenders. MUserCard and Visitors don't rerender unnecessarily.

Understanding Render Boundaries

Render boundaries are points in your component tree where rerenders stop propagating:

Parent (rerenders)
  ├─ MemoizedChildA (doesn't rerender - boundary)
  │   └─ GrandchildA (doesn't rerender)
  └─ MemoizedChildB (doesn't rerender - boundary)
      └─ GrandchildB (doesn't rerender)

When Parent rerenders, MemoizedChildA and MemoizedChildB act as boundaries, preventing rerenders of their subtrees if props haven't changed.

When to Isolate Components

Good Candidates for Isolation

  1. Expensive components - Components with heavy rendering logic
  2. Stable props - Components that receive props that don't change often
  3. Leaf components - Components at the bottom of the tree
  4. Frequently rerendered parents - When parent rerenders often but child props are stable

Not Good Candidates

  1. Simple components - Very lightweight components
  2. Frequently changing props - If props change often, memoization adds overhead
  3. Components with many props - Shallow comparison can be expensive
  4. Components that always rerender - If props always change, memoization is useless

Best Practices

  1. Isolate strategically - Don't memoize everything, only what benefits
  2. Profile first - Use React DevTools to identify actual issues
  3. Stable props - Ensure props are stable (use useCallback/useMemo if needed)
  4. Test thoroughly - Verify isolation works as expected
  5. Document decisions - Comment why components are isolated

Context Optimization

React Context is powerful for sharing state across components, but it can cause massive rerenders if not optimized properly. When context value changes, all consuming components rerender, which can significantly impact performance.

The Problem: Context Rerenders

Context creates a chain of rerenders. When context value changes, all components consuming that context rerender:

import { createContext, useState } from 'react';

const AppContext = createContext();

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

  // ❌ Bad: Single context with multiple values
  const value = {
    user,
    theme,
    setUser,
    setTheme,
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

Problem: When theme changes, all components consuming user also rerender unnecessarily.

Solution 1: Split Contexts

Split contexts by concern to minimize rerenders:

import { createContext, useState } from 'react';

const UserContext = createContext();

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

export { UserContext, UserProvider };

Benefits:

  • Components only rerender when their specific context changes
  • Better performance
  • Clearer separation of concerns

Solution 2: Memoize Context Value

Memoize the context value object to prevent unnecessary rerenders:

import { useMemo, useState } from 'react';
import { UserContext } from './UserContext';

function UserProvider({ children }) {
  const [user, setUser] = useState(null);

  // Memoize the value object
  const value = useMemo(
    () => ({ user, setUser }),
    [user]
  );

  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}

Why: Without memoization, a new object is created on every render, causing all consumers to rerender even if user hasn't changed.

Solution 3: Context Scope Optimization

Don't wrap the entire app if context is only needed in part of the tree:

// ❌ Bad: Wrapping entire app
function App() {
  return (
    <UserProvider>
      <EntireApp /> {/* User context not needed everywhere */}
    </UserProvider>
  );
}

Best Practices

  1. Split by concern - Separate contexts for different concerns
  2. Scope appropriately - Only wrap components that need context
  3. Memoize values - Use useMemo for context value objects
  4. Separate state/setters - Split state and dispatch contexts
  5. Use selectors - Subscribe to specific parts of context
  6. Profile performance - Measure impact of context optimizations

Virtualization

Virtualization is a technique that renders only the items visible on the screen, dramatically improving performance when dealing with large lists. Instead of rendering thousands of items, you render only what's visible plus a small buffer.

The Problem: Rendering Large Lists

Consider a list with 10,000 items:

function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name} - {user.email}
        </li>
      ))}
    </ul>
  );
}

Problems:

  • 10,000 DOM nodes - Massive memory usage
  • Slow initial render - Takes seconds to render
  • Janky scrolling - Browser struggles with so many elements
  • Poor performance - Scripting and rendering take 8+ seconds

What is Virtualization?

Virtualization renders only the visible items plus a small buffer. As you scroll, items are dynamically added and removed from the DOM.

Concept:

  • Render only visible items (e.g., 10-20 items)
  • Calculate scroll position
  • Dynamically render items as user scrolls
  • Remove off-screen items from DOM

Solution: Using react-window

react-window is a popular library for implementing virtualization in React.

Installation

npm install react-window

Basic Example

import { useState } from "react";
import NonVirtualList from "./NonVirtualList";
import VirtualList from "./VirtualList";
import { useFakeUsers } from "./data";

const VirtualizationDemo = () => {
    const users = useFakeUsers(50000);
    const [mode, setMode] = useState("virtual"); // "virtual" or "non-virtual"

    return (
        <div style={{ padding: 20, fontFamily: "system-ui, sans-serif" }}>
            <h1>List Virtualization Demo</h1>

            <div style={{ marginBottom: 12 }}>
                <button
                    className="underline cursor-pointer"
                    onClick={() => setMode("virtual")}
                    disabled={mode === "virtual"}
                >
                    Use virtual list
                </button>
                <button
                    className="underline cursor-pointer"
                    onClick={() => setMode("non-virtual")}
                    disabled={mode === "non-virtual"}
                    style={{ marginLeft: 8 }}
                >
                    Use non-virtual list
                </button>
            </div>

            <div style={{ marginBottom: 8, color: "#fff" }}>
                Current mode: <strong>{mode}</strong> — Users: {users.length}
            </div>

            {mode === "virtual" ? (
                <VirtualList users={users} height={600} itemHeight={80} />
            ) : (
                <NonVirtualList users={users} />
            )}
        </div>
    );
};

export default VirtualizationDemo;

Result: With virtualization, only ~12-15 items are rendered at a time, regardless of list size. Without virtualization, all 50,000 items are rendered, causing severe performance issues.

Performance Comparison

Without Virtualization

10,000 items:
- DOM nodes: 10,000
- Initial render: ~8 seconds
- Scripting: ~3.3 seconds
- Rendering: ~2.4 seconds
- Scroll performance: Janky, sluggish

With Virtualization

10,000 items (only ~15 rendered):
- DOM nodes: ~15
- Initial render: ~0.1 seconds
- Scripting: ~0.5 seconds
- Rendering: ~0.3 seconds
- Scroll performance: Smooth, responsive

Improvement: 80x faster initial render, smooth scrolling.

When to Use Virtualization

Good Candidates

  • ✅ Large lists (100+ items)
  • ✅ Long tables
  • ✅ Infinite scroll
  • ✅ Data grids
  • ✅ Chat message lists
  • ✅ Product catalogs

Not Good Candidates

  • ❌ Small lists (< 50 items)
  • ❌ Lists with complex nested structures
  • ❌ Lists that need all items in DOM
  • ❌ Lists with frequent reordering

Best Practices

  1. Use for large lists - 100+ items benefit significantly
  2. Choose appropriate item size - Accurate sizing improves performance
  3. Add overscan - Render a few extra items for smooth scrolling
  4. Memoize row components - Use React.memo for row components
  5. Handle dynamic heights - Use VariableSizeList when needed
  6. Test scroll performance - Verify smooth scrolling

Concurrent Rendering

React 18 introduced concurrent features that allow React to keep the UI responsive during expensive updates. useTransition and useDeferredValue are hooks that mark certain updates as non-urgent, allowing React to prioritize more important updates.

The Problem: Blocking Updates

When you type in a search box that filters a large list, the UI can freeze:

function SearchBox() {
  const [query, setQuery] = useState('');

  const filteredItems = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <ul>
        {filteredItems.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Problem: Every keystroke triggers expensive filtering, blocking the input from updating smoothly.

What is Concurrent Rendering?

Concurrent rendering allows React to:

  • Interrupt rendering - Pause expensive work
  • Prioritize updates - Handle urgent updates first
  • Keep UI responsive - Maintain interactivity during heavy work

useTransition: Marking Updates as Non-Urgent

useTransition marks state updates as non-urgent, allowing React to keep the UI responsive.

Basic Usage

import { useMemo, useState, useTransition } from "react";

// helper: create heavy list once
function createItems(count = 20000) {
    const arr = new Array(count);
    for (let i = 0; i < count; i++) {
        arr[i] = `Item ${i} - ${Math.random().toString(36).slice(2, 7)}`;
    }
    return arr;
}

const HEAVY_LIST = createItems(50000);

function HeavyList({ query }) {
    const filtered = useMemo(() => {
        // expensive CPU-bound task simulation
        const res = HEAVY_LIST.filter((item) =>
            item.toLowerCase().includes(query.toLowerCase())
        );
        console.log("Filtered count:", res.length);
        return res.slice(0, 200); // render top 200 for demo
    }, [query]);

    return (
        <div
            style={{
                maxHeight: 300,
                overflow: "auto",
                border: "1px solid #ddd",
            }}
        >
            {filtered.map((item, i) => (
                <div
                    key={i}
                    style={{ padding: 8, borderBottom: "1px solid #eee" }}
                >
                    {item}
                </div>
            ))}
        </div>
    );
}

export default function TransitionDemo() {
    const [text, setText] = useState("");
    const [query, setQuery] = useState("");
    const [isPending, startTransition] = useTransition();

    function handleChange(e) {
        const val = e.target.value;
        setText(val); // immediate update — keeps input responsive

        // Mark heavy update as non-urgent:
        startTransition(() => {
            setQuery(val); // updating query triggers expensive filtering + renders
        });
    }

    return (
        <div style={{ padding: 20, fontFamily: "system-ui, sans-serif" }}>
            <h2>useTransition Demo (Non-blocking updates)</h2>

            <div style={{ marginBottom: 10 }}>
                <input
                    className="border p-1 rounded"
                    value={text}
                    onChange={handleChange}
                    placeholder="Type to filter (feel the responsiveness)..."
                    style={{ width: 400, padding: 8 }}
                />

                <span style={{ marginLeft: 12 }}>
                    {isPending ? "Updating results…" : "Idle"}
                </span>
            </div>

            <HeavyList query={query} />
        </div>
    );
}

useDeferredValue: Deferring Values

useDeferredValue defers a value update, similar to debouncing but integrated with React's rendering.

Basic Usage

import { useDeferredValue, useMemo, useState } from "react";

function createLargeList(count = 20000) {
    const arr = new Array(count);
    for (let i = 0; i < count; i++) {
        arr[i] = `Product ${i} - ${Math.random().toString(36).slice(2, 6)}`;
    }
    return arr;
}

const PRODUCTS = createLargeList(20000);

function ProductsList({ filter }) {
    const filtered = useMemo(() => {
        console.log("Products filtering for:", filter);
        return PRODUCTS.filter((p) =>
            p.toLowerCase().includes(filter.toLowerCase())
        ).slice(0, 200);
    }, [filter]);

    return (
        <div
            style={{
                maxHeight: 300,
                overflow: "auto",
                border: "1px solid #ddd",
            }}
        >
            {filtered.map((p, i) => (
                <div
                    key={i}
                    style={{ padding: 8, borderBottom: "1px solid #eee" }}
                >
                    {p}
                </div>
            ))}
        </div>
    );
}

export default function DeferredDemo() {
    const [text, setText] = useState("");
    // deferredText will update less aggressively than text
    const deferredText = useDeferredValue(text);

    return (
        <div style={{ padding: 20, fontFamily: "system-ui, sans-serif" }}>
            <h2>useDeferredValue Demo (Deferred heavy updates)</h2>

            <div style={{ marginBottom: 10 }}>
                <input
                    className="border p-1 rounded"
                    value={text}
                    onChange={(e) => setText(e.target.value)}
                    placeholder="Type to filter (UI remains instant because heavy filtering is deferred)"
                    style={{ width: 400, padding: 8 }}
                />
                <span style={{ marginLeft: 12 }}>
                    {text === deferredText
                        ? "Results up-to-date"
                        : "Results are deferred"}
                </span>
            </div>

            <ProductsList filter={deferredText} />
        </div>
    );
}

useTransition vs useDeferredValue

AspectuseTransitionuseDeferredValue
ControlsState update functionValue itself
Use caseMarking updates as non-urgentDeferring a value
Returns[isPending, startTransition]Deferred value
When to useControlling state updatesDeferring prop/state values

useTransition:

  • Controlling when state updates happen
  • You want isPending state
  • Multiple state updates to defer

useDeferredValue:

  • Deferring a single value
  • Simpler API
  • Derived values from props/state

Best Practices

  1. Use for expensive operations - Filtering, sorting, transformations
  2. Keep urgent updates separate - Input updates should be immediate
  3. Show loading states - Use isPending to indicate work in progress
  4. Combine with memoization - Use useMemo for expensive computations
  5. Test on slow devices - Verify improvements on low-end devices

Using Keys Correctly

Keys are a fundamental part of React's reconciliation algorithm. They help React identify which items have changed, been added, or removed. Using keys incorrectly can cause performance issues, bugs, and unnecessary DOM recreation.

What are Keys?

Keys are special string attributes you include when creating lists of elements. They help React identify which items have changed:

const items = [
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Cherry' },
];

function FruitList() {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

The Problem: Using Index as Key

A common mistake is using array index as the key:

// ❌ Bad: Using index as key
function UserList({ users }) {
  return (
    <ul>
      {users.map((user, index) => (
        <li key={index}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}

Why Index Keys Are Problematic

  1. Breaks memoization - React can't identify which items actually changed
  2. Causes unnecessary DOM recreation - Items are destroyed and recreated
  3. State bugs - Component state can be associated with wrong items
  4. Performance issues - Slower reconciliation

Solution: Use Stable, Unique Keys

Always use stable, unique identifiers from your data:

// ✅ Good: Using unique ID
function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}

What Makes a Good Key?

  1. Unique - Each key should be unique within the list
  2. Stable - Key should not change between renders
  3. Predictable - Key should be consistent for the same item

Examples of Good Keys

// ✅ Using ID from database
{users.map(user => <UserCard key={user.id} user={user} />)}

// ✅ Using combination of stable values
{items.map(item => (
  <Item key={`${item.category}-${item.id}`} item={item} />
))}

// ✅ Using unique property
{products.map(product => (
  <Product key={product.sku} product={product} />
))}

Keys and Memoization

Keys are crucial for memoization to work correctly:

import { memo } from 'react';

const MemoizedUserCard = memo(function UserCard({ user }) {
  return <div>{user.name}</div>;
});

function UserList({ users }) {
  return (
    <div>
      {users.map(user => (
        <MemoizedUserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

With stable keys:

  • React correctly identifies unchanged items
  • Memoized components don't rerender unnecessarily
  • Performance is optimal

With index keys:

  • React thinks all items changed when list reorders
  • Memoization breaks
  • All components rerender

When Index Keys Are Acceptable

Index keys are acceptable only when:

  1. List is static - Items never reorder, add, or remove
  2. No component state - Items don't have internal state
  3. No memoization - Components aren't memoized
  4. Simple rendering - No complex logic or side effects

However: Even in these cases, using stable IDs is better practice.

Best Practices

  1. Always use stable IDs - Prefer IDs from your data
  2. Never use index - Unless list is truly static
  3. Make keys unique - Each key should be unique in the list
  4. Keep keys stable - Don't change keys between renders
  5. Test with operations - Add, remove, reorder items
  6. Combine with memoization - Keys enable effective memoization

Performance Checklist

Use this checklist to audit your React application's performance. Go through each item and verify your implementation.

CategoryItemDetailsPriority
Profiling & MeasurementProfile with React DevToolsIdentify actual performance bottlenecks before optimizing. Use React DevTools Profiler to find slow components and unnecessary rerenders.High
Measure bundle sizeUse tools like webpack-bundle-analyzer or source-map-explorer to identify large dependencies and optimize bundle size.High
Monitor Core Web VitalsTrack LCP (Largest Contentful Paint), FID (First Input Delay), and CLS (Cumulative Layout Shift) metrics to measure user experience.High
Test on slow devicesVerify performance on low-end devices and slow networks (3G throttling) to ensure good experience for all users.Medium
Use Performance APIMeasure render times and component performance using browser Performance API for detailed metrics.Medium
Set performance budgetsDefine acceptable limits for bundle size and load times. Fail builds if budgets are exceeded.Medium
Rerendering OptimizationIdentify unnecessary rerendersUse React DevTools Profiler to find components rerendering too often. Look for components that rerender when their props haven't changed.High
Check parent-child relationshipsEnsure child components only rerender when their props actually change. Use React.memo to prevent unnecessary rerenders.High
Verify Strict Mode behaviorRemember double renders in development are normal due to React Strict Mode. This doesn't occur in production.Low
Monitor state updatesEnsure state changes are necessary and minimal. Batch related state updates when possible.High
MemoizationUse React.memo strategicallyApply to components that: receive props that don't change often, are expensive to render, or are in lists with stable props.High
Memoize functions with useCallbackUse when: passing functions to memoized components, functions are dependencies in other hooks, or function identity matters for child components.High
Memoize values with useMemoUse for: expensive calculations (sorting, filtering, transformations), creating objects/arrays used as dependencies, or expensive derived values from props/state.High
Avoid over-memoizationDon't memoize simple primitives or cheap operations. Memoization has overhead—only use when beneficial.Medium
Verify dependency arraysEnsure all dependencies are included correctly in dependency arrays. Missing dependencies cause stale closures and bugs.High
State ManagementEliminate derived stateCompute values directly instead of storing in state. Derived state causes unnecessary rerenders and synchronization issues.High
Use useMemo for expensive derived valuesOnly when computation is actually expensive. For simple calculations, compute directly in render.Medium
Avoid useEffect for derived stateDon't sync derived values with effects. This causes double renders and synchronization bugs.High
Minimize state updatesBatch related state updates when possible. Use functional updates for state that depends on previous state.Medium
Use functional updatesFor state that depends on previous state: setCount(c => c + 1) instead of setCount(count + 1).Medium
Event HandlingDebounce search inputsDelay API calls until user stops typing. Recommended delay: 300-600ms for search inputs.High
Debounce form validationPrevent validation on every keystroke. Recommended delay: 500-1000ms for form validation.Medium
Throttle scroll handlersLimit scroll event processing. Recommended throttle: 100-250ms for scroll handlers.High
Throttle resize handlersControl window resize updates. Recommended throttle: 100-250ms for resize handlers.Medium
Remove event listenersClean up event listeners in useEffect cleanup function to prevent memory leaks.High
Code Splitting & Lazy LoadingLazy load routesSplit by route for maximum impact. Each route loads only when navigated to, reducing initial bundle size.High
Lazy load heavy componentsComponents with large dependencies should be lazy loaded. Use React.lazy() and Suspense.High
Lazy load modals/dialogsLoad only when opened. Modals are perfect candidates for lazy loading.Medium
Provide good fallbacksUser-friendly loading states for Suspense. Show skeleton loaders or spinners, not blank screens.Medium
Monitor bundle sizeTrack initial bundle and chunk sizes. Use bundle analyzers to identify optimization opportunities.High
Preload on interactionPreload lazy components on hover/focus to improve perceived performance.Low
Component ArchitectureIsolate componentsUse React.memo to create render boundaries. Prevent cascading rerenders in complex component trees.High
Split large componentsBreak down complex components into smaller ones. Smaller components are easier to optimize and test.Medium
Minimize prop drillingUse Context or state management for deep props. Avoid passing props through many component levels.Medium
Optimize component treeKeep component hierarchy shallow when possible. Deep trees can cause performance issues.Low
Context OptimizationSplit contexts by concernSeparate contexts for different data types. Don't put everything in one context—it causes unnecessary rerenders.High
Memoize context valuesUse useMemo for context value objects. Prevents creating new objects on every render.High
Scope contexts appropriatelyOnly wrap components that need context. Don't wrap the entire app if context is only needed in part of the tree.High
Separate state/settersSplit state and dispatch into separate contexts when beneficial. Components that only need setters won't rerender on state changes.Medium
Avoid single large contextDon't put everything in one context. Large contexts cause all consumers to rerender when any value changes.High
List RenderingUse stable, unique keysNever use array index as key (unless list is truly static). Use IDs from your data or generate stable keys.High
Virtualize large listsUse react-window for lists with 100+ items. Virtualization dramatically improves performance for long lists.High
Memoize list itemsUse React.memo for list item components. Prevents rerendering items that haven't changed.High
Optimize list item renderingKeep list items lightweight. Complex list items can cause performance issues even with virtualization.Medium
Handle dynamic list heightsUse VariableSizeList when needed. FixedSizeList is faster but requires consistent item heights.Medium
Concurrent FeaturesUse useTransitionFor non-urgent state updates that can be deferred. Keeps UI responsive during expensive updates.Medium
Use useDeferredValueFor deferring expensive computations. Similar to debouncing but integrated with React's rendering.Medium
Show loading statesUse isPending from useTransition to indicate work in progress. Improves user experience.Medium
Keep urgent updates separateInput updates should be immediate. Use startTransition only for non-urgent updates.High
Build & RuntimeEnable React CompilerIf using React 19+, enable automatic optimizations. React Compiler handles many memoization cases automatically.Medium
Optimize imagesUse Next.js Image component or similar optimizations. Images are often the largest assets.High
Enable production modeEnsure production builds are optimized. Development mode includes extra checks that slow down performance.High
Minimize re-renders in productionVerify optimizations work in production builds. Some optimizations behave differently in production.High
Use production ReactEnsure you're using the production build. Development React includes warnings and slower code paths.High
Testing & ValidationTest with real dataUse realistic data volumes in development. Testing with small datasets can hide performance issues.Medium
Test list operationsAdd, remove, reorder items to verify key usage. Ensure keys work correctly with list mutations.Medium
Verify memoizationConfirm memoized components don't rerender unnecessarily. Use React DevTools to verify memoization works.High
Test on slow networksVerify lazy loading works correctly. Test with network throttling to ensure good loading experience.Medium
Profile before and afterMeasure performance improvements. Use React DevTools Profiler to quantify optimization impact.High
Code QualityFollow React best practicesAdhere to React's recommended patterns. Use official React documentation as reference.High
Keep components simpleSimpler components are easier to optimize. Complex components are harder to memoize and optimize.Medium
Document performance decisionsComment why optimizations were added. Helps future developers understand performance-critical code.Low
Review optimization impactVerify optimizations actually improve performance. Not all optimizations provide measurable benefits.High
Avoid premature optimizationOnly optimize when you've identified issues. Profile first, then optimize based on actual bottlenecks.High
React Compiler (React 19+)Consider React CompilerEvaluate if automatic optimizations fit your needs. React Compiler can reduce manual optimization work.Low
Configure correctlySet up React Compiler for your build tool (Vite/Next.js). Follow official setup instructions.Medium
Test compiler outputVerify compiler optimizations work as expected. Some edge cases may require manual optimization.Medium
Opt out when neededUse "use no memo" directive for special cases. Some components may need to opt out of automatic optimization.Low

Conclusion

React performance optimization is about understanding how React works and applying the right techniques at the right time. This comprehensive guide covered:

  1. Understanding Rerendering - Foundation of all optimizations
  2. Memoization - React.memo, useCallback, useMemo
  3. Derived State - Avoiding unnecessary state
  4. Debouncing - Optimizing user input
  5. Throttling - Controlling frequent events
  6. React Compiler - Automatic optimization in React 19
  7. Code Splitting - Reducing bundle size
  8. Component Isolation - Creating render boundaries
  9. Context Optimization - Preventing context rerenders
  10. Virtualization - Rendering large lists efficiently
  11. Concurrent Rendering - Keeping UI responsive
  12. Keys - Proper list rendering

Final Best Practices

  1. Profile first - Use React DevTools to identify actual issues
  2. Optimize strategically - Don't optimize prematurely
  3. Measure impact - Verify optimizations improve performance
  4. Follow patterns - Use established patterns and best practices
  5. Keep learning - React performance optimization is an ongoing journey

Remember: Don't try to optimize performance of something which is already working great. Performance optimization is required only when you've found actual performance issues through feedback, profiling, or user complaints. Apply the techniques from this guide strategically, and your React applications will be fast, responsive, and performant.

On this page

Understanding Rerendering
What is Rerendering?
The Rerendering Problem
Why Rerendering Happens Twice in Development
The Impact of Unnecessary Rerenders
Understanding the Component Tree
Key Takeaways
Memoization Techniques
What is Memoization?
React.memo: Memoizing Components
Example: Preventing Unnecessary Rerenders
How React.memo Works
useCallback: Memoizing Functions
The Problem: Inline Functions Break Memoization
Solution: useCallback
useMemo: Memoizing Values
Problem: Expensive Calculations on Every Render
Solution: useMemo
Important: Avoid Mutation
When to Use Each Technique
Use React.memo when:
Use useCallback when:
Use useMemo when:
Common Pitfalls
1. Over-memoization
2. Incorrect Dependencies
3. Memoizing Primitives
Best Practices
Derived State Anti-pattern
What is Derived State?
The Anti-pattern: Storing Derived State
Problems with This Approach
The Correct Approach: Compute Directly
Common Examples
Example 1: Filtered Lists
Example 2: Boolean Flags
Example 3: Form Validation
When to Use useMemo for Derived Values
Best Practices
Debouncing
What is Debouncing?
The Problem: Too Many API Calls
Solution: Custom useDebounce Hook
How It Works
Using useDebounce in Search
Visual Example
Choosing the Right Delay
Debouncing vs Throttling
Best Practices
Throttling
What is Throttling?
The Problem: Too Many Function Calls
Solution: Custom useThrottle Hook
How It Works
Using useThrottle for Scroll
Visual Example
Choosing the Right Delay
Throttling vs Debouncing
Best Practices
React Compiler
What is React Compiler?
Before React Compiler
After React Compiler
How React Compiler Works
Internal Process
Installation and Setup
Step 1: Install the Plugin
Step 2: Configure Your Build Tool
For Vite
For Next.js
What Gets Optimized
1. Component Memoization
2. Function References
3. Expensive Computations
Opting Out of Optimization
Limitations and Considerations
1. React 19+ Required
2. Not a Magic Solution
Best Practices
Code Splitting and Lazy Loading
The Problem: Large Bundles
Solution: React.lazy and Suspense
Basic Lazy Loading
How React.lazy Works
Suspense for Loading States
Route-Based Code Splitting
Best Practices
When to Use Lazy Loading
Good Candidates
Not Good Candidates
Component Isolation
The Problem: Cascading Rerenders
Solution: Component Isolation with React.memo
Understanding Render Boundaries
When to Isolate Components
Good Candidates for Isolation
Not Good Candidates
Best Practices
Context Optimization
The Problem: Context Rerenders
Solution 1: Split Contexts
Solution 2: Memoize Context Value
Solution 3: Context Scope Optimization
Best Practices
Virtualization
The Problem: Rendering Large Lists
What is Virtualization?
Solution: Using react-window
Installation
Basic Example
Performance Comparison
Without Virtualization
With Virtualization
When to Use Virtualization
Good Candidates
Not Good Candidates
Best Practices
Concurrent Rendering
The Problem: Blocking Updates
What is Concurrent Rendering?
useTransition: Marking Updates as Non-Urgent
Basic Usage
useDeferredValue: Deferring Values
Basic Usage
useTransition vs useDeferredValue
Best Practices
Using Keys Correctly
What are Keys?
The Problem: Using Index as Key
Why Index Keys Are Problematic
Solution: Use Stable, Unique Keys
What Makes a Good Key?
Examples of Good Keys
Keys and Memoization
When Index Keys Are Acceptable
Best Practices
Performance Checklist
Conclusion
Final Best Practices