Calling JavaScript from C#
Blazor WebAssembly runs inside the browser, but sometimes you need to reach beyond the sandbox and use native browser features—localStorage, geolocation, or even a third-party map. The runtime exposes IJSRuntime for exactly that purpose.
Creating a Facade Function
Start with a plain JavaScript function that will act as the public façade between the two worlds. Parameters and return values must be JSON-serializable because they travel over a text-based channel.
// wwwroot/js/storageBridge.js
window.storageBridge = {
read: key => key in localStorage ? JSON.parse(localStorage[key]) : null,
write: (key, value) => localStorage[key] = JSON.stringify(value),
remove: key => delete localStorage[key]
};
Reference the file in index.html after the Blazor boot script:
<script src="_framework/blazor.webassembly.js"></script>
<script src="js/storageBridge.js"></script>
Invoking the Facade from C#
Inject IJSRuntime and use the asynchronous helpers:
@inject IJSRuntime JS
private async Task<int> GetLastCountAsync()
{
return await JS.InvokeAsync<int>("storageBridge.read", "counter");
}
private async Task SaveCountAsync(int value)
{
await JS.InvokeVoidAsync("storageBridge.write", "counter", value);
}
Prefer
InvokeAsync/InvokeVoidAsync; synchronous calls (IJSInProcessRuntime) only work in WebAssembly and break under Blazor Server.
Persisting a Counter Across Reloads
@page "/counter"
@inject IJSRuntime JS
<h1>Counter</h1>
<p role="status">Current count: @count</p>
<button class="btn btn-primary" @onclick="Increment">Click me</button>
@code {
private int count;
protected override async Task OnInitializedAsync()
{
try { count = await JS.InvokeAsync<int>("storageBridge.read", "counter"); }
catch { /* first run */ }
}
private async Task Increment()
{
count++;
await JS.InvokeVoidAsync("storageBridge.write", "counter", count);
}
}
Passing Element References
Sometimes JavaScript needs direct access to a DOM element. Capture it with @ref and pass it only after rendering is complete.
<input @ref="inputRef" @bind="count" />
@code {
private ElementReference inputRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await JS.InvokeVoidAsync("focusElement", inputRef);
}
}
window.focusElement = el => el.focus();
ElementReferenceis an opaque handle; do not cache it or pass it betwean components.
Calling .NET from JavaScript
Expose a public method decorated with [JSInvokable]:
[JSInvokable]
public async Task OnStorageChanged()
{
count = await JS.InvokeAsync<int>("storageBridge.read", "counter");
StateHasChanged();
}
Wrap the component instance in DotNetObjectReference and hand it to JavaScript:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var dotNetRef = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("storageBridge.watch", dotNetRef);
}
}
window.storageBridge.watch = function (dotNetRef) {
window.addEventListener('storage', _ => dotNetRef.invokeMethodAsync('OnStorageChanged'));
};
Open two tabs and change localStorage in one; the other tab updates instantly.
Hiding Interop Behind a Service
Tight coupling to IJSRuntime makes componants hard to test. Encapsulate the calls in a service.
public interface IClientStorage
{
ValueTask<T> GetAsync<T>(string key);
ValueTask SetAsync<T>(string key, T value);
ValueTask WatchAsync<T>(T instance) where T : class;
}
public class ClientStorage : IClientStorage
{
private readonly IJSRuntime _js;
public ClientStorage(IJSRuntime js) => _js = js;
public ValueTask<T> GetAsync<T>(string key) =>
_js.InvokeAsync<T>("storageBridge.read", key);
public ValueTask SetAsync<T>(string key, T value) =>
_js.InvokeVoidAsync("storageBridge.write", key, value);
public ValueTask WatchAsync<T>(T instance) where T : class =>
_js.InvokeVoidAsync("storageBridge.watch", DotNetObjectReference.Create(instance));
}
Register it in Program.cs:
builder.Services.AddSingleton<IClientStorage, ClientStorage>();
Now components simply @inject IClientStorage storage and stay ignorant of JavaScript.
Dynamic JavaScript Modules
Static script tags load even when the page doesn’t need them. ES modules solve this by loading code on demand and avoiding global scope pollution.
Refactor the façade to a module:
// wwwroot/js/storage.js
export const read = key => key in localStorage ? JSON.parse(localStorage[key]) : null;
export const write = (key, value) => localStorage[key] = JSON.stringify(value);
export const watch = async (dotNetRef) => {
window.addEventListener('storage', _ => dotNetRef.invokeMethodAsync('OnStorageChanged'));
};
Update the service to import the module lazily:
public class ClientStorageModule : IClientStorage
{
private readonly IJSRuntime _js;
private IJSObjectReference _module;
public ClientStorageModule(IJSRuntime js) => _js = js;
public async ValueTask InitAsync()
{
_module ??= await _js.InvokeAsync<IJSObjectReference>("import", "./js/storage.js");
}
public async ValueTask<T> GetAsync<T>(string key)
{
await InitAsync();
return await _module.InvokeAsync<T>("read", key);
}
public async ValueTask SetAsync<T>(string key, T value)
{
await InitAsync();
await _module.InvokeVoidAsync("write", key, value);
}
public async ValueTask WatchAsync<T>(T instance) where T : class
{
await InitAsync();
await _module.InvokeVoidAsync("watch", DotNetObjectReference.Create(instance));
}
}
The module is fetched only when InitAsync is first called.
Embedding an Interactive Map
Leaflet is a lightweight, open-source mapping library. Add its assets to index.html:
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
Create a Razor class library LeafletMap with a module wwwroot/leaflet-bridge.js:
export function attachMap(elementId, zoom, markers) {
const el = document.getElementById(elementId);
if (!el.map) {
el.map = L.map(elementId).setView([50.88, 4.30], zoom);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(el.map);
el.map.markers = [];
}
// remove outdated markers
el.map.markers.forEach(m => m.remove());
el.map.markers = markers.map(m =>
L.marker([m.lat, m.lng]).addTo(el.map).bindPopup(m.text)
);
if (markers.length)
el.map.fitBounds(L.featureGroup(el.map.markers).getBounds().pad(0.1));
}
The Razor component:
@using Microsoft.JSInterop
@inject IJSRuntime JS
<div id="@id" style="height:100%;width:100%;"></div>
@code {
[Parameter] public double Zoom { get; set; } = 15;
[Parameter] public List<Marker> Points { get; set; } = new();
private string id = "map_" + Guid.NewGuid();
private IJSObjectReference module;
protected override async Task OnInitializedAsync() =>
module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/LeafletMap/leaflet-bridge.js");
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (module is not null)
await module.InvokeVoidAsync("attachMap", id, Zoom, Points);
}
public class Marker
{
public double Lat { get; set; }
public double Lng { get; set; }
public string Text { get; set; }
}
}
Consume it in the host app:
<Map Zoom="16" Points="@pins" />
@code {
List<LeafletMap.Marker> pins = new()
{
new() { Lat = 50.88, Lng = 4.30, Text = "PizzaPlace HQ" },
new() { Lat = 50.87, Lng = 4.28, Text = "You are here" }
};
}
No script tags cluttering index.html; the map module is fetched only when the component is instantiated.