Transitions and suspense
Tools for async UI: hold the current view while the next one loads, keep the last value during a reload, and gate rendering on readiness.
@mmstack/primitivesnpm
The problem
# When a value drives an @switch or an @if, changing it unmounts the old branch right away. If the new branch loads data, the user sees a spinner between the two states. Switch tabs three times and you get three flashes.
A transition fixes the timing. The old view stays on screen until the new one has what it needs, then both swap in a single frame.
What do you need?
#Transition scopes
# A scope tracks the resources created under it and reports whether any are still loading through a pending signal. Resources join the nearest scope when you register them. provideTransitionScope() opens a fresh scope at a component boundary, and registerResource() adds a resource to it.
import { resource } from '@angular/core';
import { provideTransitionScope, registerResource } from '@mmstack/primitives';
@Component({
providers: [provideTransitionScope()], // a fresh scope for this subtree
})
class Panel {
readonly data = registerResource(
resource({ params: () => this.id(), loader: ({ params }) => load(params) }),
);
} Resources from @mmstack/resource and Angular's own resource() both work. The transition primitives below read a scope to know when to swap.
*mmTransition
#*mmTransition holds and swaps around any value change. The old view keeps its value and stays visible while the new one mounts hidden in its own scope. Once that scope settles, they swap. The demo runs the same tab panel twice: a plain switch on the left, a transition on the right.
Demo loads on scroll.
<ng-container *mmTransition="selectedTab(); let tab">
<app-panel [tab]="tab" />
</ng-container> The value is required. First render shows immediately since there is nothing to hold. Set mmTransitionImmediate to skip holding, and mmTransitionViewTransition to animate the swap with the View Transitions API where the browser supports it.
mm-suspense
#<mm-suspense> is the readiness gate for a single branch. It shows a placeholder until a first value lands, then keeps the real content mounted through later reloads and marks it busy instead of falling back. Where *mmTransition decides when to swap between two views, suspense decides placeholder versus content inside one. They compose.
<mm-suspense>
<app-panel />
<p placeholder>Loading…</p>
</mm-suspense><mm-suspense> provides its own scope, so dropping it anywhere just works. Use <mm-unscoped-suspense> instead when the resources to coordinate are registered above the boundary and you want it to read that outer scope rather than open a fresh one.
When a load fails
# A failure can land in two very different places. If a load fails and there is nothing to show yet, the boundary swaps to its [error] slot, which replaces both placeholder and content. If there is something on screen already, say rows held with keepPrevious while a reload ran, the failure never replaces them. The rows stay, the optional [failed] slot renders beside them and the host gets a data-failed attribute.
<mm-suspense>
<span placeholder>Loading orders…</span>
<!-- failed, nothing to show: replaces placeholder and content -->
<p error>Could not load the orders. <button mmRetryFailed>Retry</button></p>
<!-- failed, content held: renders beside it -->
<div failed *mmSuspenseError="let entries; retry as retry; dismissAll as dismissAll">
{{ entries[0].failure.displayName }} failed to refresh.
<button (click)="retry()">Retry</button>
</div>
<orders-table />
</mm-suspense> The scope reports the two separately. failed() is true when something failed with nothing to show. errored() lists every member failing right now, content or not, as { member, failure } entries with a displayName and a message. Both sides below use the same template; only when the failure happens differs.
Demo loads on scroll.
mmRetryFailed calls scope.retryAll() on click. *mmSuspenseError renders its template while errored() is not empty and gives it the entries as $implicit, plus retry, dismiss(entry) and dismissAll().
A retry is a round. retryAll() re-runs every member that can retry and isn't already in flight, failed or not. The round is the unit: each capable member runs at most once in it, so a healthy query in the same boundary reloads along with the failed one rather than being skipped. scope.retry(id) runs a round for one member. Each round returns { dispatched, settled() }. A second click while the first round is still loading finds those members in flight and dispatches nothing, so a double retry can't happen. Resource registrations retry through their reload(). Members without a retry (work you enroll yourself, a failed save) can be dismissed instead, which hides the failure until that member fails again.
Two scope options round this off. provideTransitionScope({ deadline: { ms: 10_000 } }) declares a blocking member failed if it hasn't settled in time, so a request that never answers can't leave a placeholder up forever. And by default ('pending-first') the boundary's error state waits until its blocking loads settle, so two failures from one incident show up together and one retry covers both. Background activity (indicator registrations, a reload behind held content) doesn't hold it back, and errored() can already list failures in the meantime. precedence: 'error-first' shows the error state as soon as a blocking member fails.
Revealing siblings in order
# Sibling boundaries show their content whenever their own data lands, so a page can fill in bottom-up, or a feed can appear above the profile it belongs to. <mm-reveal> decides when each boundary directly inside it may show its content.
<mm-reveal order="forwards" collapsed>
<mm-suspense><app-profile /></mm-suspense>
<mm-suspense><app-feed /></mm-suspense>
<mm-suspense><app-suggestions /></mm-suspense>
</mm-reveal> With order="forwards", the default, a boundary shows once it is ready and every boundary before it has shown. "backwards" is the mirror, and "together" waits for all of them. A boundary that is ready but held back keeps its placeholder; with collapsed it renders nothing, so only the next one in line shows a placeholder. The feed below loads fastest and still waits for the profile.
Demo loads on scroll.
A failure follows onError. With 'settled' (the default) a failed boundary shows its error and the rest carry on. With 'blocks' it shows its error and holds the boundaries after it until a retry succeeds, for when the order really means "this needs that first". Once a boundary has been released it no longer waits for the reveal; if it suspends again, its own suspense behaviour still applies. Only direct child boundaries take part, in the order they appear on the page, whether they come from @if, @for or a plain element, and the reveal only schedules display: held content is created and loads as usual. A boundary counts as waiting until it has rendered once, so a row whose inputs are not bound yet can never be released on a stale reading.
Boundaries that come from an @for can take their order from the data instead. Give the reveal the same array and track function, and each boundary its row:
<mm-reveal [items]="rows()" [track]="byId">
@for (row of rows(); track row.id) {
<mm-suspense [item]="row"><app-row [row]="row" /></mm-suspense>
}
</mm-reveal> Order and membership then follow the array: a moved row is seen at once, a row that leaves the array stops holding the others and keeps what it showed, and nothing is read from the DOM. A boundary with no [item], or whose key is not in the array, is held and holds nothing; dev mode warns about both a missing [item] and duplicate keys. track defaults to the row itself, like @for.
Render errors and @boundary
# Everything above is about loads, and a failed load never throws into the template: a @mmstack/resource query hands its error back as a value and value() keeps returning what it had. Angular's @boundary { … } @error { … } block is for the other kind, a template that throws while it renders. The two don't overlap, so they compose, with one rule: put @boundary inside the branch it guards (inside the *mmTransition template, inside the routed component), never around *mmTransition or the transition outlet. On a throw it removes its whole block, and around a swap that block holds both the outgoing and the incoming view.
Render boundaries don't catch everything. Errors in event listeners, root effects, afterRender callbacks, promises and @defer loads go straight to your ErrorHandler, and so does a view effect that runs while its view is only being traversed. Projected content belongs to the template that declared it, so a boundary around <ng-content> doesn't cover it either.
Keeping state through a render error
# Angular's @boundary catches a throw while its block renders, removes the block and builds it again on $reset(). For a stateless widget that's fine. For a form the user is halfway through, it throws away what they typed. *mmErrored catches the same throws but keeps the content: the view is detached and hidden, its components stay alive, and the fallback renders in its place with the error and a retry.
<form *mmErrored="failed; name: 'order note'">
<app-order-fields />
</form>
<ng-template #failed let-error let-retry="retry">
<p>{{ error.message }} <button (click)="retry()">Retry</button></p>
</ng-template>Type a note on both sides, break the price, then fix it. The left side comes back empty, the right side comes back as you left it.
Demo loads on scroll.
retry() renders the kept view again with the same instances. A computed that cached the throw throws again until something it reads changes, so retrying before the fix just lands on the fallback again. When the kept state is itself what throws, retry({ rebuild: true }) destroys the content and builds it fresh, the way @boundary does. A throw while the content is first being built leaves nothing to keep, so that case always rebuilds.
The content is hidden rather than left on screen, because the DOM after a throw is whatever the failed pass wrote before it stopped. Each catch is reported to your ErrorHandler once, and inside a transition scope the fault shows up in the scope's errored() list without blanking the boundary. The not-caught list above applies here too, and init hooks that already ran don't run again on retry.
Imperative transitions
#injectStartTransition() runs an update as a transition from code, for cases where there is no structural directive to hang it on.
import { injectStartTransition } from '@mmstack/primitives';
const startTransition = injectStartTransition();
startTransition(() => {
this.selectedTab.set('activity'); // held and swapped, not flashed
});Transactions
# A transaction generalizes startTransition to a multi-step update you might want to take back. injectStartTransaction() returns a startTransaction(fn) bound to the nearest scope. It freezes the scope's display at the pre-transaction values, then runs fn. The writes land on live state right away, so derived values and connector requests see them and refetch, but the display stays held. On settle it releases the hold and keeps the writes; call abort() to roll the writes back and release the hold without ever showing the intermediate state.
import { Component, signal } from '@angular/core';
import {
injectStartTransaction,
injectTransitionScope,
provideTransitionScope,
transactional,
} from '@mmstack/primitives';
@Component({
providers: [provideTransitionScope()],
// the template reads the held signals, never range() / groupBy() directly
template: `<h2>{{ shownRange() }} by {{ shownGroupBy() }}</h2>`,
})
class Report {
private readonly startTransaction = injectStartTransaction();
private readonly scope = injectTransitionScope();
// transactional() records each write, so abort() can put it back
readonly range = transactional(signal('last-7-days'));
readonly groupBy = transactional(signal('day'));
// what the screen shows: frozen while a transaction holds the scope
readonly shownRange = this.scope.hold(this.range);
readonly shownGroupBy = this.scope.hold(this.groupBy);
cancel: () => void = () => {};
apply() {
const txn = this.startTransaction(() => {
this.range.set('last-30-days'); // live at once, requests refetch
this.groupBy.set('week');
});
// if the user cancels: roll both writes back and release the hold
this.cancel = () => txn.abort();
}
} Rollback covers the writes the transaction recorded, and nothing else is intercepted. A store records its root the first time it is written inside a transaction, and derived, keepPrevious and the pausable signals record the source they write to. A plain signal of your own needs transactional(sig) (or activeTransaction()?.record(sig) before the write). Anything written some other way stays put after abort().
Two transactions can overlap. Aborting one only undoes its writes that are still current: if a later transaction wrote the same signal in the meantime, the later value stays. For a store, the paths only the aborted transaction touched go back and the rest keep what is there now. The limit is a value someone else calculated from the aborted one and stored. Say A writes x and B stores twiceX as twice that; when A aborts, x reverts and the stored twiceX stays as it is. A computed(() => 2 * x()) has no such problem, it just recomputes from the reverted x.
The returned handle also carries pending and a done promise. With a synchronous body, work has to go in flight by the first render after the writes to be part of the transaction, so a loader behind a debounce is missed. A load that was already running when the transaction began is not adopted. tx.retain() keeps the transaction open: it returns a release function, and the transaction can't settle until every release has been called, so call it in a finally.
this.startTransaction((tx) => {
this.search.set(q); // results load from a debounced copy of search
const release = tx.retain(); // stay open past the first render
setTimeout(() => {
try {
tx.set(this.debouncedSearch, q); // the load starts in the transaction
} finally {
release(); // now it settles once that load is done
}
}, 300);
}); The synchronous form keeps its recorded writes if its injection context is destroyed mid-flight; it only releases the hold. For the plumbing under it, createTransaction() is the bare undo log with no scope involved.
Transactions across an await
# Pass an async body and the transaction stays open until three things are true: the body's promise has settled, the loads attributed to it have finished, and every tx.retain() has been released. The catch is the usual one with async functions: code after an await runs outside whatever was active when the body started. Write through the transaction instead: tx.set(sig, value) and tx.update(sig, fn) are recorded wherever they run, plain signals included, so an abort undoes them. tx.enter(() => ...) puts a whole synchronous block back in, so writes and loads started there belong to this transaction.
readonly name = transactional(signal('Atlas'));
readonly revision = transactional(signal(1));
// the template reads these two
readonly shownName = this.scope.hold(this.name);
readonly shownRevision = this.scope.hold(this.revision);
save(next: string) {
const t = this.startTransaction(async (tx) => {
this.name.set(next); // before the first await: part of the transaction
const saved = await api.rename(next);
tx.set(this.revision, saved.revision); // still the transaction's after the await
});
return t.done; // { kind: 'completed' | 'aborted' | 'failed', ... }, never rejects
} Hit save below and watch the two middle cards. The live state moves at once, the screen keeps the old name until the body is done, then both values arrive together. Abort, or let the server reject, and the recorded writes go back. The bottom row compares a plain effect with heldEffect, covered further down.
Demo loads on scroll.
done never rejects. It resolves with { kind: 'completed' }, { kind: 'aborted', reason } or { kind: 'failed', error }. A write after an await that you forget to wrap still lands and the display stays held, but an abort won't undo it. A load Angular starts later in response to the slice's writes counts by time, like any load started while the transaction is open. Once the transaction has settled, enter and retain throw, though nothing can stop a leftover continuation from writing a signal directly. A startTransaction called inside a slice joins the outer transaction rather than starting its own, so aborting the nested handle aborts the shared transaction. And unlike the synchronous form, destroying the context mid-await restores the recorded writes and settles aborted.
Views that mount while a transaction holds (an @if that opens halfway through, a tab swapped in below the page) start from the pre-transaction values of the signals the transaction recorded, so they match the rest of the held page and reveal with it. A hold on a scope also holds the scopes created inside it. Values the transaction didn't record still show live.
Effects that wait
# A held display doesn't stop effects. They see every write as it lands, which for analytics, a document title or a scroll restore means acting on a state the user never saw. heldEffect(fn) waits it out: while the nearest scope is holding or loading, a change only marks it stale, and when the hold ends it runs once with the latest values. In the demo above the plain effect logs every step of a save, the held one logs the result.
import { heldEffect } from '@mmstack/primitives';
// runs once per settled change, not once per write
heldEffect(() => analytics.track('cart', cart.total())); It can't tell one change from many, and a hold with no change in it runs nothing. Pass scope to watch another scope or gate for your own condition. Template bindings are Angular's and are not held, so the values a template shows still go through *mmTransition or the scope's hold.
Guesses
# A guess is a value shown before its truth is known, and it's gone when its transaction settles, however that ends. There are two tiers and no default. The live tier, guessable(sig) with tx.guess(node, value), changes the signal in place over a truth kept beneath it, so every reader sees the guess; use it for a write that almost always succeeds, like a like toggle. The overlay tier, optimistic(sig) or optimisticStore(base) with tx.overlay(view), is a fork per transaction that only the view's readers see and that is thrown away at the end; use it for multi-field edits like a modal form.
const liked = guessable(signal(false));
startTransaction(async (tx) => {
tx.guess(liked, true); // everyone sees true now
const saved = await api.like(postId);
tx.set(liked, saved.liked); // the server's answer, recorded
}); // the guess is gone once this settles, success or not The frozen frame is the truth beneath the guess: a hold over a guessable shows the guess live and freezes only the truth, so a held view never keeps a guess after it reverts. Recording the server's answer inside the transaction, as the example does, resolves the guess to that answer: held and unheld readers both move from the guess to the answer, and none drops back to the value from before the save. A write from outside the transaction, like the user typing over the field, replaces the guess at once. After an await, write with tx.set; a plain set there counts as outside. A computed over a guessed value doesn't get that for free. Held, it freezes what it computed, guess included, so hold the guessable itself or read node.truth() inside the derivation. A request built from a live guess fires like any other; return undefined from the request function while the input is provisional if that's not wanted. The full rules are in the primitives README, and mutationResource's optimistic option opens one of these transactions per save.
Morphing elements across a swap
# When a held swap runs through the View Transitions API (mmTransitionViewTransition, or the transition outlet's option), the browser can morph an element from the outgoing view into its counterpart in the incoming one instead of cross-fading the whole boundary. mmViewTransitionName assigns the pairing name per element, so a thumbnail in a list can grow into the hero image on the detail view.
<!-- list view -->
<img [mmViewTransitionName]="'hero-' + item().id" [src]="item().thumb" />
<!-- detail view names the same element, so it morphs across the swap -->
<img [mmViewTransitionName]="'hero-' + item().id" [src]="item().full" /> The name is normalized to a valid CSS ident, and an empty string or 'none' clears it. The one rule to keep is that a name must be unique among elements visible at capture time, so derive it from an id for anything that repeats.
Value-level tools
#latest builds a derivation over resources with use. While a dependency reloads it returns the previous result instead of undefined, so downstream reads stay stable. Switch users below: the left panel reads the resource directly and blinks to empty, the right one holds the last value through the reload.
Demo loads on scroll.
import { latest, use } from '@mmstack/primitives';
const summary = latest(() => {
const u = use(userResource); // short-circuits until it has a value
const o = use(ordersResource);
return u.name + ': ' + o.length + ' orders';
}); Under latest sits keepPrevious, the base stale-while-revalidate primitive. It wraps a single signal so it holds its last defined value whenever the source goes undefined, which is exactly what a resource does mid-reload. Reach for it directly when you want to hold one value rather than derive over several, and for a structure rather than a value there is holdUntilReady(target, ready): it keeps yielding the previous target until the ready() predicate flips, so you can mount an incoming subtree off to the side and swap only once it has settled. Both are also useful for holding native resource snapshots.
import { holdUntilReady, keepPrevious } from '@mmstack/primitives';
// hold one value through a reload
const held = keepPrevious(myResource.value);
// hold a structure until the incoming one is ready
const shown = holdUntilReady(selectedId, () => !nextScope.pending());deferredValue lets a signal lag behind its source so an expensive render can be deprioritized, similar in spirit to React's useDeferredValue. Type quickly in the filter below: the input stays on the live value while the list catches up from the deferred one, and deferred.pending() reports the gap. The list rows here are deliberately slowed to stand in for an expensive render, which is where deferring actually pays off.
Demo loads on scroll.
import { deferredValue } from '@mmstack/primitives';
const query = signal('');
// default 'afterRender' catches up next frame; 'idle' waits for the main
// thread to go idle, so heavy work never blocks the keystroke
const deferred = deferredValue(query, { strategy: 'idle' });
// bind the input to query() so it stays responsive,
// render the expensive list from deferred(),
// and dim it while deferred.pending() is trueAsync state as a value
# A resource's outcome() reads its whole state as one value: the data, undefined when nothing was requested, or a sentinel. loading means there is nothing to show yet, error means the load failed, and DONE means it settled with nothing to return (a mutation whose result is undefined). Narrow them with isLoading, isError and isDone, or supply a fallback with ifLoading / ifError.
import { isError, isLoading } from '@mmstack/primitives';
const label = computed(() => {
const out = user.outcome();
if (isLoading(out)) return 'Loading…';
if (isError(out)) return 'Could not load the user';
return out?.name ?? 'No user selected';
}); For several independent resources, joined reads all of them and hands back a tagged Result: value, pending or error. It never throws, so it switches nicely in a template. settle(outcome) turns a single outcome into the same shape. A sentinel that reaches a template binding by accident renders as [mmstack loading] (or error, done) and is reported once; provideStrictSentinels() at the root makes that a thrown error instead, for as long as the application lives.
import { joined } from '@mmstack/primitives';
const card = joined(user, org, (u, o) => u.name + ' @ ' + o.name);
// card(): { kind: 'value', value } | { kind: 'pending', ... } | { kind: 'error', error }Reading a resource in a template
# A boundary knows about the resources registered inside it. A resource created above the boundary and only read inside it is invisible to it, so the boundary renders content around an empty value. *mmOutcome makes the read count: it takes the resource, renders its template while there is a value, and while the resource is loading or failed it joins the nearest boundary as a member of its own.
<mm-suspense>
<span placeholder>Loading…</span>
<!-- a failed first load: the boundary has nothing to keep -->
<p error>Could not load the user. <button mmRetryFailed>Retry</button></p>
<h2 *mmOutcome="user; let u; error: failed">{{ u?.name }}</h2>
<!-- a failed refresh: the boundary keeps the content up -->
<ng-template #failed let-error let-retry="retry">
Could not refresh the user. <button (click)="retry?.()">Retry</button>
</ng-template>
</mm-suspense>Demo loads on scroll.
The optional loading and error templates render in place of the content when nothing above the directive handles that state. Inside a boundary, a first load shows the boundary's placeholder, and a failed first load shows the boundary's [error] slot, because there is no content to keep yet. Put a mmRetryFailed button in that slot to retry. The directive's own error template, with a retry that reloads the resource, renders where nothing above handles the failure: with no boundary around it, or when a refresh fails while the resource still holds its last value (with keepPrevious) and the boundary keeps the content up. If the same resource is also registered in that boundary it still counts once. A latest works here too. The rule of thumb: reach for latest where you'd write a computed over resources, and for *mmOutcome where you'd read one in a template.
On the router
# The same idea drives navigation in @mmstack/router-core: mm-transition-outlet holds the current route until the next one settles, then swaps.
Putting it together
#One boundary, two queries, two writes. The name gets a guess, the address save fails the first time, and the orders refresh is debounced. Each piece covers a gap the others leave.
// the template reads the held values
readonly shownName = this.scope.hold(this.name); // name = guessable(...)
readonly shownAddress = this.scope.hold(this.address); // transactional(signal(...))
readonly shownOrders = this.scope.hold(this.orders.value); // keepPrevious query
save() {
this.startTransaction(async (tx) => {
tx.guess(this.name, nextName); // shows at once, gone when this settles
const saved = await this.rename.mutateAsync({ name: nextName });
tx.set(this.name, saved.name);
try {
await this.setAddress.mutateAsync(nextAddress); // invalidates orders
} catch {
return; // its failure stays in errored(), the old list stays up
}
tx.set(this.address, nextAddress);
const release = tx.retain(); // the orders refresh is debounced
setTimeout(() => {
try {
tx.set(this.ordersFor, nextAddress);
} finally {
release();
}
}, 300);
});
}Demo loads on scroll.
The guess makes the click feel instant without lying for long. The hold keeps the address and the list from disagreeing while the refresh runs, and retain keeps that hold up past a debounce. The registered mutation keeps "address failed" next to rows that are still correct, until the next attempt starts.