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:
- Run the event handler (your code).
- Update state.
- Re-render the component tree.
- 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:
- The input updates immediately (INP ~50 ms).
- React schedules filtering at low priority.
- If the user types again before filtering completes, React pauses filtering, processes the new keystroke, then resumes filtering.
- 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:
- Open DevTools → Components tab → Profiler.
- Record interactions.
- Look for components with high "render duration" (shown in microseconds).
- Optimize those components with
React.memo(),useMemo(), oruseCallback().
Components that render in under 50 ms contribute minimally to INP. Components over 200 ms are likely bottlenecks.
Key Takeaways
useTransitionmarks 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()anduseCallback(). - 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
- React useTransition Documentation — Official guide and examples.
- Web.dev: Optimize INP with React — Specific React patterns for INP reduction.
- Scheduler API Explainer — Low-level scheduler for task chunking.
- React Profiler Guide — How to measure and optimize render performance.