# Blazor WASM Lifecycle

Source: https://lumeo.nativ.sh/docs/blazor-wasm-lifecycle

# 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 `PersistentComponentState` to 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 `StateHasChanged` inside `OnAfterRender` unconditionally — it causes an infinite render loop.
-   If you must trigger a second render from `OnAfterRender` (e.g. after measuring the DOM), gate it on a `bool _needsRemeasure` flag 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](docs/services/component-interop)
-   [Accessibility](docs/accessibility) — focus trap and scroll lock setup
-   [Theme overrides](docs/theme-overrides)
