Skip to content

unclexo/react-performance-principles

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 

Repository files navigation

React Performance Principles

A practical guide to writing high-performance React applications. This collection distills key performance principles—covering memory efficiency, concurrent rendering, component optimization, effective state management, and debugging strategies. You may use it to identify bottlenecks, apply proven optimizations, and build React apps that scale smoothly in production.

Give the project a ⭐ if you find it helpful. Pull requests are always welcome and appreciated! Follow me on Github, Medium and X.

🙏 A Quick Note

I'm currently working on a video series and a book based on this repo. I kindly ask that others refrain from creating similar long-form content (e.g., videos or books) based directly on it. Thanks for your understanding and support!

Memory Optimization Principles

1. Avoid Memory Leaks with Proper Cleanup

Click to expand

What

Always clean up subscriptions, event listeners, and timers in useEffect.

Why

Uncleared resources can keep components in memory, causing leaks that degrade performance over time.

When

  • Timers (setInterval, setTimeout)

  • Subscriptions (e.g. WebSocket, event listeners)

  • External libraries needing manual teardown

import { useEffect, useRef } from "react";
import { Chart } from "chart.js/auto";

const BarChart = ({ type = "bar", data, options = {}, plugins = [] }) => {
  const canvasRef = useRef(null);
  const chartInstanceRef = useRef(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    // Destroy existing chart before re-creating
    if (chartInstanceRef.current) {
      chartInstanceRef.current.destroy();
    }

    chartInstanceRef.current = new Chart(canvasRef.current, {
      type,
      data,
      options,
      plugins,
    });

    // Clean up chart instance on unmount
    return () => {
      chartInstanceRef.current?.destroy();
      chartInstanceRef.current = null;
    };
  }, [type, data, options, plugins]);

  return <canvas ref={canvasRef} />;
};

export default BarChart;

2. Avoid Inline Object and Function Props

Click to open

What

Inline object or function props create new references on each render, preventing child memoization.

Why

Triggers re-renders of memoized children, increasing memory usage and CPU load.

When

Passing props to deeply nested memoized components.

import { memo } from 'react';

const MemoizedButton = ({ onClick, buttonStyle, children }) => {
  console.log('[Render] MemoizedButton');

  return (
    <button style={buttonStyle} onClick={onClick}>
      {children}
    </button>
  );
};

export default memo(MemoizedButton);
// Bad practice

import React, { useState } from 'react';
import MemoizedButton from './MemoizedButton';

function ParentComponent() {
  const [counter, setCounter] = useState(0);

  // New function is created on every render
  const inlineHandleClick = () => {
    setCounter(prevCounter => prevCounter + 1);
  };

  // New object is created on every render
  const inlineButtonStyle = {
    backgroundColor: '#f44336',
    color: 'white',
    border: 'none',
    cursor: 'pointer',
    padding: '10px 20px',
    borderRadius: '5px',
    fontSize: '1em',
  };

  return (
    <div>
      <h3>Count: {counter}</h3>

      {/* Triggers re-render of MemoizedButton every time ParentComponent re-renders */}
      <MemoizedButton
        onClick={inlineHandleClick}
        buttonStyle={inlineButtonStyle}
      >
        Increment (BAD)
      </MemoizedButton>
    </div>
  );
}

export default ParentComponent;
// Good practice

import React, { useState, useCallback, useMemo } from 'react';
import MemoizedButton from './MemoizedButton';

function ParentComponent() {
  const [counter, setCounter] = useState(0);

  // Memoized object style (not recreated on each render)
  const stableButtonStyle = useMemo(() => ({
    backgroundColor: '#3368da',
    color: 'white',
    border: 'none',
    cursor: 'pointer',
    padding: '10px 20px',
    borderRadius: '5px',
    fontSize: '1em',
  }), []);

  // Memoized function (stable reference)
  const handleButtonClick = useCallback(() => {
    setCounter(prevCounter => prevCounter + 1);
  }, []);

  return (
    <div>
      <h3>Count: {counter}</h3>

      {/* Pass stable object and function props to avoid unnecessary re-renders */}
      <MemoizedButton
        onClick={handleButtonClick}
        buttonStyle={stableButtonStyle}
      >
        Increment Counter
      </MemoizedButton>
    </div>
  );
}

export default ParentComponent;

3. Avoid Large Anonymous Inline JSX Structures

Click to open

What

Avoid defining large or complex inline JSX trees directly inside render functions or return statements.

Why

Each render creates a new tree in memory, increasing GC pressure and rendering costs.

When

Rendering lists with complex subtrees, modals, or layouts dynamically.

// Bad practice

function Dashboard({ user, onLogout }) {
  const [showProfile, setShowProfile] = React.useState(false);

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <button onClick={() => setShowProfile(true)}>View Profile</button>

      {showProfile && (
        <div className="modal">
          <div className="modal-header">
            <h2>{user.name}'s Profile</h2>
            <button onClick={() => setShowProfile(false)}>X</button>
          </div>
          <div className="modal-body">
            <p>Email: {user.email}</p>
            <p>Location: {user.location}</p>
          </div>
          <div className="modal-footer">
            <button onClick={onLogout}>Log out</button>
          </div>
        </div>
      )}
    </div>
  );
}
// Good practice

import React, { useState, useCallback, memo } from 'react';

export default function Dashboard({ user, onLogout }) {
  const [showProfile, setShowProfile] = useState(false);

  const openProfile = useCallback(() => setShowProfile(true), []);

  const closeProfile = useCallback(() => setShowProfile(false), []);

  return (
    <div>
      <h1>Welcome, {user.name}!</h1>

      <button onClick={openProfile}>View Profile</button>

      {showProfile && (
        <UserProfileModal
          user={user}
          onClose={closeProfile}
          onLogout={onLogout}
        />
      )}
    </div>
  );
}

// Extract subtrees into separate memoized components
const UserProfileModal = memo(({ user, onClose, onLogout }) => {
  console.log('[Render] UserProfileModal');

  return (
    <div className="modal">
      <div className="modal-header">
        <h2>{user.name}'s Profile</h2>
        <button onClick={onClose}>X</button>
      </div>

      <div className="modal-body">
        <p>Email: {user.email}</p>
        <p>Location: {user.location}</p>
      </div>

      <div className="modal-footer">
        <button onClick={onLogout}>Log out</button>
      </div>
    </div>
  );
});

Note: This principle results in stable memory usage, cleaner renders, better diffing performance.

4. Use Debounce or Throttle to Limit Frequent Updates

Click to open

What

Wrap frequently called functions (like event handlers or API triggers) with debounce or throttle to limit how often they run.

Why

Uncontrolled rapid calls can create memory pressure, trigger repeated renders, and lead to performance issues like input lag or layout thrashing.

When

  • Handling scroll, resize, or mousemove events

  • Updating state or triggering effects on fast user input

  • Sending API requests based on user typing or filtering

import { debounce } from 'lodash';
import { useEffect, useMemo, useState } from 'react';

const API_ENDPOINT_PRODUCTS = 'https://dummyjson.com/products';

export default function UsingRateLimiter() {
  const [query, setQuery] = useState('');

  const [products, setProducts] = useState([]);

  const debouncedSearch = useMemo(() => {
    return debounce((searchTerm) => {
      fetch(`${API_ENDPOINT_PRODUCTS}/search?q=${encodeURIComponent(searchTerm)}`)
        .then((res) => res.json())
        .then((data) => {
          setProducts(data.products);
        });
    }, 250);
  }, []);

  useEffect(() => {
    const trimmedQuery = query.trim();

    if (trimmedQuery) {
      debouncedSearch(trimmedQuery);
    } else {
      setProducts([]);
    }

    return () => {
      debouncedSearch.cancel();
    };
  }, [query, debouncedSearch]);

  const handleChange = (event) => {
    setQuery(event.target.value);
  };

  return (
    <div>
      <h3>Product Search</h3>

      <input
        value={query}
        onChange={handleChange}
        placeholder="Search here..."
      />

      <ul>
        {products.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </div>
  );
}

5. Lazy Load Heavy Components

Click to expand

What

Use React.lazy and Suspense to code-split components and reduce initial bundle size.

Why

Reduces initial memory usage and improves performance by loading heavy components only when needed.

When

For routes, modals, rarely used dashboard widgets, or heavy components.

import { lazy, Suspense } from 'react';

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

export default function App() {
  return (
    <div>
      <h3>App</h3>

      <Suspense fallback={<div>Loading dashboard...</div>}>
        <HeavyDashboard />
      </Suspense>
    </div>
  );
}

Note: Always wrap lazy-loaded components with Suspense and provide a meaningful fallback.

6. Avoid Unintentional State Retention

Click to expand

What

Be careful when storing large objects or closures in state or refs.

Why

Stateful closures and refs can keep large objects in memory unnecessarily.

When

Storing computed data, event handlers, or external library instances.

import React, { useState, useMemo } from 'react';

export default function ProductFilter({ products }) {
  const [filter, setFilter] = useState('');

  // Avoid storing filteredProducts in state. Because it's derived from props and filter
  const filteredProducts = useMemo(() => {
    return products.filter(
      product => product.title.toLowerCase().includes(filter.toLowerCase())
    );
  }, [products, filter]);

  const handleChange = (event) => {
    setFilter(event.target.value);
  };

  return (
    <div>
      <h3>Product Filter</h3>

      <input 
        value={filter} 
        onChange={handleChange} 
        placeholder="Search here..."
      />

      <ul>
        {filteredProducts.map(product => (
          <li key={product.id}>{product.title}</li>
        ))}
      </ul>
    </div>
  );
}

Note: You should only use state for large data if you're using that data to render UI or control how the UI behaves.

7. Minimize Global State Retention

Click to expand

What

Avoid storing large objects in global singleton states (Redux, MobX) unnecessarily.

Why

Global state persists for app lifetime, preventing GC of stored objects.

When

Using Redux for temporary heavy data (e.g. search results, file blobs).

Dispatch a CLEAR action on route change:

dispatch({ type: "CLEAR_SEARCH_RESULTS" });

Note: Free search results from global memory when not needed.

8. Release Large State Before Navigation Away

Click to expand

What

Clear or reset large in-memory state when navigating away from heavy routes.

Why

Helps reduce peak memory usage and avoid holding unnecessary data in SPA apps.

When

Leaving pages with large datasets (e.g. dashboard with analytics data).

Use route change listener (e.g. in React Router) or unmounting component cleanup to reset state:

import { useEffect } from 'react';
import { useNavigate } from 'react-router';

export default function Dashboard({ setData }) {
  const navigate = useNavigate();

  useEffect(() => {
    // When user navigates away from Dashboard, clear the large data.
    return () => {
      setData(null);
    };
  }, [setData]);

  return (
    <div>
      <h3>Dashboard</h3>

      {/* Dashboard content using large data */}
    </div>
  );
}

Note: Free up memory when navigating away.

9. Avoid Closures Capturing Large Props or State

Click to expand

What

Don't define functions that close over large props or state unnecessarily.

Why

Closures hold references in memory, preventing GC of captured objects until closure itself is released.

When

Defining event handlers inside deeply nested components.

Pass only required data into handler to avoid closure capture:

// Bad practice

function Modal({ largeFormState }) {
  const handleSubmit = () => {
    // Captures the entire largeFormState unnecessarily
    const { name, email } = largeFormState;

    submitForm({ name, email });
  };

  return <button type="submit" onClick={handleSubmit}>Submit</button>;
}

Note: The handleSubmit closes over all of largeFormState, preventing garbage collection and potentially causing memory bloat if largeFormState is large or updated frequently.

// Good practice

export function Modal({ largeFormState }) {
  // Extract only the needed subset for the handler
  const { name, email } = largeFormState;

  // The closure only captures name and email
  const handleSubmit = () => {
    submitForm({ name, email });
  };

  return <button type="submit" onClick={handleSubmit}>Submit</button>;
}

Note: Closure only references subset, allowing rest of form state to be GC’d.

10. Use useReducer for Complex State Logic

Click to expand

What

Use useReducer instead of useState when state logic is complex or involves multiple sub-values.

Why

Avoids closures over large state objects, reduces unnecessary re-renders and memory retention, and enables more efficient garbage collection.

When

  • Managing forms with multiple fields

  • Toggling nested booleans or statuses

  • Implementing undo/redo logic

  • Managing large or nested state objects

  • Avoiding memory leaks in event handlers and component trees.

import { useReducer, useCallback } from 'react';
import MemoizedButton from '@/components/MemoizedButton';

const initialState = { count: 0 };

const INCREMENT = 'increment';
const DECREMENT = 'decrement';

function counterReducer(state, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    default:
      console.error(`Unhandled action type: ${action.type}`);
      return state;
  }
}

export default function StateLogicUsingReducer() {
  const [state, dispatch] = useReducer(counterReducer, initialState);

  const handleIncrement = useCallback(() => {
    dispatch({ type: INCREMENT });
  }, []);

  const handleDecrement = useCallback(() => {
    dispatch({ type: DECREMENT });
  }, []);

  return (
    <div>
      <h3>{state.count}</h3>
     
      <MemoizedButton onClick={handleIncrement}>Increment</MemoizedButton>

      <MemoizedButton onClick={handleDecrement}>Decrement</MemoizedButton>
    </div>
  );
}

11. Combine Context with Reducer for Complex State Management

Click to open

What

Combine useReducer with React Context to manage complex shared state in a scalable, centralized way.

Why

Reduces memory usage by centralizing state logic, avoiding repeated state copies, and preventing component closures from retaining large state trees.

When

Sharing large or complex state across multiple components where avoiding prop drilling and redundant memory retention is critical.

import React, { createContext, useReducer, useContext } from 'react';

const initialState = { count: 0 };

export const INCREMENT = 'increment';
export const DECREMENT = 'decrement';

function counterReducer(state, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    default:
      console.error(`Unhandled action type: ${action.type}`);
      return state;
  }
}

const CounterStateContext = createContext(null);

const CounterDispatchContext = createContext(null);

export function CounterProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, initialState);

  return (
    <CounterStateContext.Provider value={state}>
      <CounterDispatchContext.Provider value={dispatch}>
        {children}
      </CounterDispatchContext.Provider>
    </CounterStateContext.Provider>
  );
}

export function useCounterState() {
  const context = useContext(CounterStateContext);

  if (!context) { 
    throw new Error("useCounterState must be within a CounterProvider"); 
  }

  return context;
}

export function useCounterDispatch() {
  const context = useContext(CounterDispatchContext);

  if (!context) { 
    throw new Error("useCounterDispatch must be within a CounterProvider"); 
  }

  return context;
}
import { useCallback } from 'react';
import { CounterProvider, DECREMENT, INCREMENT, useCounterDispatch, useCounterState } from './CounterContext';
import MemoizedButton from '@/components/MemoizedButton';

function Counter() {
  const { count } = useCounterState();

  const dispatch = useCounterDispatch();

  const handleIncrement = useCallback(() => {
    dispatch({ type: INCREMENT });
  }, [dispatch]);

  const handleDecrement = useCallback(() => {
    dispatch({ type: DECREMENT });
  }, [dispatch]);

  return (
    <div>
      <h3>{count}</h3>

      <MemoizedButton onClick={handleIncrement}>Increment</MemoizedButton>

      <MemoizedButton onClick={handleDecrement}>Decrement</MemoizedButton>
    </div>
  );
}

export default function UsingContextWithReducer() {
  return (
    <CounterProvider>
      <Counter />
    </CounterProvider>
  );
}

This gives us a centralized, predictable, scalable state management system without external state libraries.

12. Minimize Unnecessary Re-Renders with Context Splitting

Click to expand

What

Split large contexts into multiple smaller contexts to avoid unnecessary re-renders of unrelated components.

Why

  • When a context value updates, all consumers re-render. If a context holds unrelated state, it will cause performance degradation as the app grows.

  • Prevents components from retaining large context objects in memory, reduces memory churn from repeated re-renders, and allows unused data to be garbage collected more efficiently.

When

  • Your components only depend on a subset

  • Your context contains multiple independent values

  • You notice performance issues due to large context updates

  • Memory bloat or stale closures arise from excessive re-renders

// Bad practice: Single Context with Unrelated State

import React, { createContext, useContext, useState } from 'react';

// One context holds multiple unrelated pieces of state (user & theme)
const AppContext = createContext(null);

export function AppProvider({ children }) {
  const [user, setUser] = useState('John');

  const [theme, setTheme] = useState('light');

  // Any component that needs either `user` or `theme` will re-render on *any* change
  const contextValue = { user, setUser, theme, setTheme };

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

export function useAppContext() {
  const context = useContext(AppContext);

  if (!context) {
    throw new Error("useAppContext must be used within an AppProvider");
  }

  return context;
}

Note: Changing the theme also re-renders components that only use the user.

// Good practice: Split into Multiple Contexts

import React, { createContext, useContext, useMemo, useState } from 'react';

// User context only handles user state
const UserContext = createContext(null);

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

  const contextValue = useMemo(() => ({ user, setUser }), [user]);

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

export function useUser() {
  const context = useContext(UserContext);

  if (!context) {
    throw new Error("useUser must be used within a UserProvider");
  }

  return context;
}

// Theme context only handles theme state
const ThemeContext = createContext(undefined);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const contextValue = useMemo(() => ({ theme, setTheme }), [theme]);

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

export function useTheme() {
  const context = useContext(ThemeContext);

  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }

  return context;
}

Note: Components depending only on user do not re-render when theme changes.

13. Prefer Primitive Context Values Where Possible

Click to expand

What

Pass primitive values (strings, numbers, booleans) in Context instead of objects/functions when frequent changes are expected.

Why

Objects/functions change reference on each update, triggering consumer re-renders, increasing memory usage and rendering costs.

When

Using Context to store dynamic but primitive data (e.g. theme name, locale string).

import { createContext, useContext } from 'react';

const LocaleContext = createContext('en');

export function LocaleProvider({ locale, children }) {
  if (typeof locale !== 'string') {
    throw new Error('LocaleProvider expects a string locale');
  }

  // No need to memoize value because it's primitive and stable
  return <LocaleContext.Provider value={locale}>
    {children}
  </LocaleContext.Provider>;
}

export function useLocale() {
  const context = useContext(LocaleContext);
  
  if (context === undefined) {
    throw new Error('useLocale must be used within a LocaleProvider');
  }

  return context;
}
import { useState, useCallback, useMemo, memo } from 'react';
import { LocaleProvider, useLocale } from './LocaleContext';

export default function UsingContextWithPrimitiveValue() {
  console.log('[Render] UsingContextWithPrimitiveValue');

  const [locale, setLocale] = useState('en');

  const switchToSpanish = useCallback(() => setLocale('es'), []);
  const switchToFrench = useCallback(() => setLocale('fr'), []);
  const switchToEnglish = useCallback(() => setLocale('en'), []);

  return (
    <LocaleProvider locale={locale}>
      <div>
        <Greeting />

        <LocaleSwitcher
          onSwitchToSpanish={switchToSpanish}
          onSwitchToFrench={switchToFrench}
          onSwitchToEnglish={switchToEnglish}
        />
      </div>
    </LocaleProvider>
  );
}

const LocaleSwitcher = memo(({
  onSwitchToSpanish,
  onSwitchToFrench,
  onSwitchToEnglish,
}) => {
  console.log('[Render] LocaleSwitcher');

  return (
    <div>
      <button onClick={onSwitchToSpanish}>Spanish</button>
      <button onClick={onSwitchToFrench}>French</button>
      <button onClick={onSwitchToEnglish}>English</button>
    </div>
  );
});

const Greeting = memo(() => {
    console.log('[Render] Greeting');
  
  const locale = useLocale();

  const greetings = useMemo(
    () => ({
      en: 'Hello',
      es: 'Hola',
      fr: 'Bonjour',
    }),
    []
  );

  return (
    <h1>{greetings[locale] || greetings.en}, there!</h1>
  );
});

14. Avoid Context for High-Frequency Updates

Click to open

What

Avoid using context for high-frequency changing values like mouse position or real-time data.

Why

All consumers re-render on context value change, leading to performance bottlenecks.

When

When dealing with:

  • Real-time data streams

  • Animation frames

  • Mouse or touch events

Alternative

Use dedicated state in consuming components, event emitters, or external stores (Zustand, Recoil, RxJS) for such use cases.

15. Windowing for Large Lists

Click to open

What

Use libraries like react-window to render only visible list items.

Why

Rendering thousands of DOM nodes consumes large memory and degrades performance.

When

Lists with hundreds or thousands of rows.

import { memo } from 'react';
import { FixedSizeList } from 'react-window';

export default function Contacts({ contacts }) {
  return (
    <FixedSizeList
      width="100%"
      height={500}
      itemCount={contacts.length}
      itemSize={35}
      itemData={contacts}
    >
      {ContactRow}
    </FixedSizeList>
  );
}

const ContactRow = memo(({ index, style, data }) => {
  return <div style={style}>{data[index]}</div>;
});

16. Avoid Memory Bloat with Fat useRef

Click to expand

What

Don't store large datasets in useRef as a cache without a disposal mechanism.

Why

Refs persist across component lifecycles and are not cleaned by React, potentially leading to leaks.

When

  • The large dataset is not needed for rendering

  • You need to cache or store large data during a component's lifecycle

  • You don't need reactivity (i.e. you're not rendering the data directly)

  • You want explicit control over the data's lifetime and cleanup

import React, { useEffect, useRef, useState } from 'react';

const API_URL = 'https://dummyjson.com/users?limit=100';

export default function UsingRef() {
  const largeDataRef = useRef(null);

  const [dataReady, setDataReady] = useState(false);

  useEffect(() => {
    fetch(API_URL)
      .then(res => res.json())
      .then(data => {
        largeDataRef.current = data.users; // Storing large dataset in ref
        setDataReady(true);
      });

      // React does not automatically clear refs on unmount.
      // If this component unmounts repeatedly and the ref is captured 
      // in a closure or retained, the large dataset may stay in memory. 
      // Clear manually if needed.
  }, []);

  return dataReady ? <p>Data Loaded</p> : <p>Loading...</p>;
}

Note: Use a size-limited cache or cleanup mechanism. Release memory when component unmounts.

Also note that, this works well if the data does not affect rendering directly. If it does, and you need the component to re-render when data is available, you'd need to trigger that manually — or use useState with a trimmed-down or computed version of the data.

17. Use WeakMap for Caching to Enable Garbage Collection

Click to expand

What

Use WeakMap instead of Map to cache objects when key references may become unreachable.

Why

WeakMap allows garbage collection of keys with no other references, avoiding retained memory.

When

Caching data linked to object lifecycles, such as DOM nodes or class instances.

const dimensionCache = new WeakMap();

function getDimensions(element) {
  if (dimensionCache.has(element)) {
    return dimensionCache.get(element);
  }

  const dimensions = { width: element.offsetWidth, height: element.offsetHeight };
  
  dimensionCache.set(element, dimensions);
  
  return dimensions;
}

18. Use Offscreen Canvas or Web Workers for Heavy Rendering

Click to expand

What

Offload heavy canvas rendering or computations to OffscreenCanvas or Web Workers to prevent blocking main thread and growing memory usage.

Why

Blocking the main thread can drop frames, mess up layout (layout thrashing), and slowly eat up memory.

When

  • Drawing big or complex charts

  • Processing images or video in real time

  • Running heavy animations or visual effects

// imageWorker.js

self.onmessage = async (event) => {
  const { canvas } = event.data;

  const context = canvas.getContext('2d');

  context.fillStyle = 'blue';

  // Heavy rendering logic
  // Filling millions of pixels with blue (if the canvas is large)
  context.fillRect(0, 0, canvas.width, canvas.height);

  self.postMessage({ status: 'done' });
};
import React, { useEffect, useRef, useState } from 'react';

export default function CanvasEditor() {
  const canvasRef = useRef(null);

  const [isProcessing, setIsProcessiong] = useState(true);

  useEffect(() => {
    // Offload rendering a large canvas to web worker
    const worker = new Worker(new URL('../workers/imageWorker.js', import.meta.url), {
      type: 'module',
    });

    const canvas = canvasRef.current;
    const offscreen = canvas.transferControlToOffscreen();

    worker.postMessage({
      canvas: offscreen
    }, [offscreen]);

    worker.onmessage = (event) => {
      if (event.data.status = 'done') {
        setIsProcessiong(false);
      }
    };

    // Terminate worker on unmount
    return () => {
      worker.terminate();
      canvasRef.current = null;
    }
  }, []);

  return (
    <div>
      {isProcessing && <p>Processing image in background...</p>}
  
      <canvas ref={canvasRef} width={800} height={10000} />
    </div>
  )
}

19. Pool DOM Elements to Reduce Reallocation

Click to expand

What

Reuse DOM elements instead of recreating them to reduce frequent memory allocation and GC cycles.

Why

Frequent element creation/deletion causes DOM churn and memory spikes.

When

  • Showing/hiding modals, tooltips, or dropdowns

  • Repeatedly mounting the same component type

  • Animating elements in/out of view

// Bad practice

{showTooltip && <Tooltip content="React performance principles" />}
// Good practice

<Tooltip visible={showTooltip} content="React performance principles" />
function Tooltip({ visible, content }) {
  return (
    {/* Keeps DOM node mounted */}
    <div style={{ display: visible ? 'block' : 'none' }}>
      {content}
    </div>
  );
}

20. Release Detached DOM Nodes in Custom Hooks

Click to expand

What

Ensure any custom hook that creates DOM nodes releases them when no longer used.

Why

Detached nodes remain in memory if references are retained in closures or refs.

When

Creating portals, overlays, or off-DOM manipulations in hooks.

import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

export default function usePortal() {
  const elementRef = useRef(document.createElement('div'));

  useEffect(() => {
    const element = elementRef.current;

    document.body.appendChild(element);

    // Remove the element on unmount
    return () => {
      document.body.removeChild(element);
    };
  }, []);

  return elementRef.current;
}

Concurrency Principles

What is Concurrent Rendering?

Click to expand

What

Concurrent Rendering is React’s ability to interrupt and pause rendering work, making apps more responsive by prioritizing urgent updates.

Why

It prevents blocking the main thread with expensive renders, allowing smoother UI interactions.

When

  • Rendering large lists or heavy components

  • Implementing transitions or deferred updates

Note: Requires React 18+ and usage of Concurrent features like startTransition and Suspense concurrent behaviors.

1. Schedule Non-Urgent UI Updates with Concurrent Transitions

Click to expand

What

Wrap non-urgent state updates with startTransition to let React schedule them concurrently with high-priority updates.

Why

Prevents UI jank when a state update triggers expensive rendering logic.

Without concurrent transitions, all updates block the main thread equally. Using startTransition tells React that some updates (like rendering filtered lists or charts) can be delayed to keep the UI responsive.

When

  • Filtering, sorting, or paginating large datasets based on user input

  • Updating content that takes time to render (e.g. visualizations or search results)

  • Prioritizing quick feedback (typing, clicking) over slow UI changes

'use client';

import { debounce } from 'lodash';
import { useEffect, useMemo, useState, useTransition } from 'react';

const API_ENDPOINT_PRODUCTS = 'https://dummyjson.com/products';

export default function ProductSearch() {
  const [query, setQuery] = useState('');

  const [products, setProducts] = useState([]);

  const [isPending, startTransition] = useTransition();

  // This is an urgent update because we want keystrokes 
  // to reflect instantly in the input field.
  const handleChange = (event) => {
    setQuery(event.target.value);
  };

  const debouncedSearch = useMemo(() => {
    return debounce((searchTerm) => {
      fetch(`${API_ENDPOINT_PRODUCTS}/search?q=${encodeURIComponent(searchTerm)}`)
        .then((res) => res.json())
        .then((data) => {
          // Sart a transition to defer state update
          // with the new search result as a non-urgent task.
          // This prevents blocking the UI thread, keeping input typing smooth.
          startTransition(() => {
            setProducts(data.products ?? []);
          });
        })
        .catch((error) => {
          // Defer UI update
          startTransition(() => {
            setProducts([]);
          });
        });
    }, 250);
  }, [startTransition]);

  useEffect(() => {
    const trimmedQuery = query.trim();

    if (trimmedQuery) {
      debouncedSearch(trimmedQuery);
    } else {
      // Defer UI update
      startTransition(() => {
        setProducts([]);
      });
    }

    return () => {
      debouncedSearch.cancel();
    };
  }, [query, debouncedSearch, startTransition]);

  return (
    <div>
      <h3>Product Search</h3>

      <input
        value={query}
        onChange={handleChange}
        placeholder="Search here..."
      />

      {/* isPending === true when React is deferring that update */}
      {isPending && <p>Loading...</p>}

      {products.length > 0 && (
        <ul>
          {products.map((product) => (
            <li key={product.id}>{product.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

2. Defer Expensive Renders with useDeferredValue

Click to expand

What

Use useDeferredValue to delay updating parts of the UI that depend on rapidly changing state (i.e. typing), letting React render them at lower priority.

Why

Rapid state updates can trigger expensive renders and memory churn. useDeferredValue allows React to pause those renders and update them when the main thread is free, improving responsiveness and consistency.

When

Use it when:

  • Your component receives a value (like a prop or state) that causes expensive rendering

  • You don't control the update (like props passed from a parent)

  • You want the UI to stay responsive and interactive, even while slow updates are happening

Avoid it when:

  • The deferred value causes the UI to look inconsistent or laggy

  • You’re dealing with critical data (like form validation or security-related input)

  • You can handle things better with useTransition, which gives more control over what gets delayed

'use client';

import { useState, useDeferredValue } from 'react';
import ProductSearch from './ProductSearch';

export default function UsingDeferredValue() {
  const [query, setQuery] = useState('');

  // Create a deferred version of the query to avoid blocking user input
  // when rendering the heavy list. React may delay updating this value
  // during high-priority renders (like typing), keeping the UI responsive.
  const deferredQuery = useDeferredValue(query);

  const handleChange = (event) => {
    setQuery(event.target.value);
  };

  return (
    <div>
      <h3>Product Search</h3>

      <input
        value={query}
        onChange={handleChange}
        placeholder="Search here..."
      />

      <ProductSearch query={deferredQuery} />
    </div>
  );
}
'use client';

import { debounce } from 'lodash';
import { useEffect, useMemo, useState } from 'react';

const API_ENDPOINT_PRODUCTS = 'https://dummyjson.com/products';

export default function ProductSearch({ query }) {
  const [products, setProducts] = useState([]);

  const debouncedSearch = useMemo(() => {
    return debounce((searchTerm) => {
      fetch(`${API_ENDPOINT_PRODUCTS}/search?q=${encodeURIComponent(searchTerm)}`)
        .then((res) => res.json())
        .then((data) => {
          setProducts(data.products);
        })
        .catch((err) => {
          setProducts([]);
        });
    }, 250);
  }, []);

  useEffect(() => {
    const trimmedQuery = query.trim();

    if (trimmedQuery) {
      debouncedSearch(trimmedQuery);
    } else {
      setProducts([]);
    }

    return () => {
      debouncedSearch.cancel();
    };
  }, [query, debouncedSearch]);

  return (
    <div>
      {products.length > 0 && (
        <ul>
          {products.map(product => (
            <li key={product.id}>{product.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

3. Update UI Optimistically with Rollback

Click to expand

What

Optimistically apply a UI update assuming a successful result, then rollback if the server responds with an error.

Why

Provides a fast, responsive user experience by minimizing perceived latency in async operations.

When

Use it when:

  • User actions are frequent and expected to succeed (e.g., likes, checkboxes, to-do edits)

  • Latency is noticeable and hurts UX

  • You can confidently restore the previous state on failure

Avoid it when:

  • Data is highly critical (e.g. financial transactions)

  • It's difficult to revert changes cleanly

'use client';

import { useState } from 'react';

export default function PostCard({ post }) {
  const [isFavorite, setIsFavorite] = useState(post.isFavorite);

  const [error, setError] = useState(null);

  const toggleFavorite = async () => {
    const previousValue = isFavorite;
    const newValue = !previousValue;

    // Optimistically update the UI
    setIsFavorite(newValue);

    setError(null);

    try {
      await updateFavoriteStatus(newValue);
    } catch (err) {
      // Rollback on error
      setIsFavorite(previousValue);
      
      setError('Failed to update favorite status. Please try again.');
    }
  };

  return (
    <div>
      <h3>{post.title}</h3>

      <button onClick={toggleFavorite}>
        {isFavorite ? 'Unfavorite' : 'Favorite'}
      </button>
      
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}
// Mock API with 20% failure rate
const updateFavoriteStatus = (status) => {
  const delay = Math.random() * 500 + 200;

  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.8) {
        reject(new Error('Could not update favorite status.'))
      } else {
        resolve({ success: true, message: 'Successful!' });
      }
    }, delay);
  })
};

4. Prevent Race Conditions with AbortController

Click to expand

What

Use AbortController to cancel outdated concurrent async operations to ensure only the latest response updates the state.

Why

Prevents race conditions where slower, outdated responses replace newer ones — ensuring UI stays in sync with the latest state.

When

  • Typing in a search bar that triggers API requests

  • Switching filters or tabs that fetch new data

  • Navigating between pages with fetch-dependent content

  • Auto-saving drafts while the user is typing

  • Any scenario where newer requests should supersede older ones.

'use client';

import { debounce } from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';

const API_ENDPOINT_PRODUCTS = 'https://dummyjson.com/products';

export default function ProductSearch() {
  const [query, setQuery] = useState('');

  const [products, setProducts] = useState([]);
  
  const [isLoading, setIsLoading] = useState(false);

  const abortControllerRef = useRef(null);

  const debouncedFetch = useMemo(() => {
    return debounce((searchTerm) => {
      // Cancel any previous request
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      // Create new AbortController for this request
      const controller = new AbortController();
      abortControllerRef.current = controller;

      setIsLoading(true);

      fetch(`${API_ENDPOINT_PRODUCTS}/search?q=${encodeURIComponent(searchTerm)}`, {
        signal: controller.signal,
      })
        .then((res) => res.json())
        .then((data) => {
          // Only update if this request wasn't cancelled
          setProducts(data.products ?? []);
        })
        .catch((err) => {
          if (err.name !== 'AbortError') {
            setProducts([]);
          }
        })
        .finally(() => {
          if (!controller.signal.aborted) {
            setIsLoading(false);
          }
        });
    }, 250);
  }, []);

  useEffect(() => {
    const trimmedQuery = query.trim();

    if (trimmedQuery) {
      debouncedFetch(trimmedQuery);
    } else {
      setProducts([]);
      
      setIsLoading(false);

      // Cancel in-flight fetch
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    }

    return () => {
      debouncedFetch.cancel();
    };
  }, [query, debouncedFetch]);

  return (
    <div>
      <h3>Product Search</h3>

      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search here..."
      />

      {isLoading && <p>Loading...</p>}

      {products.length > 0 && (
        <ul>
          {products.map((product) => (
            <li key={product.id}>{product.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

5. Fetch Data Concurrently with Suspense and Error Boundaries

Click to expand

What

Use React.Suspense and ErrorBoundary to declaratively manage loading and error states for independent, async UI components.

Why

Allows each component to load and fail in isolation, improving resilience and user experience in dashboards or complex UIs.

When

  • Displaying multiple data cards that load independently

  • Preventing one failed fetch from crashing the whole view

  • Coordinating refresh across multiple components

  • Declaratively handling loading spinners with Suspense

  • Demonstrating fault tolerance with random failures

'use client';

import { Suspense, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import PostData from './PostData';
import UserData from './UserData';
import ErrorFallbackComponent from './ErrorFallbackComponent';

export default function ConcurrentDataFetching() {
  const [userId, setUserId] = useState(1);
  const [postId, setPostId] = useState(1);

  return (
    <div className="p-6 max-w-6xl mx-auto">
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <ErrorBoundary 
          FallbackComponent={ErrorFallbackComponent}
          onReset={() => setUserId(1)}
          resetKeys={[userId]}
        >
          <Suspense fallback={<p>Loading user...</p>}>
            <UserData userId={userId} onChange={setUserId} />
          </Suspense>
        </ErrorBoundary>
        
        <ErrorBoundary
          FallbackComponent={ErrorFallbackComponent}
          onReset={() => setPostId(1)}
          resetKeys={[postId]}
        >
          <Suspense fallback={<p>Loading post...</p>}>
            <PostData postId={postId} onChange={setPostId} />
          </Suspense>
        </ErrorBoundary>
      </div>
    </div>
  );
}

ErrorFallbackComponent.jsx

export default function ErrorFallbackComponent({ error, resetErrorBoundary }) {
  return (
    <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
      <h3 className="text-red-800 font-semibold mb-2">Something went wrong!</h3>
      <p className="text-red-600 text-sm mb-3">{error?.message}</p>
      <button
        onClick={resetErrorBoundary}
        className="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
      >
        Reset
      </button>
    </div>
  );
}

UserData.jsx

'use client';

import { useSuspenseFetch } from '@/lib/hooks';
import { memo } from 'react';

const API_ENDPOINT_USERS = 'https://jsonplaceholder.typicode.com/users';

function UserData({ userId, onChange }) {
  console.log('[Render] UserData');

  // Suspense-enabled data source
  const user = useSuspenseFetch(`${API_ENDPOINT_USERS}/${userId}`);

  const handleChange = (event) => {
    onChange(parseInt(event.target.value, 10) || 1);
  };

  return (
    <div className="bg-white p-6 rounded-lg shadow-md mb-4">
      <h2 className="text-2xl font-semibold text-gray-800 mb-4">User Data</h2>

      <div className="flex items-center space-x-4 mb-4">
        <label htmlFor="userId" className="text-gray-700">User ID:</label>
        <input
          id="userId"
          type="number"
          min="1"
          max="10"
          value={userId}
          onChange={handleChange}
          className="p-2 text-gray-700 border border-gray-300 rounded w-20"
        />
      </div>

      <h3 className="text-xl font-semibold text-gray-800 mb-2">User Details</h3>
      
      <p className="text-gray-700"><strong>Name:</strong> {user.name}</p>
      <p className="text-gray-700"><strong>Email:</strong> {user.email}</p>
      <p className="text-gray-700"><strong>Phone:</strong> {user.phone}</p>
      <p className="text-gray-700"><strong>Website:</strong> {user.website}</p>
    </div>
  );
}

export default memo(UserData);

PostData.jsx

'use client';

import { useSuspenseFetch } from '@/lib/hooks';
import { memo } from 'react';

const API_ENDPOINT_POSTS = 'https://jsonplaceholder.typicode.com/posts';

function PostData({ postId, onChange }) {
  console.log('[Render] PostData');

  // Suspense-enabled data source
  const post = useSuspenseFetch(`${API_ENDPOINT_POSTS}/${postId}`);

  const handleChange = (event) => {
    onChange(parseInt(event.target.value, 10) || 1);
  };

  return (
    <div className="bg-white p-6 rounded-lg shadow-md">
      <h2 className="text-2xl font-semibold text-gray-800 mb-4">Post Data</h2>

      <div className="flex items-center space-x-4 mb-4">
        <label htmlFor="postId" className="text-gray-700">Post ID:</label>
        <input
          id="postId"
          type="number"
          min="1"
          max="100"
          value={postId}
          onChange={handleChange}
          className="p-2 text-gray-700 border border-gray-300 rounded w-20"
        />
      </div>

      <h3 className="text-xl font-semibold text-gray-800 mb-2">Post Details</h3>
      
      <p className="text-gray-700"><strong>Title:</strong> {post.title}</p>
      <p className="text-gray-700"><strong>Body:</strong> {post.body}</p>
    </div>
  );
}

export default memo(PostData);

Miscellaneous

1. Optimize Re-Renders with Key Prop Correctly

Click to expand

What

Always use unique and stable keys when rendering lists.

Why

Keys help React identify which items have changed, improving rendering performance and avoiding bugs.

When

Any time you render a list of components.

import React from 'react';

const todos = [
  { id: 1, title: 'Learn React Performance Principles' },
  { id: 2, title: 'Build High-performance React Apps' },
];

export default function TodoList() {
  return (
    <ul>
      {todos.map((todo) => (
        {/* Pass unique ID */}
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Note: Avoid using index as key unless:

  • The list is static
  • No items are reordered, inserted, or deleted

2. Error Boundaries for Robust Apps

Click to expand

What

Implement Error Boundaries using class components to catch rendering errors in the component tree.

Why

Prevents the entire React app from crashing due to a single component failure.

When

  • Top-level layouts

  • Critical modules (dashboards, admin panels)

import React from 'react';

const initialState = {
  error: null,
};

class ErrorBoundary extends React.Component {
  state = initialState;

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { error: error };
  }

  componentDidCatch(error, info) {
    if (this.props.onError) {
      this.props.onError(error, info);
    }

    // For production, you could log this to a service like Sentry, LogRocket, etc.
    console.error("ErrorBoundary caught an error:", error, info);
  }

  resetErrorBoundary = () => {
    if (this.props.onReset) {
      this.props.onReset();
    }

    this.setState(initialState);
  };

  render() {
    if (this.state.error) {
      const { FallbackComponent } = this.props;

      return (
        <FallbackComponent
          error={this.state.error}
          resetErrorBoundary={this.resetErrorBoundary}
        />
      );
    }

    return this.props.children;
  }
}

Code Snippets

These are the helper functions used across the examples in this repository. They simplify and support the main logic demonstrated in the examples.

Memoized Button Component

import { memo } from 'react';

const MemoizedButton = ({ onClick, buttonStyle, children }) => {
  console.log('[Render] MemoizedButton');

  return (
    <button style={buttonStyle} onClick={onClick}>
      {children}
    </button>
  );
};

export default memo(MemoizedButton);

Custom hook that integrates with Suspense and Error Boundaries

import { useMemo } from 'react';

const resourceCache = new Map();

export function useSuspenseFetch(url, fetcher = defaultFetcher, options = {}) {
  const { forceRefresh = false } = options;

  const resource = useMemo(() => {
    if (forceRefresh || !resourceCache.has(url)) {
      const promise = fetcher(url, options);
      resourceCache.set(url, createSuspenseResource(promise));
    }

    return resourceCache.get(url);
  }, [url, fetcher, forceRefresh]);

  return resource.read();
};

async function defaultFetcher(url, options) {
  const response = await fetch(url, options);

  if (!response.ok) {
    throw new Error('Could not handle the request.')
  }

  return response.json();
}

function createSuspenseResource(promise) {
  let result;
  let status = 'pending';

  const suspender = promise.then(
    (data) => {
      result = data;
      status = 'success';
    },
    (error) => {
      result = error;
      status = 'error';
    }
  );

  const resource = {
    read() {
      // Activate Suspense
      if (status === 'pending') {
        throw suspender;
      }

      // Activate ErrorBoundary
      if (status === 'error') {
        throw result;
      }
      
      return result;
    }
  };

  return resource;
}

About

A practical guide to writing high-performance React applications.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published