Blazor WASM Lifecycle
Most Lumeo bugs that look like the library's fault — a component that flashes empty, a focus
trap that fails to attach, a value that doesn't show until the user clicks something — are
actually lifecycle mistakes in the consuming app. This page collects the rules: which
lifecycle hook runs when, when the DOM is ready, when JS interop is safe, and how to keep
StateHasChanged from
either silently dropping updates or rendering forever.
The hooks, in order
| Hook | When | Safe to do |
|---|---|---|
SetParametersAsync |
Each render the parent triggers, before parameter assignment. | Intercept parameter changes (rarely needed). |
OnInitializedAsync |
Once per component instance, before the first render. | Fetch initial data, subscribe to services. No DOM, no JS interop on WASM prerender. |
OnParametersSetAsync |
After parameters are set, every render. | React to parameter changes. Guard against re-running fetches needlessly. |
OnAfterRenderAsync(firstRender) |
After every render, on the UI thread, with the DOM populated. | JS interop, ElementReference access, focus management, third-party widget init. |
Dispose / DisposeAsync |
When the component is removed from the render tree. | Unsubscribe, dispose JS modules, release focus traps. Catch JSDisconnectedException. |
OnInitializedAsync vs OnAfterRenderAsync
The dividing line is the DOM. Anything that touches an
ElementReference, calls
IJSRuntime with module
interop, or reads layout (sizes, scroll positions) belongs in
OnAfterRenderAsync.
Everything else — fetching the data that the component will render — belongs in
OnInitializedAsync.
@inject IDataService Data
@inject ComponentInteropService Interop
<div id="my-panel">
@if (_items is null) { <Spinner /> }
else { @* render items *@ }
</div>
@code {
private List<Item>? _items;
protected override async Task OnInitializedAsync()
{
// Data only. No DOM. No JS.
_items = await Data.LoadAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
// DOM exists now — safe to set up interop. RegisterClickOutside takes
// the element id, an optional trigger id, and an async handler.
await Interop.RegisterClickOutside("my-panel", triggerElementId: null, OnClickedOutsideAsync);
}
private Task OnClickedOutsideAsync() { /* ... */ return Task.CompletedTask; }
}Prerender quirks
If your WASM app is hosted in an ASP.NET Core endpoint with prerendering enabled,
OnInitializedAsync runs
twice: once on the server during prerender, and again in the browser after the WASM payload
boots. JS interop in OnInitializedAsync
fails on the server pass with
InvalidOperationException: JavaScript interop calls cannot be issued at this time.
Stick to data-only work there.
- Use
OperatingSystem.IsBrowser()to branch when needed. - Use
PersistentComponentStateto avoid double-fetching across prerender + WASM boot. - Anything DOM-bound (a sheet's focus trap, scroll position) won't run on the server pass anyway — it lives in
OnAfterRenderAsync.
protected override async Task OnInitializedAsync()
{
if (OperatingSystem.IsBrowser())
{
_items = await Data.LoadAsync();
}
// On the server prerender pass, leave _items null — we render a skeleton.
// After WASM boots, this method runs again on the client and fetches.
}StateHasChanged hygiene
Blazor calls StateHasChanged
for you after every event handler and every awaited lifecycle method. You need to call it
manually only when the change happens outside those flows: a service event you subscribe to,
a System.Timers.Timer
tick, a JS-to-.NET callback, a Task.Run.
- Calls from non-UI threads must go through
InvokeAsync(StateHasChanged). - Do not call
StateHasChangedinsideOnAfterRenderunconditionally — it causes an infinite render loop. - If you must trigger a second render from
OnAfterRender(e.g. after measuring the DOM), gate it on abool _needsRemeasureflag and flip the flag.
// 1. Service event from background — non-UI thread.
protected override void OnInitialized()
{
_hub.NotificationReceived += OnNotification;
}
private void OnNotification(Notification n)
{
_notifications.Add(n);
InvokeAsync(StateHasChanged);
}
// 2. Measure-then-render — guarded with a flag.
private bool _measured;
private double _height;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !_measured)
{
_height = await Interop.GetElementHeightAsync(_root);
_measured = true;
StateHasChanged(); // safe: guarded by _measured
}
}JS interop timing
Lumeo overlay components (Sheet, Dialog, Drawer, Popover, Tooltip) call into
ComponentInteropService
from OnAfterRenderAsync
because they need the element to exist in the DOM. If you build your own interop wrappers,
follow the same rule and remember to clean up on dispose.
private IJSObjectReference? _module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./_content/MyLib/widget.js");
await _module.InvokeVoidAsync("init", _hostRef);
}
}
public async ValueTask DisposeAsync()
{
try
{
if (_module is not null)
{
await _module.InvokeVoidAsync("dispose");
await _module.DisposeAsync();
}
}
catch (JSDisconnectedException) { /* circuit gone; nothing to clean up */ }
}
JSDisconnectedException
is thrown when the circuit closes (page unload, browser tab close) before your dispose
finishes. It is not a bug — swallow it in dispose paths so the disposal can complete.
Common symptoms and their causes
| Symptom | Likely cause |
|---|---|
| Data appears one render late | Awaiting inside OnAfterRender without flipping a flag + calling StateHasChanged. |
| Component renders forever, CPU at 100 % | Unconditional StateHasChanged in OnAfterRender. |
JavaScript interop calls cannot be issued at this time |
JS call during prerender or before OnAfterRender. |
JSDisconnectedException in logs |
Cleanup running after circuit close. Catch and ignore inside dispose. |
| UI doesn't update after a service event | Missing InvokeAsync(StateHasChanged) from the event handler. |
| Sheet opens but focus trap doesn't attach | Custom code setting Open in OnInitializedAsync before the DOM exists. |
See also
- Component Interop Service
- Accessibility — focus trap and scroll lock setup
- Theme overrides