npx skills add ...
npx skills add dotnet/skills --skill use-js-interop
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).
npx skills add dotnet/skills --skill use-js-interop
Always use collocated .razor.js files with export — never global window.* functions or <script> tags.
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:
Parameter changes: set a flag in OnParametersSet, apply in OnAfterRenderAsync:
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:
When JS needs to send multiple pieces of data back to .NET, send them in a single invokeMethodAsync call rather than making separate callbacks:
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:
The component creates and uses the wrapper with no magic strings:
Prefer a concrete class over interface + implementation for interop wrappers. For unit testing, substitute IJSRuntime directly (it is already an interface).
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:
Rules:
[JSInvokable] methods must be public — private/internal silently fails at runtimeStateHasChanged in InvokeAsync inside [JSInvokable] callbacks:
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:
Never use sync IDisposable for JS interop cleanup — InvokeVoidAsync returns ValueTask and must be awaited.
Pass DOM elements via @ref, not string IDs:
.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 |