INP React 18+

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.

JSX -- startTransition in event handlers
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.

JSX -- React.memo, useMemo, useCallback
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.

JavaScript -- Yielding in event handlers
// 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.

JSX -- react-window virtualization
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.

JavaScript -- Detecting re-renders
// 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.

Continue learning