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(). EveryDotNetObjectReferenceis 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.