Skip to main content

Reduce INP in React: useTransition & Scheduler

Reducing Interaction to Next Paint in React apps requires prioritizing user input over background work. React provides useTransition to mark state updates as non-blocking, allowing the browser to respond to the next user interaction while a long computation completes in the background. Combined with manual task chunking and event handler optimization, these techniques cut INP from 400+ milliseconds to under 200 milliseconds on typical React forms and search interfaces.

The Problem: Blocking Event Handlers

The most common cause of high INP in React is a long-running event handler that blocks the main thread. When a user types in a search box or submits a form, React must:

  1. Run the event handler (your code).
  2. Update state.
  3. Re-render the component tree.
  4. Paint the new frame.

If step 3 takes 300 ms (because the component tree is large or the render logic is expensive), the user's next input is delayed by 300 ms—high INP.

// SLOW: Blocking event handler
export default function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);

const handleChange = (e) => {
const q = e.target.value;
setQuery(q); // Update immediately

// Blocking: synchronous filter of 10,000 items
const filtered = allProducts.filter(p =>
p.name.toLowerCase().includes(q.toLowerCase())
);

setResults(filtered); // Re-render with new results
// ^ This re-render blocks the main thread for 200+ ms
};

return (
<>
<input value={query} onChange={handleChange} />
<ul>
{results.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
</>
);
}

If the user types again while the re-render is happening, the browser queues the second keystroke and delays it, resulting in high INP.

Using useTransition for Non-Blocking Updates

useTransition marks a state update as low-priority, allowing React to pause the update if the user interacts. The update resumes when the browser is idle.

// OPTIMIZED: useTransition makes filtering non-blocking
import { useState, useTransition } from 'react';

export default function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();

const handleChange = (e) => {
const q = e.target.value;

// Update input immediately (high priority)
setQuery(q);

// Defer filtering to transition (low priority)
startTransition(() => {
const filtered = allProducts.filter(p =>
p.name.toLowerCase().includes(q.toLowerCase())
);
setResults(filtered);
});
};

return (
<>
<input value={query} onChange={handleChange} />

{/* Show loading state while filtering */}
{isPending && <p>Searching...</p>}

<ul>
{results.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
</>
);
}

Now, when the user types:

  1. The input updates immediately (INP ~50 ms).
  2. React schedules filtering at low priority.
  3. If the user types again before filtering completes, React pauses filtering, processes the new keystroke, then resumes filtering.
  4. Results update when filtering completes (background, no delay).

This keeps INP low because the input response is decoupled from the expensive computation.

useTransition vs useDeferredValue

useDeferredValue is similar but is triggered automatically by re-renders. Use useTransition for event handlers; use useDeferredValue for prop changes or derived state:

// ALTERNATIVE: useDeferredValue for prop changes
import { useState, useDeferredValue } from 'react';

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

// useDeferredValue creates a deferred version of the query
const deferredQuery = useDeferredValue(query);

// This re-render is deferred if the browser is busy
const filtered = items.filter(item =>
item.name.includes(deferredQuery)
);

return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<ul>
{filtered.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</>
);
}

Both achieve the same result—deferred updates that don't block user input.

Chunking Long Computations

If a computation takes multiple seconds (e.g., processing a large dataset), break it into chunks and yield to the main thread between chunks:

// src/utils/scheduler.js
export function createScheduler() {
return {
async run(task) {
if ('scheduler' in window && 'yield' in window.scheduler) {
// Use native scheduler API (Chromium-only, 2024+)
await window.scheduler.yield();
} else {
// Fallback: yield with setTimeout + requestIdleCallback
await new Promise(resolve => {
if ('requestIdleCallback' in window) {
requestIdleCallback(resolve);
} else {
setTimeout(resolve, 0);
}
});
}
return task();
},
};
}

const scheduler = createScheduler();

// In your component
async function handleLargeDataProcessing(data) {
const chunks = splitIntoChunks(data, 100); // 100 items per chunk

for (const chunk of chunks) {
processChunk(chunk);
await scheduler.run(() => {
// Update UI after each chunk
setProgress(current => current + chunk.length);
});
}
}

This keeps the main thread responsive during long operations.

Optimizing Event Handlers

Minimize the work inside event handlers. Move expensive logic outside:

// SLOW
const handleSubmit = (e) => {
e.preventDefault();

// Expensive: transform entire form object
const formData = transformFormData(largeFormObject);

// Expensive: validate all fields
const errors = validateAllFields(formData);

// Expensive: serialize to JSON string
const json = JSON.stringify(formData);

// Send to server
fetch('/api/submit', { method: 'POST', body: json });
};

// OPTIMIZED
const memoizedFormData = useMemo(
() => transformFormData(largeFormObject),
[largeFormObject]
);

const memoizedErrors = useMemo(
() => validateAllFields(memoizedFormData),
[memoizedFormData]
);

const handleSubmit = (e) => {
e.preventDefault();

// Fast: use memoized data
const json = JSON.stringify(memoizedFormData);

// Send to server
fetch('/api/submit', { method: 'POST', body: json });
};

By memoizing expensive computations, the event handler is fast (INP ~50 ms).

Debouncing and Throttling for Rapid Events

For events that fire many times per second (scroll, mousemove, input), debounce or throttle to reduce re-renders:

// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => clearTimeout(handler);
}, [value, delay]);

return debouncedValue;
}

// In your component
export default function AutoSave({ document }) {
const [content, setContent] = useState(document.content);
const debouncedContent = useDebounce(content, 500);

useEffect(() => {
// Save only when user stops typing for 500 ms
if (debouncedContent !== document.content) {
fetch('/api/save', {
method: 'PUT',
body: JSON.stringify({ content: debouncedContent }),
});
}
}, [debouncedContent]);

return (
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="Type your content..."
/>
);
}

Without debouncing, each keystroke triggers a save API call and re-render. With debouncing, saves only happen 500 ms after the user stops typing. INP stays low because the input handler is fast; the save logic runs less frequently.

Performance Monitoring with React Profiler

Use React DevTools Profiler to measure re-render times:

  1. Open DevTools → Components tab → Profiler.
  2. Record interactions.
  3. Look for components with high "render duration" (shown in microseconds).
  4. Optimize those components with React.memo(), useMemo(), or useCallback().

Components that render in under 50 ms contribute minimally to INP. Components over 200 ms are likely bottlenecks.

Key Takeaways

  • useTransition marks state updates as low-priority, keeping event handlers responsive.
  • Deferred updates pause if the user interacts, resuming when idle.
  • Chunk long computations and yield to the main thread between chunks.
  • Move expensive logic out of event handlers using useMemo() and useCallback().
  • Debounce rapid events (input, scroll) to reduce re-renders and event handler frequency.
  • Use React Profiler to identify components with high render time and optimize them.

Frequently Asked Questions

Does useTransition slow down my app?

No. useTransition defers updates, but React still runs them in the background. The total time to complete is the same, but user interactions are not blocked. Your app feels faster because the input feels responsive, even if the full result takes longer to appear.

Can I use useTransition with API calls?

Yes. Wrap the state update (not the fetch) in startTransition:

const handleSearch = async (q) => {
setQuery(q); // Update input immediately

startTransition(() => {
fetch(`/api/search?q=${q}`)
.then(res => res.json())
.then(data => setResults(data)); // Deferred update
});
};

The input updates immediately; the API result populates in the background.

What if I need to validate form input instantly?

Validate in the event handler (high priority), but defer rendering the full results to a transition:

const handleChange = (e) => {
const q = e.target.value;
setQuery(q); // Update immediately

const isValid = validateQuery(q); // Quick validation, synchronous
setValidationError(isValid ? null : 'Invalid input');

startTransition(() => {
// Deferred: expensive filtering
const filtered = filterResults(q);
setResults(filtered);
});
};

Validation is instant; filtering is deferred.

Further Reading