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: OnMount → OnMountAsync → OnPropsChanged →
OnPropsChangedAsync → (render) → OnRendered(firstRender: true) → OnRenderedAsync(firstRender: true). On disposal:
OnUnmount → OnUnmountAsync.
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):
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()]
];
}
Hook log
OnMountOnMountAsync (start)OnPropsChanged (render #1)OnPropsChangedAsyncOnRendered(firstRender: True)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:
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."]
];
}
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
ErrorBoundaryaround the risky subtree keeps the rest of the page alive, and itsFallbackreceives aretrycallback that clears the error and re-renders that subtree. Atry/catchinside 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 againas well asReload 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:
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()."]
];
}
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:
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()."]
];
}
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):
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."]
];
}
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:
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."
]
];
}
}
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
StartAsyncaborts 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.
StartAsynchas already returned by the time aBackgroundService'sExecuteAsyncfails, 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
SIGTERMispagehide, 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 inStopAsync, and when that does not land the lease simply expires, exactly as it would for a server that was killed rather than drained.