5.5 — Concurrent React¶
Module 5 · Lesson 5 · 🔴 Advanced · ~35 min
What you'll learn¶
- How
startTransitionanduseDeferredValuefix INP without making anything faster - Yielding to the main thread:
scheduler.yield, chunking, andisInputPending - Which commerce interactions are urgent and which are not
- Measuring the difference concurrency makes
The core idea¶
Concurrent React lets you mark updates as non‑urgent. React renders them in a way that can be interrupted, so the browser can paint and process input in between.
It does not make rendering faster. It makes it interruptible, which is what INP measures.
WITHOUT transitions — one blocking render
User types "j" ──┤████████████████████████ 380ms render ████████████████│── paint
User types "a" ──┤ (queued behind the render, input delay 380ms) │
INP ≈ 420ms 🔴
WITH transitions — interruptible, input stays responsive
User types "j" ──┤██│ urgent: input value updates, paints at 8ms
│████░░░░│ non-urgent results render, interruptible
User types "a" ──────┤██│ interrupts the previous render, input paints at 8ms
│████████░░░░│ results re-render with the new query
INP ≈ 24ms 🟢
Total CPU work is the same or slightly higher. The user‑perceived latency drops by an order of magnitude, because the thing they're waiting on (the character appearing) happens immediately.
useTransition¶
'use client';
import { useState, useTransition } from 'react';
export function CategoryFilters({ facets }: { facets: Facet[] }) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [isPending, startTransition] = useTransition();
const toggle = (id: string) => {
// URGENT: the checkbox must flip immediately. This is outside the transition.
const next = new Set(selected);
next.has(id) ? next.delete(id) : next.add(id);
setSelected(next);
// NON-URGENT: the expensive results update. Interruptible.
startTransition(() => {
applyFiltersToResults(next);
});
};
return (
<div className={isPending ? 'opacity-70 transition-opacity' : ''}>
{facets.map((f) => (
<label key={f.id}>
<input type="checkbox" checked={selected.has(f.id)} onChange={() => toggle(f.id)} />
{f.label}
</label>
))}
</div>
);
}
The split is the whole technique: immediate feedback outside the transition, expensive consequence inside it. If you put the checkbox state inside the transition too, the checkbox itself lags — which is worse than doing nothing.
isPending gives you a way to show the UI is working without blocking it. Keep the pending
treatment subtle (a slight opacity change, a thin progress bar) — a full skeleton makes the page
flash on every keystroke.
With router navigation¶
'use client';
import { useRouter } from 'next/navigation';
import { useTransition, useOptimistic } from 'react';
export function SortSelect({ current, options }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// Optimistic value so the select shows the new choice instantly
const [optimisticSort, setOptimisticSort] = useOptimistic(current);
return (
<select
value={optimisticSort}
disabled={false} // never disable: it feels broken
onChange={(e) => {
const value = e.target.value;
startTransition(() => {
setOptimisticSort(value);
router.push(`?sort=${value}`, { scroll: false });
});
}}
className={isPending ? 'opacity-70' : ''}
>
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
);
}
This is the pattern that makes URL‑as‑state (5.3) feel instant despite a server round trip: the control updates immediately, the page updates when the data arrives, and the old content stays visible in the meantime instead of flashing to a skeleton.
useDeferredValue¶
Use it when you don't control the state update — typically a value coming from props or a controlled input.
'use client';
import { useState, useDeferredValue, useMemo } from 'react';
export function ProductSearch({ allProducts }: { allProducts: Product[] }) {
const [query, setQuery] = useState('');
// The input reads `query` (urgent). The results read `deferredQuery` (lags behind).
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(
() => filterProducts(allProducts, deferredQuery), // expensive
[allProducts, deferredQuery],
);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products"
/>
<div style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
<ProductGrid products={results} />
</div>
</>
);
}
useTransition vs useDeferredValue:
useTransition |
useDeferredValue |
|
|---|---|---|
| You control the setter | ✅ Use this | — |
| Value comes from props/parent | — | ✅ Use this |
| Gives you a pending flag | ✅ isPending |
Compare value !== deferred |
| Wraps | The update | The value |
Both are better than debouncing for this case: debouncing delays the work, so the results are always stale by the debounce interval. Deferring keeps the work interruptible, so results appear as soon as the user pauses — without a fixed artificial delay.
Debounce is still correct for network calls (don't send a request per keystroke). Use both: debounce the fetch, defer the render.
Yielding to the main thread¶
Transitions help React work. For your own long‑running JavaScript, you need to yield explicitly.
// lib/yield.ts
/**
* Yields to the main thread so pending input can be processed.
* scheduler.yield() is the modern API; setTimeout(0) is the universal fallback.
*/
export function yieldToMain(): Promise<void> {
if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
return (globalThis as any).scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* Process a large array in chunks, yielding between them so the page stays responsive.
*/
export async function processInChunks<T>(
items: T[],
fn: (item: T) => void,
chunkSize = 50,
) {
for (let i = 0; i < items.length; i += chunkSize) {
for (const item of items.slice(i, i + chunkSize)) fn(item);
if (i + chunkSize < items.length) await yieldToMain();
}
}
// Real use: building an analytics payload for a 2,000-product impression event
async function trackProductImpressions(products: Product[]) {
const payload: Impression[] = [];
await processInChunks(products, (p) => {
payload.push(buildImpression(p)); // ~0.4ms each → 800ms unchunked 🔴
}, 100);
navigator.sendBeacon('/api/events', JSON.stringify(payload));
}
scheduler.yield() is better than setTimeout(0) where available: it yields but keeps your
task's priority, so your continuation runs before other queued tasks rather than at the back of the
queue. The fallback matters — check support and always provide it.
For work that can be prioritized, scheduler.postTask:
// Explicit priority: 'user-blocking' | 'user-visible' | 'background'
if ('scheduler' in globalThis && 'postTask' in (globalThis as any).scheduler) {
(globalThis as any).scheduler.postTask(() => refreshRecommendations(), {
priority: 'background',
});
} else {
requestIdleCallback(() => refreshRecommendations(), { timeout: 3000 });
}
Urgent vs non‑urgent, for commerce¶
| Interaction | Urgent (immediate) | Non‑urgent (transition) |
|---|---|---|
| Typing in search | Input value | Results list |
| Toggling a filter | Checkbox state | Product grid, facet counts |
| Changing sort | Select value | Re‑sorted grid |
| Selecting a variant | Swatch highlight | Gallery image, price, stock |
| Opening the mini‑cart | Drawer animation | Recommendations inside it |
| Adding to cart | Button state, cart badge | Cart contents, upsells |
| Changing quantity | Input value | Recalculated totals, shipping |
| Pagination | Loading indicator | The new page |
| Tab switch | Tab highlight | Tab panel content |
The pattern is always the same: the thing the user directly manipulated updates in the next frame; everything downstream is a transition.
// PDP variant selector — the exemplar
'use client';
export function VariantSelector({ variants, productId }: Props) {
const [selectedId, setSelectedId] = useState(variants[0].id);
const [isPending, startTransition] = useTransition();
const select = (id: string) => {
setSelectedId(id); // URGENT: swatch highlights instantly
startTransition(() => {
updateGalleryForVariant(id); // NON-URGENT: image swap
refreshPriceAndStock(id); // NON-URGENT: may hit the network
});
};
return (
<div role="radiogroup" aria-label="Colour">
{variants.map((v) => (
<button
key={v.id}
role="radio"
aria-checked={v.id === selectedId}
onClick={() => select(v.id)}
className={v.id === selectedId ? 'ring-2 ring-neutral-900' : ''}
>
<span className="sr-only">{v.name}</span>
<span style={{ background: v.swatch }} className="block h-8 w-8 rounded-full" />
</button>
))}
</div>
);
}
Suspense + transitions: avoiding the skeleton flash¶
When a transition suspends, React keeps showing the previous content instead of falling back to the skeleton. That's usually what you want on a commerce page — a flash to skeleton on every filter change looks broken.
// Inside a transition, this stays visible while new data loads
<Suspense fallback={<GridSkeleton />}>
<ProductGrid searchParams={searchParams} />
</Suspense>
- First load: the fallback shows (there's nothing else to show).
- Transition update: the old grid stays, dimmed via
isPending, until the new one is ready.
If you do see a skeleton flash on updates, the update wasn't wrapped in a transition, or a key
change forced a remount:
// ❌ Changing the key remounts the subtree → fallback shows on every filter change
<Suspense fallback={<GridSkeleton />}>
<ProductGrid key={JSON.stringify(filters)} filters={filters} />
</Suspense>
// ✅ Same component instance; React reconciles
<Suspense fallback={<GridSkeleton />}>
<ProductGrid filters={filters} />
</Suspense>
Measuring the difference¶
Concurrency changes when work happens, so byte counts and total CPU won't show the win. Measure input responsiveness directly.
// Measure interaction → next paint, the same thing INP measures
function measureInteraction(name: string, fn: () => void) {
const start = performance.now();
fn();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
reportMetric({ name: `interaction_${name}`, value: performance.now() - start });
});
});
}
Then compare, at 4× CPU throttling:
| Interaction | Before | After transitions |
|---|---|---|
| Filter toggle → checkbox paints | 412 ms | 14 ms |
| Filter toggle → grid updated | 412 ms | 380 ms |
| Search keystroke → character appears | 380 ms | 9 ms |
| Search keystroke → results updated | 380 ms | 340 ms |
| Variant select → swatch highlights | 260 ms | 11 ms |
Note the second row of each pair: the total work didn't get faster. The user's perception and the INP metric improved by 25×, because INP measures the paint they were waiting for.
This is also why concurrency alone isn't enough — a 380 ms grid update is still slow if the user is watching for it. Combine transitions (perceived latency) with the work in Modules 3–5 (actual latency).
Common mistakes¶
| Mistake | Consequence |
|---|---|
| Putting the urgent update inside the transition | The control itself lags — worse than nothing |
| Using transitions to avoid fixing real slowness | Feels better, still slow; users notice on the second interaction |
Aggressive isPending UI (full skeleton) |
Flashing on every keystroke |
| Debouncing renders instead of deferring | Results always stale by the debounce interval |
| Never yielding in long loops | React can't help with your own JS |
setTimeout(0) without trying scheduler.yield |
Continuation goes to the back of the task queue |
Changing key on transition updates |
Forces remount, shows the fallback |
| Not measuring to the next paint | You'll measure handler time and miss the real latency |
Lab 5.5 — Add concurrency to your worst interaction¶
- Take the interaction with the worst field INP.
- Identify the urgent part (what the user directly touched) and the non‑urgent part (everything downstream).
- Wrap the non‑urgent part in
startTransition. Keep the urgent update outside. - Add a subtle
isPendingtreatment — opacity, not a skeleton. - Measure interaction‑to‑next‑paint before and after at 4× CPU. Expect a 10–30× improvement in perceived latency.
- Find your longest self‑written loop (analytics payload building, price calculations, facet
counting) and chunk it with
yieldToMain. - Verify in the field: p75 INP for that page after a week.
Checklist¶
- Urgent feedback (the thing touched) is outside every transition
- Downstream/expensive updates are inside transitions
-
isPendingtreatment is subtle - Router navigations from filters/sort wrapped in transitions
-
useDeferredValueused where you don't own the setter - Network calls debounced; renders deferred (both, not one)
- Long self‑written loops chunked with
scheduler.yield+ fallback - No
keychurn causing remounts on updates - Measured to the next paint, not to handler completion