You're reading the Rask v1.0.0 guides.View source
All guides

Lifecycle

Every Component can override a small set of lifecycle hooks. They fire at well-defined points around each render, in both synchronous and asynchronous flavours, and are identical on the Server and WASM hosts (only the transport differs). This page documents the exact hooks, their order, the async rules, and the gotchas.

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

The hooks

All hooks are protected virtual on Component. Each has a sync and an async variant; you can override either or both, and they run in pairs (sync first, then async):


protected virtual void OnMount() { }
protected virtual Task OnMountAsync() => Task.CompletedTask;

protected virtual void OnPropsChanged() { }
protected virtual Task OnPropsChangedAsync() => Task.CompletedTask;

protected virtual void OnRendered(bool firstRender) { }
protected virtual Task OnRenderedAsync(bool firstRender) => Task.CompletedTask;

protected virtual void OnUnmount() { }
protected virtual Task OnUnmountAsync() => Task.CompletedTask;

Order

Hook When
OnMount / OnMountAsync Once, on first creation of the instance (first render only).
OnPropsChanged / OnPropsChangedAsync On the first render, and on any later render where a bound prop / route or query param actually changed.
OnRendered / OnRenderedAsync After every render commit, with a firstRender flag.
OnUnmount / OnUnmountAsync Once, on disposal (navigation away, parent subtree torn down, session teardown). Children unmount before parents (depth-first).

So on the first render of a component you get, in order: OnMountOnMountAsyncOnPropsChangedOnPropsChangedAsync → (render) → OnRendered(firstRender: true)OnRenderedAsync(firstRender: true). On disposal: OnUnmountOnUnmountAsync.

Live probe

The component below records every hook invocation into a list 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* / OnPropsChanged* (nothing the component is bound to changed):

LifecycleProbe.cs

namespace Rask.Site.Features;

public sealed partial class LifecycleProbe : Component
{
    private readonly List<string> _log = new();
    private int _renderCount;

    protected override void OnMount() => _log.Add("OnMount");

    protected override async Task OnMountAsync()
    {
        _log.Add("OnMountAsync (start)");
        await Task.Delay(450);
        _log.Add("OnMountAsync (after 450ms await)");
    }

    protected override void OnPropsChanged() => _log.Add($"OnPropsChanged (render #{_renderCount + 1})");

    protected override Task OnPropsChangedAsync()
    {
        _log.Add("OnPropsChangedAsync");
        return Task.CompletedTask;
    }

    protected override void OnRendered(bool firstRender) =>
        _log.Add($"OnRendered(firstRender: {firstRender})");

    protected override Component? Render() =>
        [
            Div.Class("flex gap-3 items-center flex-wrap mb-3")[
                Span.Class($"{Tw.BadgePrimary} text-base")[$"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 Button.Type("button").Class(Tw.BtnSecondary),
                // which forwards the callback down to the native <button>.
                Button.Type("button").Class(Tw.BtnPrimary)
                    .OnClick(() => _log.Add("Trigger re-render (button click)"))[UiIcon.Name(UiIconName.Retry).Class("me-1"), "Trigger re-render"]
            ],
            H3.Class("text-base font-semibold text-ui-muted uppercase text-sm")["Hook log"],
            Ol.Class($"{Tw.ListGroup} list-decimal list-inside divide-y divide-ui-line")[
                _log.Select((l, i) => Li.Key(i).Class($"{Tw.ListGroupItem} ps-2 text-sm")[Code.Class("text-sm")[l]])
                    .ToArray()]
        ];
}
Live result
Render #2

Hook log

  1. OnMount
  2. OnMountAsync (start)
  3. OnPropsChanged (render #1)
  4. OnPropsChangedAsync
  5. OnRendered(firstRender: True)
  6. OnMountAsync (after 450ms await)

Mount / unmount cycle

Toggle the probe in and out of the tree to watch OnUnmount and OnUnmountAsync 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 OnUnmount / OnUnmountAsync — 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 required Action<string> Log { get; set; }
    public required int InstanceId { get; set; }

    protected override void OnMount() => Log.Invoke($"#{InstanceId} OnMount");

    protected override async Task OnMountAsync()
    {
        Log.Invoke($"#{InstanceId} OnMountAsync (start)");
        await Task.Delay(150);
        Log.Invoke($"#{InstanceId} OnMountAsync (after 150ms await)");
    }

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

    protected override Task OnUnmountAsync()
    {
        Log.Invoke($"#{InstanceId} OnUnmountAsync");
        return Task.CompletedTask;
    }

    protected override Component? Render() =>
        Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Span.Class(Tw.BadgeSuccess)[$"#{InstanceId} alive"],
            Span.Class("text-ui-muted text-sm")["Unmount me to fire OnUnmount / OnUnmountAsync."]
        ];
}
Live result

Probe not mounted.

Log

Empty — mount and unmount the probe.

A typical async-data page uses OnMountAsync 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 OnMountAsync() =>
        _forecasts = await service.GetForecastsAsync();

    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. The placeholder still renders whenever the page mounts later (a client-side navigation), and still shows if the fetch outlives the budget, in which case the page keeps its live session and finishes loading over the socket. See Render modes.

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


protected override async Task OnMountAsync()
{
    await ReadAsync(CancellationToken);   // awaited: the GET waits for this
    _ = PollAsync(CancellationToken);     // detached: it must not hold the response open
}

When OnPropsChanged* refires

OnPropsChanged* 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 OnPropsChanged* — nothing the component is bound to changed. (Key is a reconciliation identity, not a reactive prop, so a key change doesn't fire OnPropsChanged either; it mounts a fresh instance.)

Do not run an unbounded loop in OnMountAsync

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 OnMountAsync 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.

Sync vs async rules

The async 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 OnMountAsync()
{
    // placeholder shows here
    _data = await LoadAsync();
    // auto re-render after the await → real data paints, no StateHasChanged()
}

OnRenderedAsync is loop-safe. The terminal auto re-render is a publish-only walk: it does not re-fire OnRendered / OnRenderedAsync on components that have already rendered at least once. That's what keeps an OnRenderedAsync 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 first OnRendered(firstRender: true).


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

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

If an async 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, and every component is stamped with the boundary above it during the render walk, so a faulting OnMountAsync / OnPropsChangedAsync / OnRenderedAsync renders that boundary's fallback rather than logging quietly.

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 — 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 OnMountAsync() => _rows = await api.LoadAsync();

// 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()
]

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, the state and the scroll position — enough for the common case, a handler that threw and damaged nothing. A render that faults deterministically simply lands back on the error page, and then the reload is what you want.

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

Console.Error only comes into it when there is genuinely no boundary — a component rendered outside a live render context. In a live app that path is unreachable, so do not go looking there for a fault you can see on screen.

Gotcha: don't StateHasChanged() in unmount

When OnUnmount / OnUnmountAsync 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 void 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 required Action<string> Log { get; set; }
    public required int InstanceId { get; set; }

    public void Dispose() =>
        Log.Invoke($"#{InstanceId} disposed (lived {(DateTimeOffset.Now - _mountedAt).TotalMilliseconds:F0} ms)");

    protected override void OnMount()
    {
        _mountedAt = DateTimeOffset.Now;
        Log.Invoke($"#{InstanceId} mounted");
    }

    protected override Component? Render() =>
        Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Span.Class($"{Tw.BadgeWarning} dispose-probe-pill")[$"#{InstanceId} alive"],
            Span.Class("text-ui-muted 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 required Action<string> Log { get; set; }
    public required int InstanceId { get; set; }

    public ValueTask DisposeAsync()
    {
        Log.Invoke($"#{InstanceId} async-disposed (lived {(DateTimeOffset.Now - _mountedAt).TotalMilliseconds:F0} ms)");
        return ValueTask.CompletedTask;
    }

    protected override void OnMount()
    {
        _mountedAt = DateTimeOffset.Now;
        Log.Invoke($"#{InstanceId} async-mounted");
    }

    protected override Component? Render() =>
        Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Span.Class($"{Tw.BadgeInfo} dispose-async-pill")[$"#{InstanceId} alive"],
            Span.Class("text-ui-muted 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 / OnUnmountAsync 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 OnMount and stopped in OnUnmount. Demonstrates the "use the
// lifecycle hook for things that mirror OnMount" pattern — no IDisposable required.
public sealed partial class UnmountTimerProbe : Component
{
    private int _ticks;
    private Timer? _timer;

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

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

    protected override void OnUnmount()
    {
        _timer?.Dispose();
        _timer = null;
        Log.Invoke($"#{InstanceId} ticker stopped after {_ticks} tick(s)");
    }

    protected override Component? Render() =>
        Div.Class("flex gap-2 items-center flex-wrap items-center")[
            Span.Class(Tw.BadgeWarning)[$"#{InstanceId} tick {_ticks}"],
            Span.Class("text-ui-muted 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 OnMountAsync()
    {
        try
        {
            await Task.Delay(TimeSpan.FromMilliseconds(2500), 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.

Mount the probe to start a 2.5-second Task.Delay inside OnMountAsync; 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 required Action<string> Log { get; set; }
    public required int InstanceId { get; set; }

    protected override async Task OnMountAsync()
    {
        // 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)");
        }, 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";
        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-ui-muted text-sm")[
                _status == "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. Register the producer as a singleton with a lifetime of its own, have it raise an event each tick, and let components subscribe:


protected override void OnMount()   => feed.Updated += StateHasChanged;
protected override void OnUnmount() => feed.Updated -= StateHasChanged;

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

Each consumer subscribes on mount and unsubscribes on unmount so it stops repainting (and can be collected) once it leaves the tree. The loop runs on a background thread, so StateHasChanged() crosses threads — safe here: it schedules a render under the subscriber's own session lock and is a no-op once the component unmounts. Publish the producer's state as a single immutable snapshot swapped by reference, so a reader on the render thread cannot catch a half-built one.

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.