Mastering Blazor & JavaScript Interop

Blazor JavaScript Interop: The Practical Guide

Call JavaScript from Blazor and back with clean patterns. Real code, modules, events, streams, and safety tips to ship fast.

.NET Development Blazor·By amarozka · November 8, 2025

Blazor JavaScript Interop: The Practical Guide

Need a datepicker, chart, or WebAPI that Blazor doesn’t ship out‑of‑the‑box? JS interop is the secret door. The trick is using it without turning your app into spaghetti.

I’ve spent years shipping Blazor Server and WASM apps in real projects. Every time I had to wire up a JS library (Mapbox, Chart.js, Clipboard, Web Share) the same pain points showed up: when to call it, how to pass complex data, how to clean up listeners, and how not to blow up Server latency. This guide gives you a clean set of patterns and copy‑paste‑ready snippets that just work.

When you should (and shouldn’t) use JS interop

Use JS interop when:

  • You need a browser API that .NET doesn’t expose yet (Clipboard, Web Share, IntersectionObserver, File System Access).
  • You want a mature UI lib that exists only in JS (Chart.js, Leaflet, ApexCharts, TinyMCE).
  • You need to control the DOM directly in small, focused spots.

Avoid interop when:

  • You can solve it with native Blazor components.
  • You’re tempted to re‑implement a whole widget tree in JS. Keep it small and focused.

Server vs WASM note: in Blazor Server the JS call goes over SignalR. Don’t spam high‑frequency calls (e.g., on scroll or mousemove). Batch or throttle in JS first.

High-level diagram showing Blazor interacting with browser APIs and third-party JavaScript libraries through JavaScript Interop.

The API surface in 60 seconds

  • IJSRuntimeentry point to call JS.
  • IJSObjectReference – a JS module/object handle you can call later.
  • JSInvokable – allow JS to call .NET (static method) or use DotNetObjectReference for instance methods.
  • Streams – pass big payloads using DotNetStreamReference (to JS) or receive a stream from JS using IJSStreamReference.

Mental model: think of JS modules like disposable services. You import, call, and dispose when the component unmounts.

Setup once: project structure

wwwroot/
  js/
    interop.js        // your shared helpers
Pages/
  _Host.cshtml or index.html // include your script if needed (for global helpers)
Components/
  Sample.razor

For modern Blazor, prefer ES modules and load them from components with import (shown below). Keep global functions to a minimum.

Pattern #1: Call JS from a component (modules + cleanup)

wwwroot/js/interop.js

// wwwroot/js/interop.js
export async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    // fallback: textarea trick
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.left = '-9999px';
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    document.body.removeChild(ta);
    return true;
  }
}

Components/ClipboardDemo.razor

@inject IJSRuntime JS

<button class="btn" @onclick="Copy">Copy greeting</button>
<p>@_status</p>

@code {
    private IJSObjectReference? _module;
    private string _status = "";

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

    private async Task Copy()
    {
        if (_module is null) return; // safe on prerender
        var ok = await _module.InvokeAsync<bool>("copyText", "Hello from Blazor ✋");
        _status = ok ? "Copied" : "Failed";
        StateHasChanged();
    }

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

Why this works well

  • Loads the module only once on first render.
  • Safe during prerender (Server) because we don’t call JS before first interactive render.
  • Disposes the module on unmount.

Pattern #2: Pass complex data and get results back

C# model

public record Person(string Name, int Age, DateTime BirthDate);

Call from Blazor

var person = new Person("Sam", 28, new DateTime(1997, 4, 15));
var saved = await _module!.InvokeAsync<bool>("savePerson", person);

JS function

export function savePerson(person) {
  // person is a plain object now
  localStorage.setItem('person', JSON.stringify(person));
  return true;
}

Tip: watch for DateTime round‑trips. JSON becomes ISO strings; if you need a real Date in JS, parse it: new Date(person.birthDate).

Pattern #3: JS → .NET (events, resize, observers)

Goal: listen to resize in JS, push size into a component instance.

JS module

// wwwroot/js/interop.js
const resizeHandlers = new Map();

export function subscribeResize(id, dotNet) {
  const handler = () => dotNet.invokeMethodAsync('OnResize', innerWidth, innerHeight);
  addEventListener('resize', handler);
  resizeHandlers.set(id, handler);
}

export function unsubscribeResize(id) {
  const h = resizeHandlers.get(id);
  if (h) {
    removeEventListener('resize', h);
    resizeHandlers.delete(id);
  }
}

Razor component

@implements IAsyncDisposable
@inject IJSRuntime JS

<p>Size: @_w × @_h</p>

@code {
    private IJSObjectReference? _module;
    private DotNetObjectReference<ResizeDemo>? _self;
    private string _id = Guid.NewGuid().ToString("N");
    private int _w, _h;

    [JSInvokable]
    public Task OnResize(int w, int h)
    {
        _w = w; _h = h; StateHasChanged();
        return Task.CompletedTask;
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/interop.js");
            _self = DotNetObjectReference.Create(this);
            await _module.InvokeVoidAsync("subscribeResize", _id, _self);
        }
    }

    public async ValueTask DisposeAsync()
    {
        try { await _module?.InvokeVoidAsync("unsubscribeResize", _id); }
        catch { /* ignore during teardown */ }
        _self?.Dispose();
        if (_module is not null) await _module.DisposeAsync();
    }
}

Why this works well

  • Instance callback with DotNetObjectReference keeps state inside the component.
  • We store handlers by id and unsubscribe on dispose – no leaks.

Pattern #4: Streams for big payloads (images, files)

Send large data from .NET to JS without huge JSON strings:

C#

await using var fs = File.OpenRead("wwwroot/images/big.png");
var streamRef = new DotNetStreamReference(fs);
await _module!.InvokeVoidAsync("receiveImage", streamRef);

JS

export async function receiveImage(dotNetStreamRef) {
  const blob = await dotNetStreamRef.stream();
  const url = URL.createObjectURL(blob);
  document.getElementById('preview').src = url;
}

Receive large data from JS to .NET (e.g., file input):

JS

export function fileToStream(inputId) {
  const f = document.getElementById(inputId).files?.[0];
  if (!f) return null;
  return f; // Blob → becomes IJSStreamReference in .NET
}

C#

var jsStream = await _module!.InvokeAsync<IJSStreamReference?>("fileToStream", "upload");
if (jsStream is not null)
{
    await using var stream = await jsStream.OpenReadStreamAsync();
    // process stream
}

Pattern #5: Cooperative cancel for long JS tasks

CancellationToken cancels the .NET wait, but your JS may still run. Add an abort channel:

JS

const controllers = new Map();

export function startJob(id, ms) {
  const c = new AbortController();
  controllers.set(id, c);
  return new Promise((res, rej) => {
    const t = setTimeout(() => res("done"), ms);
    c.signal.addEventListener('abort', () => { clearTimeout(t); rej('aborted'); });
  });
}

export function stopJob(id) {
  controllers.get(id)?.abort();
  controllers.delete(id);
}

C#

var id = Guid.NewGuid().ToString("N");
var exec = _module!.InvokeAsync<string>("startJob", id, 10000);
// ... later
await _module.InvokeVoidAsync("stopJob", id);
try { var result = await exec; } catch { /* aborted */ }

Common errors and fast fixes

  • “Could not find ‘X’ in ‘window’” – you called a global that doesn’t exist (or before interactive render). Prefer modules and call after firstRender.
  • JSDisconnectedException (Server) – the circuit dropped during a call. Guard with try/catch and avoid long chains of calls.
  • Serialization fails – trim your payload; use DTOs; avoid circular refs. For DateTime, keep to UTC.
  • Event handler leaks – always unsubscribe in DisposeAsync and clear JS maps.
  • High‑frequency events – throttle/debounce in JS; send summaries to .NET.
  • DOM not found – call JS after the element exists; use ElementReference if you need a specific element.

Recipes you’ll reuse

Chart.js minimal wrapper

JS

import { Chart } from 'https://cdn.jsdelivr.net/npm/chart.js';
const charts = new Map();

export function chartInit(id, ctxId, config) {
  const ctx = document.getElementById(ctxId).getContext('2d');
  charts.set(id, new Chart(ctx, config));
}

export function chartUpdate(id, data) {
  const c = charts.get(id);
  if (!c) return;
  c.data = data; c.update();
}

export function chartDestroy(id) {
  charts.get(id)?.destroy();
  charts.delete(id);
}

Razor

<canvas id="sales"></canvas>
@code {
    string _id = Guid.NewGuid().ToString("N");
    IJSObjectReference? _m;
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _m = await JS.InvokeAsync<IJSObjectReference>("import", "./js/interop.js");
            await _m.InvokeVoidAsync("chartInit", _id, "sales", new {
                type = "line",
                data = new {
                    labels = new[] {"Mon","Tue","Wed"},
                    datasets = new[] { new { label = "Orders", data = new[] {12, 19, 7} } }
                }
            });
        }
    }
    public async ValueTask DisposeAsync() => await _m!.InvokeVoidAsync("chartDestroy", _id);
}

Copy to clipboard with selection feedback

export async function copyAndFlash(elId) {
  const el = document.getElementById(elId);
  await navigator.clipboard.writeText(el.value ?? el.textContent ?? "");
  el.classList.add('copied');
  setTimeout(()=> el.classList.remove('copied'), 300);
}

Save file with File System Access (fallback to download)

export async function saveTextFile(name, content) {
  if (window.showSaveFilePicker) {
    const h = await showSaveFilePicker({ suggestedName: name });
    const w = await h.createWritable();
    await w.write(content); await w.close();
  } else {
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([content]));
    a.download = name; a.click();
  }
}

Blazor Server specifics (latency, prerender, circuits)

  • Prerender: don’t call JS in OnInitialized; wait for OnAfterRenderAsync(firstRender). Guard all calls when _module is null.
  • Latency: do more work on the JS side for noisy events, send summaries to .NET.
  • Circuit drops: wrap interop in try/catch; if a call fails during dispose, swallow it.

Testing the JS bits

  • Unit test your JS with Jest/Vitest (pure functions and small helpers).
  • Use Playwright to click UI and assert DOM side‑effects.
  • In .NET, wrap your interop behind a small service so you can mock it in component tests.

Example service

public interface IClipboardService { Task<bool> Copy(string text); }
public sealed class ClipboardService(IJSRuntime js) : IClipboardService
{
    private IJSObjectReference? _m;
    public async Task<bool> Copy(string text)
    {
        _m ??= await js.InvokeAsync<IJSObjectReference>("import", "./js/interop.js");
        return await _m.InvokeAsync<bool>("copyText", text);
    }
}

Security notes you can’t skip

  • Never inject untrusted HTML. Prefer textContent over innerHTML.
  • Don’t eval strings. Keep functions in modules.
  • Validate data before you push it into the DOM.
  • Keep third‑party libs pinned to exact versions; track changelogs.

Visual guide (text diagram)

[Blazor component]
      |  InvokeAsync
      v
[ES module (interop.js)]  ↔  [Browser API / 3rd‑party lib]
      ^                         |
      |  DotNetObjectReference  |
      +-------------------------+

FAQ: real‑world gotchas

Can I call JS in OnInitialized?

No. Call after first interactive render. Use OnAfterRenderAsync(firstRender).

My component re‑renders; do I re‑import the module?

Import once, cache in a field, dispose on unmount.

How do I call instance methods from JS?

Pass DotNetObjectReference.Create(this) to JS and call invokeMethodAsync('MethodName').

Is JSON the only way to pass data?

Mostly yes, but use DotNetStreamReference / IJSStreamReference for big files.

Why does interop fail during navigation?

The circuit or component disposed mid‑call. Wrap calls in try/catch and cancel or ignore during teardown.

How do I keep types aligned?

Use small DTOs, keep enums as strings, and document expected shapes next to JS functions.

Conclusion: JS interop that stays tidy

Ship the feature, not the mess. Keep JS in small ES modules, import once, unsubscribe on dispose, stream big payloads, and move noisy work to the browser side. With these patterns you can plug in charts, maps, editors, and any Web API with confidence.

Which JS lib are you trying to bring into Blazor next? Drop a comment and I’ll post a focused wrapper.

2 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *