Skip to content

5.5 — Concurrent React

Module 5 · Lesson 5 · 🔴 Advanced · ~35 min

What you'll learn

  • How startTransition and useDeferredValue fix INP without making anything faster
  • Yielding to the main thread: scheduler.yield, chunking, and isInputPending
  • 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

  1. Take the interaction with the worst field INP.
  2. Identify the urgent part (what the user directly touched) and the non‑urgent part (everything downstream).
  3. Wrap the non‑urgent part in startTransition. Keep the urgent update outside.
  4. Add a subtle isPending treatment — opacity, not a skeleton.
  5. Measure interaction‑to‑next‑paint before and after at 4× CPU. Expect a 10–30× improvement in perceived latency.
  6. Find your longest self‑written loop (analytics payload building, price calculations, facet counting) and chunk it with yieldToMain.
  7. 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
  • isPending treatment is subtle
  • Router navigations from filters/sort wrapped in transitions
  • useDeferredValue used 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 key churn causing remounts on updates
  • Measured to the next paint, not to handler completion

Next: 5.6 Forms, Server Actions & optimistic UI