JavaScript Interop

The SessionStorageService pattern already shown is one small case of JS interop. This page covers the general shape: calling JS from C#, calling back into C# from JS, and isolating your interop code in a proper JS module instead of leaking functions onto window.

C# calling JS: prefer an isolated module over global functions

Putting interop functions on window works but pollutes global scope and can collide with libraries. A JS module loaded via IJSObjectReference is scoped to whichever component imports it — nothing else on the page can see or call it.

wwwroot/js/clipboard.js
export function copyToClipboard(text) {
    return navigator.clipboard.writeText(text);
}

export function getViewportWidth() {
    return window.innerWidth;
}
ClipboardButton.razor
@implements IAsyncDisposable
@inject IJSRuntime JS

<button @onclick="Copy">Copy</button>

@code {
    private IJSObjectReference? _module;

    [Parameter] public string TextToCopy { get; set; } = "";

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
            _module = await JS.InvokeAsync<IJSObjectReference>(
                "import", "./js/clipboard.js");
    }

    private async Task Copy()
    {
        if (_module is not null)
            await _module.InvokeVoidAsync("copyToClipboard", TextToCopy);
    }

    public async ValueTask DisposeAsync()
    {
        if (_module is not null)
            await _module.DisposeAsync();
    }
}

JS calling back into C#

Two flavors: a static method callable without any particular component instance (good for a one-off global event like a resize listener), and an instance method via DotNetObjectReference when JS needs to call back into a specific component.

static — [JSInvokable]
public partial class ThemeWatcher
{
    [JSInvokable]
    public static Task OnSystemThemeChanged(bool isDark)
    {
        Console.WriteLine($"System theme is now {(isDark ? "dark" : "light")}");
        return Task.CompletedTask;
    }
}
instance — DotNetObjectReference, for callbacks scoped to one component
@implements IAsyncDisposable
@inject IJSRuntime JS

@code {
    private DotNetObjectReference<ResizeWatcher>? _selfReference;
    private IJSObjectReference? _module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        _selfReference = DotNetObjectReference.Create(this);
        _module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/resize.js");
        await _module.InvokeVoidAsync("registerResizeListener", _selfReference);
    }

    [JSInvokable]
    public void OnResize(int width, int height)
    {
        Console.WriteLine($"Viewport is now {width}x{height}");
        StateHasChanged();
    }

    public async ValueTask DisposeAsync()
    {
        _selfReference?.Dispose(); // MUST dispose — JS holds a reference until you do
        if (_module is not null) await _module.DisposeAsync();
    }
}
wwwroot/js/resize.js
export function registerResizeListener(dotNetRef) {
    window.addEventListener("resize", () => {
        dotNetRef.invokeMethodAsync("OnResize", window.innerWidth, window.innerHeight);
    });
}
The most common Blazor interop bug: forgetting _selfReference?.Dispose(). Every DotNetObjectReference is a live handle JS can call through — skip disposing it and the component (and everything it references) never gets garbage collected, even after navigating away.