Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Add, review, or fix JavaScript interop in Blazor components. USE FOR: calling JavaScript from Blazor, calling .NET from JavaScript, collocated .razor.js modules, IJSRuntime, IJSObjectReference lifecycle, DotNetObjectReference, ElementReference, timing rules for when JS is available, IAsyncDisposable disposal of JS references, server-side JS interop safety. DO NOT USE FOR: general Blazor component authoring without JS interop needs (use author-component), forms (use collect-user-input).
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 79% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 54% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 85% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 104% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 51% | 0% |
Always use collocated .razor.js files with export — never global window.* functions or <script> tags.
javascript// ChartPanel.razor.js — placed next to ChartPanel.razor export function initialize(canvas, dotNetRef) { /* ... */ } export function updateData(points) { /* ... */ } export function dispose() { /* ... */ }
Import paths: same project = "./Components/ChartPanel.razor.js", RCL = "./_content/{AssemblyName}/...".
All JS interop must happen in OnAfterRenderAsync or event handlers — never in OnInitialized, OnParametersSet, or constructors. JS is not available during server prerendering.
Use a typed interop wrapper (see Section 4) — never call InvokeAsync/InvokeVoidAsync with raw string literals:
csharpprivate ChartInterop? _chart; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { _chart = new ChartInterop(JS); await _chart.InitializeAsync(_canvasRef); } }
Parameter changes: set a flag in OnParametersSet, apply in OnAfterRenderAsync:
csharpprivate bool _dataChanged; protected override void OnParametersSet() => _dataChanged = true; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { /* init */ } else if (_dataChanged && _chart is not null) { _dataChanged = false; await _chart.UpdateDataAsync(DataPoints); } }
Each JS interop call crosses the .NET-to-JS boundary (and in Blazor Server, the SignalR circuit). Batching applies in both directions — .NET→JS and JS→.NET.
If the C# side makes two or more JS calls in a row, combine them into one JS function:
csharp// ❌ Two round-trips — theme and locale are always applied together await _module.InvokeVoidAsync("applyTheme", theme); await _module.InvokeVoidAsync("applyLocale", locale); // ❌ Result of one call feeds into another — both can stay in JS var token = await _module.InvokeAsync<string>("createAccessToken"); await _module.InvokeVoidAsync("storeToken", token);
javascript// ✅ One call applies both — no data dependency, no reason for two trips export function applyPreferences(theme, locale) { document.documentElement.dataset.theme = theme; document.documentElement.lang = locale; } // ✅ Chain stays in JS — the token never needs to cross the boundary export function createAndStoreToken() { const token = crypto.randomUUID(); sessionStorage.setItem('access-token', token); return token; }
When JS needs to send multiple pieces of data back to .NET, send them in a single invokeMethodAsync call rather than making separate callbacks:
javascript// ❌ Two .NET round-trips from JS await dotNetRef.invokeMethodAsync(ON_VOLUME_CHANGED, volume); await dotNetRef.invokeMethodAsync(ON_PLAYBACK_CHANGED, isPlaying); // ✅ One callback with all data await dotNetRef.invokeMethodAsync(ON_PLAYER_STATE_CHANGED, { volume, isPlaying });
Rule: if two interop calls always happen together from either side, merge them into one function.
Encapsulate interop for a feature in a plain class that owns the module lifecycle:
csharppublic sealed class ChartInterop : IAsyncDisposable { internal const string ModulePath = "./Components/ChartPanel.razor.js"; internal const string InitMethod = "initialize"; internal const string UpdateMethod = "updateData"; internal const string DisposeMethod = "dispose"; private readonly IJSRuntime _js; private IJSObjectReference? _module; public ChartInterop(IJSRuntime js) => _js = js; private async ValueTask<IJSObjectReference> GetModuleAsync() => _module ??= await _js.InvokeAsync<IJSObjectReference>("import", ModulePath); public async ValueTask InitializeAsync(ElementReference canvas) { var module = await GetModuleAsync(); await module.InvokeVoidAsync(InitMethod, canvas); } public async ValueTask UpdateDataAsync(IReadOnlyList<DataPoint> points) { var module = await GetModuleAsync(); await module.InvokeVoidAsync(UpdateMethod, points); } public async ValueTask DisposeAsync() { try { if (_module is not null) { await _module.InvokeVoidAsync(DisposeMethod); await _module.DisposeAsync(); } } catch (JSDisconnectedException) { } } }
The component creates and uses the wrapper with no magic strings:
razor@inject IJSRuntime JS @implements IAsyncDisposable <canvas @ref="_canvasRef" width="600" height="400"></canvas> @code { private ElementReference _canvasRef; private ChartInterop? _chart; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { _chart = new ChartInterop(JS); await _chart.InitializeAsync(_canvasRef); } } async ValueTask IAsyncDisposable.DisposeAsync() { if (_chart is not null) await _chart.DisposeAsync(); } }
Prefer a concrete class over interface + implementation for interop wrappers. For unit testing, substitute IJSRuntime directly (it is already an interface).
csharp_dotNetRef = DotNetObjectReference.Create(this); await _module.InvokeVoidAsync("initialize", _dotNetRef);
On the JS side, wrap the dotNetRef in a class. Use async/await with try/catch (not .catch()) to guard against circuit loss. Define .NET method name constants at the top:
javascriptconst ON_CLIPBOARD_CHANGED = 'OnClipboardChanged'; class ClipboardMonitor { #dotNetRef; #abortController; constructor(dotNetRef) { this.#dotNetRef = dotNetRef; this.#abortController = new AbortController(); } start() { document.addEventListener('copy', async () => { try { const text = await navigator.clipboard.readText(); await this.#dotNetRef.invokeMethodAsync(ON_CLIPBOARD_CHANGED, text); } catch { /* circuit disconnected or clipboard denied */ } }, { signal: this.#abortController.signal }); } dispose() { this.#abortController.abort(); } } let monitor; export function initialize(dotNetRef) { monitor = new ClipboardMonitor(dotNetRef); monitor.start(); } export function dispose() { monitor?.dispose(); }
Rules:
[JSInvokable] methods must be public — private/internal silently fails at runtimeStateHasChanged in InvokeAsync inside [JSInvokable] callbacks:csharp [JSInvokable] public async Task OnClipboardChanged(string text) { await InvokeAsync(() => { _lastClipboard = text; StateHasChanged(); }); }
try/catch around invokeMethodAsync in JS — circuit loss throwsconst for .NET method name strings in JS — prevents typo bugs that silently failDotNetObjectReference in DisposeAsyncAlways implement IAsyncDisposable. Call JS cleanup first, then dispose references. Catch JSDisconnectedException for Blazor Server circuit loss:
csharppublic async ValueTask DisposeAsync() { try { if (_module is not null) { await _module.InvokeVoidAsync("dispose"); await _module.DisposeAsync(); } } catch (JSDisconnectedException) { } _dotNetRef?.Dispose(); }
Never use sync IDisposable for JS interop cleanup — InvokeVoidAsync returns ValueTask and must be awaited.
Pass DOM elements via @ref, not string IDs:
razor<canvas @ref="_canvasRef" width="600" height="400"></canvas>
csharpawait _chart.InitializeAsync(_canvasRef);
.razor.js with export — no window.* globalsOnAfterRenderAsync or event handlers — never during prerenderIAsyncDisposable catches JSDisconnectedExceptionDotNetObjectReference disposed in DisposeAsync; JS side has try/catch around invokeMethodAsync[JSInvokable] methods are public and use await InvokeAsync(StateHasChanged)InvokeVoidAsync used when no return value is neededElementReference instead of string IDs| Mistake | Fix | |---------|-----| | Using JS for something achievable with CSS | Use CSS custom properties, data- attributes, pseudo-classes | | Many fine-grained interop calls | Batch into coarse functions — both .NET→JS and JS→.NET | | Component imports JS module directly | Encapsulate in a strongly typed interop class | | Magic strings for method names / module paths | Define internal const fields in the interop class | | Interface + implementation for interop wrapper | Use a plain class; mock IJSRuntime for tests instead | | JS calls in OnInitializedAsync | Move to OnAfterRenderAsync(firstRender) | | InvokeAsync<object> for void calls | Use InvokeVoidAsync | | IDisposable with fire-and-forget JS | Use IAsyncDisposable with await | | Global window.* JS functions | Use collocated .razor.js with export | | String element IDs passed to JS | Use ElementReference with @ref | | [JSInvokable] on private method | Must be public — silently fails otherwise | | DotNetObjectReference not disposed | Dispose in DisposeAsync — causes memory leak | | StateHasChanged() without InvokeAsync | Wrap in await InvokeAsync(() => { StateHasChanged(); }) | | JS invokeMethodAsync without error handling | Wrap in try/catch — circuit loss throws | | Bare dotNetRef in JS event handlers | Wrap in a class with #dotNetRef private field | | Magic strings in JS invokeMethodAsync calls | Use const at module top — typos silently fail at runtime | | JS calls in OnParametersSetAsync | Track changes, apply in OnAfterRenderAsync with guard | | No null check before calling module | Check module is not null before use |
Other measured skills in the registry, with their headline benchmark lift.