Practical Blazor JavaScript Interop Guide
Mastering JavaScript Interop in Blazor: Efficiently calling JS from C# and vice-versa, managing object references, and ensuring type safety.
When I first configured JavaScript interop for our PDF viewer component, I thought it would be a quick wrapper around a third-party library. Instead, I spent days chasing a memory leak that only manifested after users opened and closed the viewer several times in a single session. The DotNetObjectReference instances were accumulating because I was creating new references on every parameter change instead of reusing them. That debugging session gave me a deep respect for the GC boundary between .NET and JavaScript, and it is the reason every JS interop pattern in this article emphasizes proper disposal.
Introduction
While Blazor allows us to write C# for the frontend, the reality of the web is that the ecosystem is rich with JavaScript libraries. Whether it’s a complex chart, a map, or a specific browser API, robust interop is essential. It’s not just about “making it work”; it’s about managing memory, ensuring type safety, and handling asynchrony correctly.
[Call JavaScript functions from .NET methods in ASP.NET Core Blazor] — Microsoft , 2024-11-12Why Proper Interop Matters:
- Memory Leaks: Improper disposal of
DotNetObjectReferenceorIJSObjectReferencecan crash long-running apps. - Performance: Excessive marshalling between WASM/Server and JS can slow down the UI.
- Maintainability: encapsulating JS logic prevents “magic string” calls scattered throughout Razor components.
What We’ll Build
We will implement a wrapper for a hypothetical PDF viewer library. You will learn how to:
- Load Modules: Use dynamic importing of JS modules.
- Two-way Comms: Call JS functions and receive callbacks in C#.
- Clean Up: Implement
IAsyncDisposablecorrectly.
Architecture Overview
We use the “Module Pattern” to isolate our JS code, avoiding global scope pollution.
[JavaScript module interop in Blazor] — Microsoft , 2024-08-20sequenceDiagram
participant C as C# Component
participant R as IJSRuntime
participant J as JS Module
participant D as DOM
C->>R: InvokeAsync("import", "./pdfViewer.js")
R->>J: Load Module
J-->>C: Return IJSObjectReference
C->>J: InvokeVoidAsync("init", elementId, DotNetRef)
J->>D: Render Viewer
D->>J: User Clicks "Next Page"
J->>C: InvokeMethodAsync("OnPageChanged", 2)
Implementation
1. The JavaScript Module
Create an ES6 module that exports functions.
export function initViewer(element, dotNetRef) {
const viewer = new ThirdPartyViewer(element);
viewer.on('pagechange', (page) => {
dotNetRef.invokeMethodAsync('OnPageChanged', page);
});
return {
next: () => viewer.nextPage(),
prev: () => viewer.prevPage(),
dispose: () => viewer.destroy()
};
} 2. The C# Wrapper Component
We wrap the functionality in a reusable C# class or component.
[Call .NET methods from JavaScript functions in ASP.NET Core Blazor] — Microsoft , 2024-11-12using Microsoft.JSInterop;
public partial class PdfViewer : IAsyncDisposable
{
[Inject] IJSRuntime JSRuntime { get; set; }
private IJSObjectReference? _module;
private IJSObjectReference? _instance;
private DotNetObjectReference<PdfViewer>? _selfRef;
private ElementReference _container;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_module = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/pdfViewer.js");
_selfRef = DotNetObjectReference.Create(this);
_instance = await _module.InvokeAsync<IJSObjectReference>(
"initViewer", _container, _selfRef);
}
}
[JSInvokable]
public void OnPageChanged(int page)
{
Console.WriteLine($"User is now on page {page}");
}
public async ValueTask DisposeAsync()
{
if (_instance != null)
{
await _instance.InvokeVoidAsync("dispose");
await _instance.DisposeAsync();
}
if (_module != null)
await _module.DisposeAsync();
_selfRef?.Dispose();
}
} Conclusion
By treating JavaScript interoperability as a first-class citizen with strict types and lifecycle management, we extend Blazor’s capabilities infinitely. This pattern defines how we integrate complex visualization tools without compromising stability.
The investment in building a proper interop wrapper pays dividends across the entire application. Every subsequent JS library integration follows the same pattern: module import, DotNetObjectReference for callbacks, and disciplined IAsyncDisposable cleanup. What once felt like a fragile bridge between two worlds now feels like a reliable, well-tested integration layer. The key takeaway is that JS interop failures are almost always lifecycle issues, not logic issues, and the fix is always to respect the disposal chain.
Next Steps
- Build a generic
JsModuleBase<T>base class that automates module loading and disposal for any JS library. - Implement streaming interop for large data transfers between C# and JavaScript.
- Add TypeScript declaration files to get compile-time safety on the JS method signatures.