Last month I cut a dashboard’s render time from 800ms to 45ms. The fix wasn’t adding more memoization. It was removing the useless memoization and fixing the two components that actually mattered.
React performance issues stem from unnecessary re-renders, not missing optimizations. Most slow React apps I’ve debugged share the same pattern: developers wrap everything in useMemo and useCallback without measuring first, then wonder why nothing improved. The Virtual DOM’s reconciliation algorithm is already fast. Your job is to stop triggering it unnecessarily.
This guide covers the exact framework I use to diagnose and fix React performance problems: profile first, identify the actual bottleneck, apply the right fix, and verify the improvement. If you’re still getting comfortable with React’s hook system, start with my complete guide to React Hooks first.
TL;DR: React Performance in 60 Seconds
The Framework: Profile → Identify → Fix → Verify. Never optimize without measuring first.
Quick Fixes by Problem Type:
- Laggy inputs/interactions → Check for unnecessary re-renders. Use React.memo, component composition, or state colocation
- UI freezes on updates → Expensive calculations in render. Use useMemo or useDeferredValue
- Slow initial load → Bundle too large. Code split with React.lazy at route boundaries
- Janky scrolling → Too many DOM nodes. Virtualize lists over 100 items with react-window
Key Numbers: Target renders under 16ms (60fps). Main bundle under 200KB gzipped. INP under 200ms.
React 19+: Enable React Compiler to auto-memoize. Remove manual useMemo/useCallback.
Why React Apps Get Slow: Understanding the Virtual DOM and Reconciliation
Before optimizing, you need to understand what you’re optimizing. React’s performance model centers on two concepts: the Virtual DOM and reconciliation.
What is the Virtual DOM?
The Virtual DOM is React’s in-memory representation of the actual browser DOM. When your component’s state or props change, React creates a new Virtual DOM tree, compares it to the previous tree (a process called reconciliation), and calculates the minimum set of changes needed to update the real DOM.
What is reconciliation?
Reconciliation is React’s diffing algorithm that determines what changed between renders. React Fiber, the current reconciliation engine, walks through your component tree and compares each node. When it finds differences, it schedules DOM updates.
The performance insight: reconciliation itself is fast. The problem is when you trigger it unnecessarily, or when the components being reconciled do expensive work during their render phase.
The Four Categories of React Performance Problems
| Problem Type | Symptoms | Root Cause | Primary Fix |
|---|---|---|---|
| Unnecessary re-renders | Laggy inputs, slow interactions | Parent state changes cascade to children | React.memo, component composition, state colocation |
| Expensive calculations | UI freezes during updates | Heavy computation in render phase | useMemo, Web Workers, debouncing |
| Large bundle size | Slow initial load, blank screen | All code loads upfront | Code splitting with React.lazy |
| DOM overload | Janky scrolling, high memory | Too many DOM nodes | Virtualization with react-window |
Quick Performance Audit
Before diving into profiling tools, run this checklist:
# Check your production bundle size
npm run build
# Target: main bundle under 200KB gzipped
# Audit with Lighthouse
# Chrome DevTools > Lighthouse > Performance
# Target scores:
# - LCP (Largest Contentful Paint): < 2.5s
# - INP (Interaction to Next Paint): < 200ms
# - CLS (Cumulative Layout Shift): < 0.1
If your JavaScript bundle exceeds 500KB gzipped, prioritize code splitting. If Lighthouse shows poor INP scores, focus on re-render optimization.
React DevTools Profiler: Finding the Real Bottlenecks
The React DevTools Profiler is your primary diagnostic tool. It records component render times, shows why components re-rendered, and visualizes the render cascade through your component tree.
Setting Up the Profiler
Install the React Developer Tools browser extension for Chrome, Firefox, or Edge. Open your app, then navigate to the Profiler tab in DevTools.
Configure these settings before recording:
- Click the gear icon in the Profiler panel
- Enable “Record why each component rendered while profiling”
- In the Components tab, enable “Highlight updates when components render”
The highlight feature flashes components as they re-render, making unnecessary updates immediately visible. If you see your entire app flashing on every keystroke, you’ve found your problem.
Recording and Reading a Profile
To capture meaningful data:
- Click the blue “Record” button
- Perform the slow interaction (click a button, type in an input, navigate between pages)
- Click “Stop”
- Analyze the flame graph
Reading the flame graph:
Each horizontal bar represents a component. Width indicates render duration. Colors show relative performance:
| Color | Render Time | Action Needed |
|---|---|---|
| Gray | Did not render | None (good) |
| Blue/Green | Under 16ms | None (acceptable) |
| Yellow | 16-50ms | Investigate if frequent |
| Orange/Red | Over 50ms | Optimize immediately |
Click any component to see why it rendered:
- “The parent component rendered”
- “Props changed: items, onClick”
- “State changed”
- “Context changed”
// Example: Profiler output showing a slow component tree
// App: 2ms
// └─ Dashboard: 150ms ← Investigate this
// ├─ Header: 1ms
// ├─ DataGrid: 145ms ← Root cause found
// └─ Footer: 1ms
Advanced Profiling: why-did-you-render
For deeper re-render debugging, add why-did-you-render to your development build:
// src/wdyr.js (import this first in your app)
import React from "react";
if (process.env.NODE_ENV === "development") {
const whyDidYouRender = require("@welldone-software/why-did-you-render");
whyDidYouRender(React, {
trackAllPureComponents: true,
});
}
// Then on specific components:
MyComponent.whyDidYouRender = true;
This logs detailed information about why components re-rendered, including prop comparisons that show exactly which prop changed.
React Memoization: When to Use React.memo, useMemo, and useCallback
What is memoization in React?
Memoization is a performance optimization technique that caches results so React can skip redundant work on subsequent renders. React provides three memoization tools: React.memo for components, useMemo for computed values, and useCallback for function references.
Memoization has overhead. The shallow comparison runs on every render, and incorrect dependency arrays cause stale data bugs. Profile your app first to confirm a component is a bottleneck before applying memoization. For a deeper understanding of how these hooks work under the hood, see my complete guide to React Hooks.
Memoization Comparison Chart
| API | What It Caches | Use When | Avoid When |
|---|---|---|---|
React.memo | Component render output | Parent re-renders often with same props | Props change on every render |
useMemo | Computed value | Expensive calculations (filtering, sorting large arrays) | Simple arithmetic or string operations |
useCallback | Function reference | Passing callbacks to memoized children or as hook dependencies | Function isn’t used as a dependency anywhere |
| React Compiler | Everything (automatic) | React 19+ projects with compiler enabled | React 18 or earlier |
React.memo for Components
What is React.memo?
React.memo is a higher-order component that wraps a functional component and prevents re-renders when props are shallowly equal to the previous render.
import { memo } from "react";
// Without memo: re-renders whenever parent renders
function ExpensiveList({ items, onItemClick }) {
console.log("ExpensiveList rendered");
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
}
// With memo: only re-renders when items or onItemClick reference changes
const MemoizedExpensiveList = memo(ExpensiveList);
When to use React.memo:
- Component renders frequently with the same props
- Component has expensive render logic (complex calculations, many children)
- Profiler confirms the component re-renders unnecessarily
When to skip React.memo:
- Component always receives new props (memo comparison is wasted work)
- Component is simple (renders a few elements)
- You haven’t profiled to confirm it’s a bottleneck
Custom Comparison Functions
By default, React.memo uses shallow equality. For complex props, provide a custom comparison:
const MemoizedChart = memo(ChartComponent, (prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
// Return false if props differ (allow re-render)
return (
prevProps.data.length === nextProps.data.length &&
prevProps.data.every((item, i) => item.id === nextProps.data[i].id)
);
});
Warning: Returning true incorrectly causes stale renders. When in doubt, stick with shallow equality and fix prop stability instead.
useMemo for Expensive Calculations
What is useMemo?
useMemo is a React hook that caches the result of a calculation between renders. It only recalculates when its dependencies change.
import { useMemo } from "react";
function ProductList({ products, filters }) {
// Without useMemo: filters 10,000 products on every render
// const filtered = products.filter(p => matchesFilters(p, filters));
// With useMemo: only recalculates when products or filters change
const filteredProducts = useMemo(() => {
console.log("Filtering products...");
return products.filter((p) => matchesFilters(p, filters));
}, [products, filters]);
return (
<ul>
{filteredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</ul>
);
}
Good candidates for useMemo:
- Filtering or sorting arrays with 100+ items
- Complex data transformations or aggregations
- Creating objects passed to memoized children
- Derived state calculations
Skip useMemo for:
- Simple calculations (adding numbers, string concatenation)
- Values that change on every render anyway
- Primitive values that are cheap to recreate
useCallback for Stable Function References
What is useCallback?
useCallback is a React hook that returns the same function reference between renders, unless its dependencies change. It’s essentially useMemo for functions.
import { useCallback, memo } from "react";
function Parent({ items }) {
// Without useCallback: new function reference on every render
// This breaks memoization on MemoizedList
// const handleClick = (id) => console.log('Clicked:', id);
// With useCallback: same function reference between renders
const handleClick = useCallback((id) => {
console.log("Clicked:", id);
}, []); // Empty deps = function never changes
return <MemoizedList items={items} onItemClick={handleClick} />;
}
const MemoizedList = memo(function List({ items, onItemClick }) {
// Only re-renders when items or onItemClick reference actually changes
return items.map((item) => (
<button key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</button>
));
});
useCallback is only useful when:
- The function is passed to a memoized child component (React.memo)
- The function is a dependency of another hook (useEffect, useMemo)
If neither condition applies, useCallback adds overhead without benefit.
React 19: useTransition and useDeferredValue
React 19 introduced hooks that solve performance problems without memoization:
useTransition marks state updates as non-urgent, allowing React to keep the UI responsive:
import { useTransition, useState } from "react";
function SearchResults({ query }) {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
function handleSearch(input) {
// Urgent: update the input field immediately
setQuery(input);
// Non-urgent: defer the expensive filtering
startTransition(() => {
setResults(filterLargeDataset(input));
});
}
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</>
);
}
useDeferredValue defers re-rendering of expensive components:
import { useDeferredValue, memo } from "react";
function App() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{/* ExpensiveList re-renders with deferred value, keeping input responsive */}
<ExpensiveList query={deferredQuery} />
</>
);
}
React Compiler: Automatic Memoization
React Compiler (formerly React Forget) automatically adds memoization at build time. It shipped as stable in late 2025 and is production-ready for React 19+ projects.
// Before: Manual memoization everywhere
const MemoizedComponent = memo(function Component({ data }) {
const processed = useMemo(() => expensiveCalc(data), [data]);
const handler = useCallback(() => doSomething(data), [data]);
return <Child data={processed} onClick={handler} />;
});
// After: React Compiler handles it automatically
function Component({ data }) {
const processed = expensiveCalc(data);
const handler = () => doSomething(data);
return <Child data={processed} onClick={handler} />;
}
If you’re starting a new project on React 19+, enable the compiler instead of manual memoization. It’s more granular and less error-prone than hand-written optimizations.
Advanced Patterns: Component Composition and State Colocation
Before reaching for memoization, consider architectural patterns that prevent unnecessary re-renders by design. These patterns often eliminate performance problems without adding complexity.
Component Composition: The Children Pattern
What is component composition?
Component composition uses the children prop to pass pre-rendered elements into a component. Because children are created in the parent scope, they don’t re-render when the wrapper component’s state changes.
// ❌ Problem: ExpensiveChild re-renders on every mouse move
function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPosition({ x: e.clientX, y: e.clientY })}>
<Cursor position={position} />
<ExpensiveChild /> {/* Re-renders on every mouse move! */}
</div>
);
}
// ✅ Solution: Lift ExpensiveChild out via composition
function MouseTracker({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPosition({ x: e.clientX, y: e.clientY })}>
<Cursor position={position} />
{children} {/* Doesn't re-render - created in parent scope */}
</div>
);
}
function App() {
return (
<MouseTracker>
<ExpensiveChild /> {/* Only renders once */}
</MouseTracker>
);
}
This pattern works because children is evaluated in App’s render, not MouseTracker’s. When MouseTracker re-renders due to mouse movement, children is already a stable React element reference.
State Colocation: Move State Closer to Where It’s Used
What is state colocation?
State colocation means keeping state as close as possible to the components that use it. When state lives too high in the component tree, updates cascade to siblings that don’t need the data.
// ❌ Problem: State too high - SearchInput changes re-render ProductGrid
function ProductPage() {
const [searchQuery, setSearchQuery] = useState("");
const [products, setProducts] = useState([]);
return (
<>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<ProductGrid products={products} /> {/* Re-renders on every keystroke */}
<Sidebar />
</>
);
}
// ✅ Solution: Colocate search state with the component that uses it
function ProductPage() {
const [products, setProducts] = useState([]);
return (
<>
<SearchSection products={products} />{" "}
{/* Contains its own search state */}
<Sidebar /> {/* Never re-renders from search */}
</>
);
}
function SearchSection({ products }) {
const [searchQuery, setSearchQuery] = useState("");
const filteredProducts = useMemo(
() => products.filter((p) => p.name.includes(searchQuery)),
[products, searchQuery],
);
return (
<>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<ProductGrid products={filteredProducts} />
</>
);
}
When to colocate state:
- Only one component (or its children) uses the state
- Sibling components re-render unnecessarily when state changes
- You’re considering adding React.memo to prevent sibling re-renders
For complex state that multiple components need, consider a state management library. See my comparison of Redux, Zustand, and Jotai for guidance on choosing the right tool.
Selective Hydration for SSR Applications
What is selective hydration?
Selective hydration is a React 18+ feature that allows different parts of your server-rendered page to hydrate independently. Instead of waiting for all JavaScript to load before any interactivity, users can interact with components as they become ready.
import { Suspense } from "react";
function App() {
return (
<html>
<body>
{/* Hydrates immediately - critical for interaction */}
<Header />
<SearchBar />
{/* Hydrates when JavaScript loads - can be delayed */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Lowest priority - hydrates last */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</body>
</html>
);
}
How selective hydration improves performance:
- Prioritized interactivity: If a user clicks on a Suspense boundary before it hydrates, React prioritizes hydrating that component
- Non-blocking hydration: Slow components don’t block fast ones from becoming interactive
- Streaming HTML: Server can send HTML progressively as components render
This pattern is especially powerful with React Server Components, which eliminate hydration entirely for non-interactive parts of your UI.
Code Splitting with React.lazy and Suspense
What is code splitting?
Code splitting divides your JavaScript bundle into smaller chunks that load on demand. Instead of downloading your entire application upfront, users get only the code needed for the current view, reducing Time to Interactive and improving First Contentful Paint.
Route-Based Code Splitting
The highest-impact code splitting happens at route boundaries:
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
// Static import: included in main bundle
import Home from "./pages/Home";
// Dynamic imports: separate chunks loaded on navigation
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const Analytics = lazy(() => import("./pages/Analytics"));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Each lazy-loaded route becomes a separate chunk. Users visiting the home page don’t download dashboard code until they navigate there.
Component-Level Code Splitting
Split heavy components that aren’t immediately visible:
import { lazy, Suspense, useState } from "react";
// Heavy charting library (e.g., recharts, chart.js) only loads when needed
const Chart = lazy(() => import("./components/Chart"));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Analytics</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<Chart data={analyticsData} />
</Suspense>
)}
</div>
);
}
Preloading for Better UX
Lazy loading can feel slow if chunks load after user interaction. Preload likely-needed chunks on hover or after initial render:
// Preload on hover
function NavLink({ to, children }) {
const preload = () => {
if (to === "/dashboard") {
import("./pages/Dashboard");
}
};
return (
<Link to={to} onMouseEnter={preload} onFocus={preload}>
{children}
</Link>
);
}
// Preload after initial render
useEffect(() => {
// Preload routes user is likely to visit next
const timer = setTimeout(() => {
import("./pages/Dashboard");
import("./pages/Settings");
}, 2000);
return () => clearTimeout(timer);
}, []);
Analyzing Your Bundle: What to Look For
After implementing code splitting, verify the improvement with bundle analysis:
# For Webpack projects
npm install -D webpack-bundle-analyzer
# Add to webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
};
# For Vite projects
npm install -D rollup-plugin-visualizer
# Add to vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [visualizer({ open: true })]
};
Reading the bundle visualization:
The analyzer shows a treemap where rectangle size represents file size. Look for these red flags:
| Red Flag | What It Means | How to Fix |
|---|---|---|
| One giant rectangle | Single chunk too large | Split with React.lazy at route boundaries |
| Duplicate libraries | Same dependency in multiple chunks | Configure shared chunks in bundler |
| moment.js or lodash (full) | Importing entire library | Use date-fns or lodash-es with tree shaking |
| Large node_modules in main | Vendor code blocking initial load | Move to separate vendor chunk |
| Unused exports visible | Dead code not eliminated | Check tree shaking configuration |
Target metrics after optimization:
| Chunk Type | Target Size (gzipped) | Action if Exceeded |
|---|---|---|
| Main bundle | Under 200KB | Split routes, defer non-critical code |
| Route chunks | Under 100KB each | Split heavy components within routes |
| Vendor chunk | Under 150KB | Audit dependencies, find lighter alternatives |
| Total initial load | Under 400KB | Aggressive code splitting, lazy load below fold |
Common bundle bloat culprits:
# Check what's taking space
npx source-map-explorer dist/main.js
# Common offenders and alternatives:
# moment.js (300KB) → date-fns (30KB) or dayjs (7KB)
# lodash (70KB) → lodash-es with cherry-picking
# chart.js (200KB) → lazy load, or use lightweight alternative
# @mui/material → import specific components, not entire library
List Virtualization with react-window and TanStack Virtual
What is virtualization?
Virtualization is a rendering technique that displays only the items visible in the viewport, plus a small buffer. A list of 10,000 items renders only 20-50 DOM nodes at any time, dramatically reducing memory usage and improving scroll performance.
When to Virtualize
| List Size | Recommendation |
|---|---|
| Under 50 items | Don’t virtualize (overhead not worth it) |
| 50-100 items | Consider if items are complex |
| 100-500 items | Virtualize if scrolling feels laggy |
| Over 500 items | Always virtualize |
react-window: Lightweight Virtualization
react-window is the lightweight choice for most use cases:
import { FixedSizeList } from "react-window";
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
// The style prop is critical - it contains positioning data
<div style={style} className="list-item">
<span>{items[index].name}</span>
<span>{items[index].email}</span>
</div>
);
return (
<FixedSizeList
height={400} // Viewport height in pixels
itemCount={items.length}
itemSize={50} // Row height in pixels
width="100%"
>
{Row}
</FixedSizeList>
);
}
Variable Height Items
For items with different heights, use VariableSizeList:
import { VariableSizeList } from "react-window";
import { useRef } from "react";
function ChatMessages({ messages }) {
const listRef = useRef();
const getItemSize = (index) => {
const message = messages[index];
const baseHeight = 60;
const lineHeight = 20;
const lines = Math.ceil(message.text.length / 50);
return baseHeight + lines * lineHeight;
};
const Row = ({ index, style }) => (
<div style={style} className="message">
<strong>{messages[index].author}</strong>
<p>{messages[index].text}</p>
</div>
);
return (
<VariableSizeList
ref={listRef}
height={600}
itemCount={messages.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
}
TanStack Virtual: Maximum Flexibility
TanStack Virtual (formerly react-virtual) offers more control for complex cases:
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
function VirtualTable({ rows }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5, // Render 5 extra items above/below viewport
});
return (
<div ref={parentRef} style={{ height: "400px", overflow: "auto" }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rows[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
Virtualization Accessibility
Virtualized lists can confuse screen readers. Add appropriate ARIA attributes:
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={50}
width="100%"
role="list"
aria-label="Product catalog"
>
{({ index, style }) => (
<div
style={style}
role="listitem"
aria-posinset={index + 1}
aria-setsize={items.length}
>
{items[index].name}
</div>
)}
</FixedSizeList>
Case Study: Optimizing a Slow Dashboard
Here’s a real optimization I performed on a dashboard with 800ms render times.
The Problem
A data dashboard was taking 800ms to render after fetching new data. Users complained about laggy interactions and delayed updates.
Initial Profiler Results:
Dashboard: 800ms totalDataTable: 650ms (rendering 2,000 rows)FilterPanel: 120ms (re-rendering on every table scroll)Charts: 30ms
The Fixes
Fix 1: Virtualize the DataTable
The table rendered all 2,000 rows. Switching to TanStack Virtual reduced DOM nodes from 2,000 to 25.
// Before: 2,000 DOM nodes
{
data.map((row) => <TableRow key={row.id} data={row} />);
}
// After: ~25 DOM nodes
<VirtualizedTable rows={data} />;
Result: DataTable render time dropped from 650ms to 15ms.
Fix 2: Isolate FilterPanel State
FilterPanel was a child of Dashboard, causing it to re-render on every Dashboard state change. Moving filter state into FilterPanel with component composition fixed this.
// Before: FilterPanel re-renders when table data changes
function Dashboard() {
const [data, setData] = useState([]);
const [filters, setFilters] = useState({});
return (
<>
<FilterPanel filters={filters} onChange={setFilters} />
<DataTable data={data} />
</>
);
}
// After: FilterPanel only re-renders when filters change
function Dashboard() {
return (
<FilterProvider>
<FilterPanel />
<DataTableWithFilters />
</FilterProvider>
);
}
Result: FilterPanel stopped re-rendering during table interactions.
Fix 3: Debounce Filter Changes
Typing in filter inputs triggered immediate re-filtering of 2,000 rows. Adding debouncing reduced unnecessary calculations.
const debouncedFilter = useDeferredValue(filterText);
const filteredData = useMemo(
() => data.filter((row) => matchesFilter(row, debouncedFilter)),
[data, debouncedFilter],
);
Result: Input remained responsive during typing.
Final Results
| Metric | Before | After | Improvement |
|---|---|---|---|
| Total render time | 800ms | 45ms | 94% faster |
| DOM nodes | 2,000+ | ~50 | 97% reduction |
| INP score | 890ms | 85ms | Passed Core Web Vitals |
Additional Resources
Related articles on this site:
- Mastering React Hooks - Deep dive into useState, useEffect, useMemo, and useCallback
- React State Management: Redux vs Zustand vs Jotai - Choosing the right state library for performance
- React Server Components Practical Guide - Zero-JS components and selective hydration
- Framer Motion Complete Guide - Performant animations in React
External resources: