Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

Lifecycle

Every Component can override five lifecycle hooks. Each is one Task-returning method, runs at a well-defined point around a render, and behaves identically on the Server and WASM hosts (only the transport differs). This page documents the hooks, their order, the async rules, and the gotchas.

See also: routing.md for how route/query params drive OnUpdated, and the README Lifecycle reference table for a one-glance summary.

The hooks

All five are protected virtual on Component:

protected override async Task OnMount()       => _items = await LoadItems();          // once, before the first render
protected override async Task OnUpdated()     => _product = await Product.Where(p => p.Id == Id).First();   // new props arrived (and on mount)
protected override async Task OnFirstRender() => await _map.Init(_center);            // once, after the first render
protected override async Task OnRendered()    => await _map.Refresh(_markers);        // after every render
protected override async Task OnUnmount()     => await _socket.Close();               // once, on the way out

There is one hook per moment, not a synchronous and an asynchronous twin. The part of a hook before its first await runs synchronously — before the first render, for OnMount — so work that used to go in a separate synchronous hook simply goes above the await:

protected override async Task OnMount()
{
    feed.Updated += StateHasChanged;                        // now, before the first render
    _items = await Cache.Remember("catalog", LoadItems);    // later — the component re-renders when it lands
}

A hook with nothing to await is still written async; the compiler does not warn about it.

Order

Hook When
OnMount Once, before the instance's first render.
OnUpdated On the first render, and on any later render where a bound prop / route or query param actually changed.
OnFirstRender Once, after the first render is in the page — where browser work that needs the elements starts.
OnRendered After every render, the first included (after OnFirstRender).
OnUnmount Once, on disposal (navigation away, parent subtree torn down, session teardown). Children unmount before parents (depth-first).

What "the instance" is. A component keeps its instance — and so mounts once — for as long as the component that writes it goes on writing it at the same place among the children of its type, or under the same Key. A sibling of another type, or an element, appearing or disappearing ahead of it does not unmount it. A sibling of its own type doing so moves every later one of that type along by one, state included, unless they carry keys: see keys and identity.

So a component's life reads:

first time:  OnMount → OnUpdated → Render → OnFirstRender → OnRendered
new props:   OnUpdated → Render → OnRendered
own state:   Render → OnRendered
leaving:     OnUnmount   (its CancellationToken is cancelled right after)

Live probe

The component below counts every hook and re-renders so you can watch the order. Trigger re-render fires a bare event-handler render — note it re-runs OnRendered but does not re-fire OnMount / OnUpdated (nothing the component is bound to changed), and OnFirstRender stays at one:

LifecycleProbe.cs
namespace Rask.Site.Features;

// One row per hook, fixed from the first paint, with each hook's own status as TEXT.
//
// It used to append to a growing log, which made the row COUNT a function of how many times the
// component had rendered and of whether a 450ms await had resolved yet -- so the markup's shape
// depended on when you looked at it. DemoMarkupGoldenTests snapshots every demo's skeleton (tag names
// and classes), and the contract it states is that a demo may change as it settles, but the moving
// part has to live in text, an id or a data-* attribute. A tag that arrives on a timer cannot be
// snapshotted by anyone.
//
// Reading the hooks as a fixed table is also the better demo: the full order is visible before anything
// has fired, a hook that has not run yet says so rather than being absent, and the counts make it
// obvious which hooks run once per mount and which run on every render.
public sealed partial class LifecycleProbe : Component
{
    private int _clicks;
    private int _onFirstRender;
    private int _onMount;
    private bool _onMountSettled;
    private int _onUpdated;
    private int _onRendered;
    private int _renderCount;

    protected override async Task OnMount()
    {
        // Above the first await, so it still runs before the first render — which is what the
        // synchronous twin used to be for, back when there were two of these.
        _onMount++;
        await Task.Delay(450);

        // Flips a flag the row already on screen reads. Appending a line here instead is what used to
        // grow the list by an <li> and a <code>, 450ms after the first paint.
        _onMountSettled = true;
    }

    protected override Task OnUpdated()
    {
        _onUpdated++;
        return Task.CompletedTask;
    }

    protected override Task OnFirstRender()
    {
        // Counted, not latched: its own hook now, and the count beside OnRendered's is what shows one
        // fires once per mount while the other fires per render — which a bool could only assert.
        _onFirstRender++;
        return Task.CompletedTask;
    }

    protected override Task OnRendered()
    {
        _onRendered++;
        return Task.CompletedTask;
    }

    protected override Component? Render() =>
        [
            Div.Class("flex gap-3 items-center flex-wrap mb-3")[
                Ui.Badge.Lg[$"Render #{++_renderCount}"],
                // The handler just records the click; Rask re-renders the component that owns the
                // callback (this probe — the lambda closes over its state) right after it runs, so the
                // badge repaints with no StateHasChanged (RASK026). Works the same through Ui.Button,
                // which forwards the callback down to the native <button>.
                Ui.Button.Primary.Icon(Ui.IconName.ArrowPath)
                    .OnClick(() => _clicks++)["Trigger re-render"]
            ],
            Ui.Heading.Level(3).Class("mb-2")["Hook log"],
            Ui.Table[
                Ui.TableColumns[Ui.TableColumn["Hook"], Ui.TableColumn["Status"]],
                Ui.TableRows[
                    Row("OnMount (before its await)", Ran(_onMount)),
                    Row("OnMount (after a 450ms await)", _onMountSettled ? "resolved" : "awaiting…"),
                    Row("OnUpdated", Ran(_onUpdated)),
                    Row("OnFirstRender", Ran(_onFirstRender)),
                    Row("OnRendered", _onRendered == 0 ? "not yet" : Ran(_onRendered)),
                    Row("Button clicks", Ran(_clicks))
                ]
            ]
        ];

    private static string Ran(int times) => times switch
    {
        0 => "not yet",
        1 => "ran 1x",
        _ => $"ran {times}x"
    };

    // Every row is the same shape, so the skeleton is a constant of this component rather than a
    // function of its state. `data-hook` gives a test something stable to select on — a data-* attribute
    // is explicitly one of the places the golden contract allows a value to move.
    private static Component Row(string hook, string status) =>
        Ui.TableRow.Data("hook", hook)[
            Ui.TableCell.Variant(Ui.TableCellVariant.Strong)[hook],
            Ui.TableCell[status]
        ];
}
Live result
Render #3

Hook log

Hook
Status
OnMount (before its await)ran 1x
OnMount (after a 450ms await)resolved
OnUpdatedran 1x
OnFirstRenderran 1x
OnRenderedran 1x
Button clicksnot yet

Mount / unmount cycle

Toggle the probe in and out of the tree to watch OnUnmount fire (children before parents). The log is held by the parent, so it survives the probe's unmount:

LifecycleCycleProbe.cs
namespace Rask.Site.Features;

// Variant that surfaces every hook — including OnMount / OnUnmount — to a parent-held
// log so the unmount entries survive the probe being torn down. The parent owns the list.
public sealed partial class LifecycleCycleProbe : Component
{
    public Callback<string> Log { get; set; }
    public required int InstanceId { get; set; }

    protected override async Task OnMount()
    {
        // Above the first await, so it still runs before the first render — which is what the
        // synchronous twin used to be for.
        await Log.Invoke($"#{InstanceId} OnMount (before its await)");
        await Task.Delay(150);
        await Log.Invoke($"#{InstanceId} OnMount (after a 150ms await)");
    }

    protected override async Task OnUnmount() => await Log.Invoke($"#{InstanceId} OnUnmount");

    protected override Component? Render() =>
        Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Ui.Badge.Color(Ui.Color.Green)[$"#{InstanceId} alive"],
            Span.Class("text-zinc-500 dark:text-white/70 text-sm")["Unmount me to fire OnUnmount."]
        ];
}
Live result

Probe not mounted.

Log

Empty — mount and unmount the probe.

A typical async-data page uses OnMount to fetch once and renders a placeholder until it lands:

[Route("/weather")]
public sealed partial class Weather(IWeatherForecastService service) : Component
{
    private WeatherForecast[]? _forecasts;

    protected override async Task OnMount() =>
        _forecasts = await service.GetForecasts();

    protected override Component? Render() =>
        _forecasts is null
            ? P[Em["Loading..."]]
            : Table[/* render rows */];
}

On the Server host the initial GET waits for that fetch, so the first response carries the forecasts rather than the placeholder — which is what a crawler, a cache and the user's first paint all see. If the fetch outlives the budget the placeholder is what is served, and the page finishes loading over the socket. See Live pages.

A page reached in place is waited for too, on both hosts: a link, Back, Go() from a handler or a redirect sends the new page once its OnMount and OnUpdated are back, within a navigation budget of 150 ms (Rask:Server:NavigationQuiescenceTimeout; RaskWasmOptions.NavigationQuiescenceTimeout in the browser). Until then the reader keeps the page they are on, under the usual busy marks. A fetch that takes longer is painted as the placeholder at the budget and as the data when it lands, so the placeholder is still worth writing — it is what a slow load looks like. See the page is sent when its first reads are back.

An edit page is the same page with a form where the table was: read the record in the page's hook, draw the form once it is there, and let a form component of your own read its model prop in Render — a copy taken in its OnMount is taken once, before the read is back. See an edit page that reads its record.

Work you deliberately detach from the hook is not waited on. A poll loop started with _ = Poll() returns from OnMount immediately, so the response goes out and the loop keeps pushing over the live connection:

protected override async Task OnMount()
{
    await Read();    // awaited: the GET waits for this
    _ = Poll();      // detached: it must not hold the response open
}

Neither call passes a cancellation token: both run inside the component's own work and are cancelled when it unmounts.

When OnUpdated refires

OnUpdated fires on the first render and whenever a value the component is bound to actually changes — including:

  • A parent passing a different value for a chain step (a prop).
  • A [RouteParam] / [QueryParam] value changing because the URL changed.
  • A reused routed page whose URL path changes (the router keeps the instance and re-binds it rather than remounting).

What does not refire it: a bare event-handler re-render. Clicking a button that mutates a local field re-renders the component but does not re-fire OnUpdated — nothing the component is bound to changed. (Key is a reconciliation identity, not a reactive prop, so a key change doesn't fire OnUpdated either; it mounts a fresh instance.)

Do not run an unbounded loop in OnMount

The first render waits on the task a lifecycle hook hands back. That is right for load the data this page shows and wrong for run until this component goes away — a while (!ct.IsCancellationRequested) loop awaited inside OnMount never returns, so the render never settles. It waits out its whole budget and is then reported as timed out; under prerendering the page is skipped entirely and ships to a crawler as a boot shell.

Splitting the loop does not rescue it either. Letting the hook return before the first tick paints an empty widget, and starting the loop detached lets it outlive the render pass and re-render against a session scope that has already been disposed.

Put ongoing work in a service with its own lifetime and have the component subscribe to it — which is what Background service below shows. The component's own hooks then do what they are for: subscribe on mount, unsubscribe on unmount.

Async rules

The hooks install a synchronization context so each await inside a hook triggers an automatic re-render after the continuation, plus one terminal re-render on completion — you get "mutate state after the await and it paints" without calling StateHasChanged() by hand. The runtime coalesces these into one payload per handler dispatch.

protected override async Task OnMount()
{
    // placeholder shows here
    _data = await Load();
    // auto re-render after the await → real data paints, no StateHasChanged()
}

OnRendered is loop-safe. The terminal auto re-render is a publish-only walk: it does not re-fire OnRendered on components that have already rendered at least once. That's what keeps a OnRendered hook which awaits a next-frame side effect (e.g. drawing a chart, or a scoped-JS call) from looping on itself. Newly-mounted children on the same walk still get their OnFirstRender and OnRendered.

protected override async Task OnRendered() =>
    await js.InvokeVoidAsync("Rask.CodeSample.rendered");
    // re-render from another component won't re-fire this — no loop

When a handler's state reaches the screen

An event handler is different from a hook: what it sets is not painted at each await. The page is sent when the handler has answered — one frame, with what the reads it started brought back — and only a handler still running after the handler budget (150 ms) is painted as it stands, once, with every later await repainting as it lands:

private async Task Save()
{
    _saving = true;                 // shown only if the save is still running at 150 ms
    await _order.Save();
    _saving = false;                // a save that was back in time: this is the only frame
}

So an intermediate state — _saving, _loading — is what a slow handler looks like, and a fast one never flashes it. An async validator's "Checking…" follows the same rule: a slow check shows it at the budget, and a quick one shows it only beside its verdict, for the indicator's own 200 ms tail. The button that was pressed says it is busy by itself after 200 ms (buttons that wait), so most handlers need no flag at all. Call StateHasChanged() when something must be painted regardless: it is honoured when the handler ends, as before. The budget is Rask:Server:HandlerQuiescenceTimeout (RaskWasmOptions.HandlerQuiescenceTimeout in the browser); zero paints at every await. See a handler's work reaches the screen once.

An event that arrives after a re-render

An event the browser sent before a re-render and that arrives after it runs the handler it was sent to, wherever that handler is in the page now — or nothing, when its button, row or component has left. It never runs another handler, and a component that was unmounted never hears an event again. What counts as the same handler, and what that means for a list of rows, is in An event that outlived its render.

Each event gets its own scoped services

A live page answers many events over one connection, and each one runs the way a request would: the app's scoped services it reaches are fresh for that event, signed in as the user the session holds for that event (revalidated as it started), and disposed once the event has rendered. A click, a form submit, a navigation, a render the server pushes and the lifecycle hooks they run each count as one event; the events of one batched frame count as one.

builder.Services.AddDbContext<ShopDb>();          // scoped: one per event, not one per open tab
builder.Services.AddScoped<IPricing, Pricing>();  // likewise

// A command handler that takes the context gets the event's own, and it is disposed after the click.
public sealed class PlaceOrderHandler(ShopDb db) : ICommandHandler<PlaceOrder> { … }

Everything scoped is one per event — your services, the batteries' (Rask.Data, Rask.Auth) and any package's — and so is every transient, which is how the CQRS dispatcher an event reaches, and the handler behind it, get the event's context. A few services are the session's, because they are the open page: its route and navigator, its connection (IJSRuntime), its user (IUserProvider), its toaster, its language, its query cache and the tenant its resolver named. A service made for the event that takes one of those is handed the session's own, so a scoped service of yours can inject IToaster or IUserProvider as it always could. The scope is opened only when something asks for a per-event service, so an event that asks for none costs nothing.

Two rules follow:

  • A component's constructor is the session's. A component outlives every event, so what it takes in its constructor comes from the session, as before. Reach the event's own with Current.Service<T>() inside the handler — or through the calls that take no service, await Dispatcher.Send(command), Cache.Remember(…), and the handler they reach.
  • Do not keep a scoped service past its event. A field that holds a DbContext it was handed in one event finds it disposed in the next. This is a behaviour change: an app that kept scoped state across events (a scoped "unit of work" collecting changes over several clicks) now gets a fresh one per event — keep such state on the component, or in a singleton keyed by the user.

An event's scope lives as long as the work the event started. A lifecycle hook — OnMount, OnUpdated, OnRendered — belongs to the event whose render started it, and so does every Current.Service<T>() it makes, before an await or after one, itself or through a helper it calls. A navigation is sent at once and its pages' hooks load on behind it; their reads keep that event's scope (one DbContext, the same instance throughout) while other events come and go, and it is disposed when the event has ended and the last hook it started — and the last read its page went out without — is done. Two navigations in a row each keep their own. Two limits keep this from leaking: when a hook's component leaves the page, its token (Current.Cancellation) is cancelled and the hook keeps the scope for ten seconds more at most — a loop over a stream that never ends does not keep its DbContext for the rest of the session — and when the session ends, every scope is disposed, whatever is still running.

Current.Service<T>() — the event's own, with nothing injected

Button.OnClick(async () =>
{
    var db = Current.Service<ShopDb>();   // this event's ShopDb
    db.Orders.Add(order);
    await db.SaveChangesAsync();          // still the same instance after the await
})

It answers from whatever work is in flight, as its constructor would have been handed it:

Where it is called What it returns
A live event (handler, navigation, pushed render, the hooks they run) — both hosts The event's own scoped instance: one per event, the same across awaits, disposed when the event ends. The session's services (route, navigator, IJSRuntime, IUserProvider, toaster, language, query cache, tenant) are the session's.
An HTTP request (minimal API, controller) The request's RequestServices.
A background job, a CQRS handler's scope That scope.
Nowhere — a constructor, a hosted service's own loop An InvalidOperationException naming what to inject instead. Never the root provider.

An unregistered T throws the standard "No service for type" error. Keep what it hands you in a local: a field that holds it finds it disposed in the next event. The scope is opened on first use, so an event that never asks costs nothing.

Asking another component to render, from a hook

A page often has something to tell the layout above it — its title for a breadcrumb, say — and tells it as it mounts: it sets a value on a scoped service, and whoever shows that value re-renders on the service's event.

// the page
protected override async Task OnMount() => heading.Set("Orders");

// a crumb in the layout
protected override async Task OnMount() => heading.Changed += StateHasChanged;
protected override Component? Render() => Text[heading.Text];

By then the layout's crumb has already rendered, with the old value: a layout is walked before the page inside it mounts. That is fine. StateHasChanged() on a component the pass in flight has already rendered — the crumb, or the layout itself — is never lost: the pass finishes, and one more follows that renders exactly what asked. It works the same from OnMount, OnUpdated and an event handler, on the component itself or on any other.

On the Server host the first response includes that follow-up render, so the crumb arrives filled in rather than being corrected a moment later over the socket. It is bounded: a component that asks again on every render gets two follow-ups per request, and the rest goes over the live connection.

Gotcha: a faulted async hook takes the page, not the component

If a lifecycle hook faults, it trips the nearest ErrorBoundary — and in a live app there is always one. The host wraps your App in an implicit root boundary, so a faulting OnMount / OnUpdated / OnFirstRender / OnRendered renders that boundary's fallback rather than logging quietly. It makes no difference where in the hook it throws — on its first line, in a read that fails before it ever awaits, or after an await — nor how deep the component is: a page, a layout, a child of a child.

The practical symptom is therefore the opposite of what you might expect: not a component stuck forever on a loading placeholder, but the whole page replaced by an error page — the one a Render that throws gets — because the boundary that caught it is the root one, unless you put a closer boundary in the way.

// Without a boundary of your own, a throw here replaces the entire document.
protected override async Task OnMount() => _rows = await api.Load();

// With one, the blast radius is the subtree you chose.
ErrorBoundary.Fallback((ex, retry) => Div[
    P["Could not load the rows."],
    Button.OnClick(retry)["Try again"]
])[
    RowList()
]

What the reader gets, on both hosts:

The hook faults The reader sees The first GET answers
before the page is sent — on the first request, or within the navigation's wait the error page, never the placeholder 500
after the page was painted on its placeholder (a read that outlasted the wait, an OnRendered) the placeholder, then the error page in the frame that follows 200 — it was already sent
inside an ErrorBoundary you wrote your fallback, the rest of the page as it was 200
after its component left the page nothing: leaving cancels the load, and what that throws is recorded at Information —

Two things follow:

  • Scope the damage yourself. An ErrorBoundary around the risky subtree keeps the rest of the page alive, and its Fallback receives a retry callback that clears the error and re-renders that subtree. A try/catch inside the hook is still the right tool when you want to render an error state rather than a fallback.
  • The root error page offers Try again as well as Reload this page. The first clears the error and re-renders in place, keeping the session and the state. A load that faulted is run again: the component whose OnMount or OnUpdated threw gets both once more, so Try again tries the read and not only the drawing — the same for the retry your own Fallback is handed. A render or a load that faults deterministically simply lands back on the error page, and then the reload is what you want.
  • The error page stands in for the page that faulted, not for the app. Back, or any other address, is another page, and it is drawn; one that faults too gets the error page again.

The initial GET for a page whose render or hook faulted before it was sent answers 500, not 200 — the body is still the error page, so both buttons work.

What a visitor sees when something throws

Outside Development, nothing of the exception. The built-in error page — the root one, and a nested ErrorBoundary with no Fallback — shows a heading, Try again where the boundary can recover, Reload this page, and one sentence with a reference:

Something went wrong
[Try again] [Reload this page]
The problem has been recorded. If it keeps happening, quote this reference: K7WQ2MZP

No exception type, no message, no inner exception, no stack and no source reach the response or the live frame: a message routinely carries a connection string, a query, a path or a user's data. The texts are RaskString.ErrorHeading, ErrorTryAgain, ErrorReload and ErrorReference (localization).

A handler that throws is not that page. A click, a change, any callback whose handler throws, in a part of the app you wrote no ErrorBoundary around, keeps the page it was on — with whatever the handler changed before it threw — and says so in an error toast that carries the same reference:

That did not work. Please try again. The problem has been recorded. If it keeps happening, quote this reference: K7WQ2MZP

The next click is answered as usual. The sentence is RaskString.HandlerFailed followed by ErrorReference. What still replaces the page, or the part your own boundary wraps, is a fault the tree cannot be drawn around: a Render or a lifecycle hook that throws, a handler inside an ErrorBoundary you wrote (see a handler that fails), and a handler in a host whose layout draws no toasts — there would be nowhere to say it.

The reference is how support finds it. Every exception a boundary or that toast takes — the root one or your own, whatever its fallback draws — is logged once at Error on the Rask.Errors category, with the exception attached and the same reference in the message:

fail: Rask.Errors[0]
      An unhandled exception reached an error boundary from an event handler. Reference K7WQ2MZP
      System.InvalidOperationException: Login failed for user 'sa' …
         at …

A handler's line reads reached a toast from an event handler in place of the boundary. It is eight random characters, made on the host when the exception is first seen, and derived from nothing: not the exception, not the visitor, not the request. One exception keeps one reference however many times its page is drawn. With request tracing on, the same log line carries the host's trace id as usual. A build-time prerender shows its pages to nobody: what a boundary takes there — typically a hook reaching for a browser that is not there — is one Information line, and the pass itself reports a page it could not write.

In Development the page shows everything it always has — type, message, each cause, the stack with source excerpts — and the reference beside them, for a render and for a lifecycle hook alike. A handler's fault is shown over the running app instead (rask dev), and a handler's toast adds the exception's type and message after its sentence.

What decides Development: the host's own IHostEnvironment, which AddRask/RaskApp register for that host alone — two hosts in one process (a test run, an app mounting another) each answer for their own renders, handlers and sessions, never with whichever started first. A host that never says — a hand-wired host with no IHostEnvironment, a component rendered outside a host — falls back to ASPNETCORE_ENVIRONMENT, then DOTNET_ENVIRONMENT, and with neither set to Development it is not Development. A browser (WASM) app has no such variables, so it shows the Production page unless its own startup sets LiveOptions.IsDevelopment = true; the exception is in the browser console either way.

Your own Fallback is handed the exception — do not print it. ex.Message and ex.GetType().Name on a page are exactly what the built-in page no longer shows. Say what the visitor can do, as the example above does, and branch on the exception's type if the wording depends on it. It is already in the log.

Gotcha: don't StateHasChanged() in unmount

When OnUnmount runs, the component's lifetime CancellationToken is still live — it's cancelled immediately after the hook returns. But the component is already leaving the tree, so calling StateHasChanged() from inside an unmount hook is a no-op by design (it's been flagged unmounted before the hook fires). Don't request a render from unmount.

protected override async Task OnUnmount()
{
    route.Changed -= StateHasChanged;   // typical: tear down subscriptions
    // do NOT call StateHasChanged() here — the component is leaving the tree
}

Disposal: IDisposable / IAsyncDisposable

Components that implement IDisposable or IAsyncDisposable get their Dispose / DisposeAsync called by the framework when they leave the render tree. Use it to release timers, subscriptions, or any handle you took out in OnMount. Disposal walks children depth-first, so nested disposables tear down bottom-up.

Mount, then unmount — the sync probe's Dispose() runs as the parent's diff removes it from the tree:

DisposableTimerProbe.cs
namespace Rask.Site.Features;

public sealed partial class DisposableTimerProbe : Component, IDisposable
{
    private DateTimeOffset _mountedAt;

    public Callback<string> Log { get; set; }
    public required int InstanceId { get; set; }

    public void Dispose() =>
        _ = Log.Invoke($"#{InstanceId} disposed (lived {(TimeProvider.System.GetLocalNow() - _mountedAt).TotalMilliseconds:F0} ms)").AsTask();

    protected override async Task OnMount()
    {
        _mountedAt = TimeProvider.System.GetLocalNow();
        await Log.Invoke($"#{InstanceId} mounted");
    }

    protected override Component? Render() =>
        Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Ui.Badge.Color(Ui.Color.Yellow).Class("dispose-probe-pill")[$"#{InstanceId} alive"],
            Span.Class("text-zinc-500 dark:text-white/70 text-sm")[$"Mounted at {_mountedAt:HH:mm:ss.fff}. Unmount me to fire Dispose()."]
        ];
}
Live result

Probe not mounted.

Log

Empty — mount and unmount the probe.

The async variant is awaited on its own dispatch path; the log entry shows up after the next render cycle resolves the continuation:

DisposableAsyncProbe.cs
namespace Rask.Site.Features;

public sealed partial class DisposableAsyncProbe : Component, IAsyncDisposable
{
    private DateTimeOffset _mountedAt;

    public Callback<string> Log { get; set; }
    public required int InstanceId { get; set; }

    public ValueTask DisposeAsync() =>
        Log.Invoke($"#{InstanceId} async-disposed (lived {(TimeProvider.System.GetLocalNow() - _mountedAt).TotalMilliseconds:F0} ms)");

    protected override async Task OnMount()
    {
        _mountedAt = TimeProvider.System.GetLocalNow();
        await Log.Invoke($"#{InstanceId} async-mounted");
    }

    protected override Component? Render() =>
        Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Ui.Badge.Color(Ui.Color.Blue).Class("dispose-async-pill")[$"#{InstanceId} alive"],
            Span.Class("text-zinc-500 dark:text-white/70 text-sm")[
                $"Mounted at {_mountedAt:HH:mm:ss.fff}. Unmount me to fire DisposeAsync()."]
        ];
}
Live result

Probe not mounted.

Log

Empty — mount and unmount the probe.

OnUnmount vs IDisposable

OnUnmount is the framework-side cleanup signal. It fires before the lifetime CancellationToken is cancelled, so cleanup code can still observe the token. Reach for it when the resource is conceptually a lifecycle hook (unsubscribe from an event, stop a timer you started in OnMount) and reserve IDisposable for things you would dispose anyway in non-Rask code (file handles, HTTP responses, DB connections):

UnmountTimerProbe.cs
namespace Rask.Site.Features;

// Holds a Timer started in Mount and stopped in Unmount. Demonstrates the "use the
// lifecycle hook for things that mirror Mount" pattern — no IDisposable required.
#pragma warning disable CA1001 // disposed in OnUnmount, the lifecycle hook this demo teaches instead of IDisposable
public sealed partial class UnmountTimerProbe : Component
#pragma warning restore CA1001
{
    private int _ticks;
    private Timer? _timer;

    public Callback<string> Log { get; set; }
    public required int InstanceId { get; set; }

    protected override async Task OnMount()
    {
        await Log.Invoke($"#{InstanceId} ticker started");
        _timer = new Timer(_ =>
        {
            Interlocked.Increment(ref _ticks);
            StateHasChanged();
        }, null, 1000, 1000);
    }

    protected override async Task OnUnmount()
    {
        if (_timer is not null)
        {
            await _timer.DisposeAsync();
            _timer = null;
        }
        await Log.Invoke($"#{InstanceId} ticker stopped after {_ticks} tick(s)");
    }

    protected override Component? Render() =>
        Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Ui.Badge.Color(Ui.Color.Yellow)[$"#{InstanceId} tick {_ticks}"],
            Span.Class("text-zinc-500 dark:text-white/70 text-sm")["Stop me to fire OnUnmount and dispose the Timer."]
        ];
}
Live result

Ticker not running.

Log

Empty — mount and unmount the probe.

Cancellation tied to component lifetime

Every component exposes a protected CancellationToken CancellationToken. It's allocated lazily (a component that never reads it pays nothing) and cancelled exactly once when the component is unmounted. Pass it into HttpClient calls, Task.Delay, or any cancellable async work started in a lifecycle hook so it aborts cleanly when the user navigates away:

public sealed partial class CancellationProbe : Component
{
    public required Action<string> Log { get; set; }
    public required int InstanceId { get; set; }

    protected override async Task OnMount()
    {
        try
        {
            await Task.Delay(2500.Milliseconds, CancellationToken);
            Log($"#{InstanceId} completed");
        }
        catch (OperationCanceledException)
        {
            Log($"#{InstanceId} cancelled");
        }
    }
}

The framework cancels the token before disposing the subtree, so awaits unwind via OperationCanceledException before Dispose runs and the unmount hooks fire. Cooperation is required: the framework only signals the token — it doesn't abort blocking calls. Thread the token through anything you want cancelled.

Each component answers to its own lifetime, whoever caused it to mount. A call that passes no token — await Product.Count(), Cache.Remember(…) — is cancelled with the work in progress: in OnMount and OnUpdated that is the hook's own component, in an event handler the component the handler belongs to. A page reached by Go() from a handler mounts during that handler's turn and still loads under its own lifetime, not under the saving page's, which the navigation has just ended. The handler keeps its own component's: code after a Go() that unmounted it finds the token cancelled, so finish the work before navigating. Leaving a page while its load is in flight shows nothing — the cancellation is silent, and a failure a data provider raises because of it is logged, never displayed on the page the visitor went to.

A callback answers to the component that wrote it, not the one that invoked it. A page's .OnSaved(async () => { _editing = false; await Reload(); }) is the page's code, reached through the editor's handler: while it runs — its first line and everything after each await — the work in progress is the page's, so unmounting the editor does not cancel the reload, and leaving the page does. When it returns, the editor's handler is back under the editor's lifetime. With a HandlerTimeout configured the callback is bound by both: its owner's lifetime and the same timeout as the event that raised it. More in callbacks.

Mount the probe to start a 2.5-second Task.Delay inside OnMount; click Unmount before it settles to cancel — the probe records what happened into the log:

CancellationProbe.cs
using System.Diagnostics;

namespace Rask.Site.Features;

public sealed partial class CancellationProbe : Component
{
    private readonly Stopwatch _watch = new();
    private int _logged;
    private string _status = "pending";

    public Callback<string> Log { get; set; }
    public required int InstanceId { get; set; }

    protected override async Task OnMount()
    {
        // Capture the lifetime token ONCE up-front. Reading Component.CancellationToken
        // after the framework has disposed the underlying CTS would lazily allocate a
        // fresh (uncancelled) CTS — masking the very signal we're trying to observe.
        var token = CancellationToken;
        _watch.Start();
        _status = "running";
        // Make the "running" pill visible BEFORE the long await — the framework's
        // post-await StateHasChanged only fires after the continuation resumes, so
        // without this the user would jump straight from "pending" to "completed".
        StateHasChanged();

        // Synchronous cancellation observer: fires the instant the framework calls
        // Cancel() on our lifetime CTS (inside DisposeComponentTree, before the loop's
        // next Task.Delay continuation would otherwise resume). On WASM CI the polling
        // continuation can be delayed enough by event-loop contention to push the test
        // past its 10s timeout — registering here makes the "cancelled" log entry
        // appear in lock-step with the framework's dispose pass, independent of any
        // scheduler latency. Interlocked guards against the loop's own cancellation
        // observation logging a duplicate entry.
        using var registration = token.Register(static state =>
        {
            var probe = (CancellationProbe)state!;
            if (Interlocked.Exchange(ref probe._logged, 1) != 0)
            {
                return;
            }

            if (probe._watch.IsRunning)
            {
                probe._watch.Stop();
            }

            _ = probe.Log.Invoke($"#{probe.InstanceId} cancelled ({probe._watch.ElapsedMilliseconds} ms)").AsTask();
        }, this);

        // Cooperative cancellation in 100ms slices. We poll the captured token
        // rather than passing it into Task.Delay because, on single-threaded WASM,
        // a Task.Delay cancellation raised from inside the dispatch lock doesn't
        // always resume the await — polling at every slice boundary guarantees
        // we notice the cancellation within ~100ms of it being requested even
        // when the Register callback above somehow missed.
        while (_watch.ElapsedMilliseconds < 2500)
        {
            await Task.Delay(100);
            if (token.IsCancellationRequested)
            {
                return;
            }
        }

        if (Interlocked.Exchange(ref _logged, 1) != 0)
        {
            return;
        }

        _watch.Stop();
        _status = "completed";
        await Log.Invoke($"#{InstanceId} completed ({_watch.ElapsedMilliseconds} ms)");
    }

    protected override Component? Render()
    {
        var pillClass = _status switch
        {
            "running" => "badge text-bg-warning",
            "completed" => "badge text-bg-success",
            _ => "badge text-bg-secondary"
        };

        return Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Span.Class($"{pillClass} cancel-probe-pill")[$"#{InstanceId} {_status}"],
            Span.Class("text-zinc-500 dark:text-white/70 text-sm")[
                _status is "running"
                    ? "Awaiting Task.Delay(2500ms, CancellationToken). Click Unmount to abort."
                    : "Awaited task settled — probe is still alive."
            ]
        ];
    }
}
Live result

Probe is not mounted.

Log

Mount and unmount the probe to populate this log.

Background service

An app-wide background process can push updates into the UI, and this is where ongoing work belongs — not in a lifecycle hook. Have the producer — a singleton, a hosted service, a timer, a job, another page's handler — publish an event, and let the page that shows it handle it:

// Anywhere: a BackgroundService, a job, a handler — any thread.
await Dispatcher.Publish(new PumpStatusChanged(pumpId));

// The page: a component that implements IEventHandler<T>.
public sealed partial class PumpsPage : Component, IEventHandler<PumpStatusChanged>
{
    private List<Pump> _pumps = [];

    public async Task Handle(PumpStatusChanged e) => _pumps = await Pump.Where(p => p.Active).ToList();

    protected override Component? Render() => …;
}

Unlike a poll loop inside one component, this producer is decoupled from the component tree — it keeps working across navigations (and, on the Server, across every session), and no first render is ever waiting on it.

A page that handles an event

A component that implements IEventHandler<T> is the page's listener. A service handler runs once per event; a component handler runs once per open page that has that component mounted — each in its own page's session:

  • After what the page is doing, never beside it. It is queued behind the page's own events, under the session's lock, like a click: a click in flight finishes first, and the handlers of one page run in the order the events were published.
  • As that page's user, with the event's own services. It gets a fresh scope, as every event does (above): the session's current user and tenant, a DbContext of its own.
  • Drawn once. The page renders once when Handle returns; no StateHasChanged() to call.
  • Never dropped. A page between two connections handles it and is sent the result when it reconnects.
  • Only while mounted. A component that has left the page hears nothing more; there is nothing to unsubscribe.
  • A fault stays on its page. One that throws is logged and shown as the handler-fault toast, as a click's would be; every other page still hears the event.
  • After the commit. Published inside Db.Transaction — or raised by an aggregate with Raise(…) — it reaches the pages once the transaction commits, and never after a rollback.

Registration is generated at build time, so a component that handles nothing costs nothing to mount, and a component is never a DI service: a publish never builds one. Delivery stays inside this process — every open page this server holds; a browser app hears what is published in that tab.

Following a plain .NET event

source.Changed += StateHasChanged still works for anything that raises an ordinary event, and StateHasChanged() may be called from any thread: a BackgroundService raising its event straight from the pool, several threads at once, with no dispatcher and no await. It never renders on top of a render in flight — it marks the component and asks its session, which draws under its own lock. It does not run your code in the page's session, though: reach for a component handler (above) when the reaction reads data or needs the user. Three things are guaranteed:

  • The last state is always drawn. Whatever was marked when the call returned reaches the reader's page without another event, wherever the call landed: while a handler runs, inside a render, after a read came back, between two connections.
  • Calls that land together are drawn together. While the session is handling an event or rendering, every call is folded into the next render rather than given one of its own. A dispatch renders at most three times; what is asked for after that is one more render straight after the dispatch.
  • A runaway is slowed and named, never spun. A component that asks for a render from every Render() would otherwise never stop. After ten renders in a row that were each asked for while the one before was being made, each further one waits 250 ms, and a warning on Rask.Live names the component types that asked. The page stays current and the reader's events are answered in between.

Subscribe on mount and unsubscribe on unmount, mutate the state before the call, and do not call StateHasChanged() from Render() itself.

Hosted services

A self-starting singleton is the simplest producer, but it gives you no say over when it starts and no chance to shut it down cleanly. For that, register an IHostedService — usually by deriving from BackgroundService:

builder.Services.AddHostedService<ReportGenerator>();

This works the same on both hosts. On the Server the generic host starts it; on WASM the framework starts it for you at the end of boot — late enough that a service is free to mutate state and call StateHasChanged() against a mounted tree, and early enough that the work has begun before anyone can interact. Registration order is start order, and startup is sequential.

Be precise about what "started" buys you, though: for a BackgroundService it means ExecuteAsync reached its first await, not that it finished initialising. If one service must not run until another is genuinely ready — a job processor that must not poll until its store has restored a snapshot — make it wait on something explicit (a TaskCompletionSource, a readiness flag); registration order alone will not do it.

Three differences from the Server are worth knowing:

  • A failure to start is not fatal. On the Server a hosted service that throws from StartAsync aborts startup, which is right when an orchestrator can restart the process. A browser tab has nothing to restart, so the failure is logged and the app carries on without that service rather than showing a blank page. One caveat: a hosted service whose constructor throws (or whose dependency is not registered) takes the whole set down, because the container builds them all in a single call — you get a clear error, and no hosted services.
  • A loop that faults later is reported. StartAsync has already returned by the time a BackgroundService's ExecuteAsync fails, and nothing on this host awaits it, so Rask observes the execute task for you and logs a fault. Without that, a crashed background loop would look exactly like one that was never started.
  • Shutdown is best-effort. The browser's nearest thing to SIGTERM is pagehide, and it does not wait for anything a handler starts. Rask drains hosted services there (in reverse start order, and not for a back/forward-cache suspend, where the page can be restored still running), but a service may get little time or none. Treat it as an optimisation — Rask.Jobs' processor, for instance, hands its lease back in StopAsync, and when that does not land the lease simply expires, exactly as it would for a server that was killed rather than drained.

Confirm