Transitions
Keep the current screen while the next one loads with startTransition, useTransition and useDeferredValue. How transitions interact with Suspense, what isPending means, and what runs on the server.
A transition is an update you are willing to wait for. A click that opens a slow page should not freeze the button
that was pressed, and the screen the user is looking at should stay until the new one is ready. ShadowJS has three
functions for this, from @shadow-js/core:
| Function | What it does |
|---|---|
startTransition(fn) |
Runs fn; the effects its writes wake run later, in the transition lane. Returns a promise that resolves when the update is on screen. |
useTransition() |
Returns [isPending, start]: isPending is an accessor, start is startTransition that also updates isPending. |
useDeferredValue(source, options?) |
Returns an accessor that follows source in the transition lane: it keeps the previous value until the lane runs. |
These replace React's startTransition, useTransition and useDeferredValue. Solid's createDeferred is
useDeferredValue.
A first example#
import { useSignal, useTransition } from "@shadow-js/core";
import { Suspense } from "@shadow-js/web";
export function Pages() {
const [page, setPage] = useSignal("home");
const [pending, start] = useTransition();
return (
<>
<nav>
<button disabled={pending()} onClick={() => start(() => setPage("reports"))}>
Reports
</button>
{pending() ? <span>loading</span> : null}
</nav>
<Suspense fallback={<p>loading the first screen</p>}>
{page() === "home" ? <Home /> : <Reports />}
</Suspense>
</>
);
}
Clicking "Reports" does not show the fallback. Home stays on screen, pending() is true, and the button is disabled,
until Reports has loaded its data. Then the new page replaces the old one.
Urgent updates and the lane#
Updates are urgent by default. An urgent write is applied in the microtask after the event handler, as before. A
write made inside startTransition belongs to the transition lane: the effects it wakes run in a later macrotask,
one chunk at a time, so the browser can paint and take input between chunks. Reads see the new values at once; only
the DOM catches up later.
const done = startTransition(() => setQuery(value)); // the list updates later
setCount(count() + 1); // the counter updates now
await done; // the list is on screen
If an urgent write reaches an effect that is already waiting in the lane, the effect moves to the urgent queue: the
urgent update is not delayed by the transition. flushSync() flushes urgent work only (as in React); a transition is
finished when its promise resolves.
useDeferredValue#
Use it for a value that drives slow work. The slow part reads the deferred value; the input reads the live one:
const [query, setQuery] = useSignal("");
const deferredQuery = useDeferredValue(query);
const matches = useMemo(() => search(deferredQuery())); // slow; updates in the lane
<input value={query()} onInput={(e) => setQuery(e.currentTarget.value)} />;
The input is never blocked by the search. deferredQuery() keeps the previous value until the lane applies the new
one. Options: equals (default Object.is) and name (devtools).
Suspense: holding the screen#
When a transition reads data that is still loading inside a Suspense boundary that is showing content, the boundary
keeps the content. The reader keeps its previous value, no fallback appears, and isPending is true until the data
has arrived; then the new content is shown. A boundary that has not shown anything yet shows its fallback, as usual.
A branch built during a transition (a Show or Switch that changes its branch) is built in its own place and shown
when everything it reads has arrived. The branch that was on screen stays on screen meanwhile.
Two transitions in a row: the newer one writes the newest state; the held content is rebuilt from current values when
it is shown. Errors from data that fails while a transition holds go to the nearest ErrorBoundary, like any error.
What isPending means#
isPending is true from start(fn) until the update is on screen: the lane has run and no boundary holds it. A
transition that never resolves (its data never arrives) keeps isPending true; give the data a timeout (see
useResource's signal) if the UI must recover.
On the server#
There are no effects on the server, so startTransition runs its callback at once and returns a resolved promise.
useDeferredValue(source) returns the source itself. Pages render the same markup as the client shows at the end of
the transition.
The router#
The router does not depend on transitions, and this release does not change it. Its navigation can use the same
functions: start(() => navigate(path)) keeps the previous page while the next loads, and isPending drives the
progress indicator. The router's own "keep the previous page" behavior (its pendingMs option) is independent.
What is not covered#
- A transition does not cancel an older held one: the older one is superseded by the newest state (it is rebuilt from current values when shown).
- Effects that ran before a suspension inside the same lane chunk are applied; the held content is the last committed DOM. A branch shown before a transition that starts waiting keeps its DOM, but its reactive bindings resume only when the new branch is committed.
addTransitionTypeis not provided: the type of a view transition is given tostartViewTransition(update, { types })(see the control-flow guide).- Devtools receives
TRANSITION_STARTandTRANSITION_ENDevents; there is no transitions panel yet.
Common mistakes#
- Writing after an
awaitinsidestartTransition: the writes after the firstawaitare urgent. Start one transition per update instead. - Reading
isPendingas "the data is loaded": it is "the update is on screen". Put the loading UI in the boundary fallback for first loads. - Expecting
flushSync()to apply a transition: await its promise.
See also#
- Suspense and resources:
useResourceand the fallback of a boundary. - Control flow:
Show,SwitchandActivity. - Parity decisions (
docs/contracts/parity-decisions.md): DEC-TRANSITIONS, with the design and the residual gaps.
Immediate skeletons#
pendingMs: 0 (on the router or on a route) shows the pending UI at once, rather than after the default 1000 ms. See
Dusk routing.