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.
#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.
#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 { injectStartTransaction } from '@mmstack/primitives';
const startTransaction = injectStartTransaction();
const txn = startTransaction(() => {
this.range.set('last-30-days'); // writes live, refetches, display stays held
this.groupBy.set('week');
});
// later, if the user cancels:
txn.abort(); // roll the writes back, release the hold The returned handle also carries pending and a done promise. One caveat: 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 or a chained resource is not attributable to it. Trigger such work eagerly inside fn. For the plumbing under it, createTransaction() is the bare undo log (record / restore / clear) with no scope involved.
#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 true#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.