Fix INP in React
React applications are disproportionately affected by INP because state updates can trigger synchronous re-render cascades across large component trees. A single button click that causes 200 components to re-render will have high processing time -- the second phase of INP -- even if the event handler itself is trivial. React 18's concurrent features are the primary fix, combined with memoization and virtualization.
Expected results
Before
480ms
INP (Poor) -- unmemoized tree, synchronous updates, large lists, no concurrent features
After
110ms
INP (Good) -- startTransition, React.memo, virtualized lists, deferred non-visual work
Step-by-step fix
Use startTransition for expensive state updates
startTransition is the most powerful INP tool in React 18. Wrap any state update that triggers an expensive re-render in startTransition -- React will render the update at low priority and interrupt it if higher-priority input arrives. The UI responds to the user immediately; the expensive update catches up in the background.
import { useState, startTransition, useDeferredValue } from 'react';
// Pattern 1: startTransition in event handler
function FilterableList({ items }) {
const [filter, setFilter] = useState('');
const [filtered, setFiltered] = useState(items);
function handleFilterChange(e) {
const value = e.target.value;
setFilter(value); // Urgent: update input immediately
startTransition(() => {
// Non-urgent: compute filtered list at lower priority
setFiltered(items.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
));
});
}
return (
<>
<input value={filter} onChange={handleFilterChange} />
<ItemList items={filtered} />
</>
);
}
// Pattern 2: useDeferredValue for derived expensive renders
function SearchResults({ query, allData }) {
// query updates immediately, deferredQuery updates at lower priority
const deferredQuery = useDeferredValue(query);
const results = useMemo(
() => expensiveFilter(allData, deferredQuery),
[allData, deferredQuery]
);
return <ResultsList results={results} />;
}
Memoize components and values
Without memoization, a state update near the root of the component tree causes every child component to re-render, even if their props have not changed. React.memo prevents re-renders when props are the same. useMemo and useCallback preserve referential equality for objects and functions passed as props.
import React, { memo, useMemo, useCallback } from 'react';
// React.memo: skip re-render if props are shallow-equal
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>Add to cart</button>
</div>
);
});
function ProductGrid({ products }) {
// useCallback: stable function reference so ProductCard doesn't re-render
const handleAddToCart = useCallback((id) => {
addToCart(id);
}, []); // no dependencies = stable forever
// useMemo: expensive derivation runs only when products changes
const sortedProducts = useMemo(
() => [...products].sort((a, b) => b.rating - a.rating),
[products]
);
return sortedProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
));
}
Break up long tasks in event handlers
If an event handler performs more than 50ms of synchronous work, the browser is blocked from painting the visual response. Break the work into chunks and yield back to the main thread between each chunk, allowing the browser to commit the visual update before continuing.
// yield helper
function yieldToMain() {
if ('scheduler' in window && 'yield' in window.scheduler) {
return window.scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
// Event handler that breaks up expensive work
async function handleSaveForm(formData) {
// Step 1: Immediate visual feedback (urgent)
setStatus('saving');
// Step 2: Yield so browser can paint the status change
await yieldToMain();
// Step 3: Validate (can take 20-50ms for complex rules)
const errors = await validateForm(formData);
if (errors.length) {
setErrors(errors);
setStatus('error');
return;
}
// Step 4: Yield again before the network call setup
await yieldToMain();
// Step 5: Save data
await saveToAPI(formData);
setStatus('saved');
}
Virtualize large lists
A list with 500+ items creates thousands of DOM nodes. When any component in the tree re-renders, React must diff all of them, and the browser must style and potentially repaint them. Virtualization renders only the visible items, capping the DOM size regardless of data size.
import { FixedSizeList } from 'react-window';
// Bad: renders all 5000 items
function BadList({ items }) {
return (
<ul>
{items.map(item => <ListItem key={item.id} item={item} />)}
</ul>
);
}
// Good: only renders ~20 items at any time
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
<ListItem item={items[index]} />
</div>
);
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={80} // row height in pixels
>
{Row}
</FixedSizeList>
);
}
Profile and eliminate unnecessary re-renders
Use the React DevTools Profiler to record an interaction and see which components re-rendered. Components that render with no prop changes are candidates for React.memo. Components with high self-time are candidates for memoizing their expensive calculations with useMemo.
// Install React DevTools and use the Profiler tab
// OR: add a render counter during development
function expensiveComponent(WrappedComponent) {
let renderCount = 0;
return function DebugWrapper(props) {
renderCount++;
if (process.env.NODE_ENV === 'development') {
console.log(
`[${WrappedComponent.name}] render #${renderCount}`,
'props changed:',
JSON.stringify(props).slice(0, 100)
);
}
return <WrappedComponent {...props} />;
};
}
// Or use the why-did-you-render package for automatic detection:
// npm install @welldone-software/why-did-you-render
// import './wdyr'; // in development entry point
Quick checklist
-
Expensive state updates wrapped in
startTransition -
List item components wrapped in
React.memo -
Callbacks passed as props created with
useCallback -
Expensive derived values computed with
useMemo - Lists with 100+ items use virtualization
-
Non-visual work in event handlers deferred with
requestIdleCallback
Frequently asked questions
React's rendering model means a single state update can cascade across many components. In complex apps, one button click can trigger hundreds of synchronous re-renders. This makes INP's processing time phase particularly problematic for React compared to simpler server-rendered sites.
Use React.memo on components that render often but whose props change infrequently -- list items, table rows, sidebar panels. It is less useful near the root (they rarely re-render anyway) or on components with frequently-changing props (the shallow comparison adds overhead without benefit).
Concurrent rendering lets React pause, resume, and abandon in-progress renders. If a user interacts while React is in the middle of a large state update, React pauses the update, handles the interaction (keeping INP fast), then resumes. Without this, the interaction would wait for the full update.
Yes, if context values change frequently and many components consume it. Every consumer re-renders when context changes. For high-frequency updates (input values, scroll position, mouse position), avoid Context. Use Zustand or Jotai which subscribe components only to the specific slices they need.
External state libraries improve INP by enabling fine-grained subscriptions -- components only re-render when their specific state slice changes. Zustand and Jotai excel here. The key improvement over naive Context is that unrelated components are not re-rendered when unrelated state changes.
Use Chrome DevTools Performance panel with CPU throttling (4x slowdown) to simulate mid-range mobile devices. Interact with the page (click buttons, type in inputs, open menus) and look for long tasks in the flame chart. The Web Vitals Chrome Extension shows real-time INP scores as you interact. For React, pay attention to hydration-related interaction delays.
Related resources
Complete INP Guide
Deep dive into INP's three phases, measurement tools, and universal optimization strategies.
FixFix INP in Next.js
Next.js-specific optimizations with Server Components, next/dynamic, and startTransition.
FixFix CLS in React
Eliminate layout shifts from images, useEffect changes, and animations in React.
Continue learning
Complete INP Guide
Deep dive into INP -- thresholds, measurement, and optimization strategies.
FixFix LCP in React
Related performance optimization for the same framework.
FixFix CLS in React
Related performance optimization for the same framework.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.