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.
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!
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;
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;
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.
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>
);
}
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.
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.
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.
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.
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.
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>
);
}
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.
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.
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>
);
});
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.
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>;
});
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.
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;
}
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>
)
}
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>
);
}
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;
}
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.
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>
);
}
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>
);
}
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);
})
};
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>
);
}
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);
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
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;
}
}
These are the helper functions used across the examples in this repository. They simplify and support the main logic demonstrated in the examples.
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);
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;
}