Skip to main content
skill CX Codex

use-js-interop — Claude Code skill: what it does, how to install

Sets up and audits JavaScript interop in Blazor components. It guides .NET-to-JS calls, lifecycle handling, and call batching across the circuit.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add dotnet/skills --skill use-js-interop -a claude-code
Open on GitHub

What use-js-interop does

This skill inspects and organizes JavaScript interop in Blazor components. It enforces collocated .razor.js files with explicit exports rather than global functions on window. All calls run through a typed C# class that implements IAsyncDisposable and catches JSDisconnectedException during teardown.

Interop timing follows strict rules. JavaScript is completely unavailable during server prerendering. Because of that, running interop code from OnInitialized breaks immediately. The pattern defers all execution to OnAfterRenderAsync or dedicated event handlers, setting flags in OnParametersSet whenever component parameters change.

Consecutive interop calls are batched into single operations to cut traffic over SignalR circuits. Browser callbacks rely on DotNetObjectReference wrapped in JavaScript classes with explicit try/catch guards. Do not load this skill for general component templates or form fields, which use separate guidance.

What it helps with

  • isolate script logic into collocated .razor.js files
  • prevent JS calls during server prerendering phases
  • batch related interop calls across the boundary
  • manage lifecycle cleanup with IAsyncDisposable
  • stream browser callbacks into C# via DotNetObjectReference

Example prompts for the agent

  • Create a collocated .razor.js module for this chart component and add a typed interop wrapper.
  • Fix lifecycle issues in this Blazor component by moving JS interop into OnAfterRenderAsync.
  • Batch these consecutive interop calls into a single JS export to cut SignalR round-trips.

How to install use-js-interop

1Skills CLI (Vercel)

npx skills add dotnet/skills --skill use-js-interop -a claude-code

2Manually: put the files in place

~/.claude/skills/use-js-interop/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/use-js-interop/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about use-js-interop

How to install use-js-interop in Claude Code?

Run npx skills add dotnet/skills --skill use-js-interop -a claude-code in your terminal.

What does use-js-interop do?

It provides patterns for integrating JavaScript with Blazor. The skill structures collocated .razor.js modules, typed C# wrappers, and batched boundary calls.

When should use-js-interop be avoided?

Do not use it for standard component markup without JavaScript or for input forms. The repository provides author-component and collect-user-input for those scenarios.

Source text

SKILL.md · MIT
---
license: MIT
name: use-js-interop
description: >
  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).
---

# JS Interop in Blazor

## 1. Collocated JS Modules

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}/..."`.

## 2. Lifecycle Timing

**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:

```csharp
private 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`:

```csharp
private 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);
    }
}
```

## 3. Batch Related Operations

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.

### .NET → JS: merge consecutive calls

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;
}
```

### JS → .NET: batch callbacks

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.

## 4. Typed Interop Wrapper

Encapsulate interop for a feature in a plain class that owns the module lifecycle:

```csharp
public 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).

## 5. DotNetObjectReference for JS-to-.NET Callbacks

```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:

```javascript
const 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 runtime
- Wrap `StateHasChanged` in `InvokeAsync` inside `[JSInvokable]` callbacks:
  ```csharp
  [JSInvokable]
  public async Task OnClipboardChanged(string text)
  {
      await InvokeAsync(() => { _lastClipboard = text; StateHasChanged(); });
  }
  ```
- Always `try/catch` around `invokeMethodAsync` in JS — circuit loss throws
- Use `const` for .NET method name strings in JS — prevents typo bugs that silently fail
- Dispose `DotNetObjectReference` in `DisposeAsync`

## 6. Disposal and Server Safety

Always implement `IAsyncDisposable`. Call JS cleanup first, then dispose references. Catch `JSDisconnectedException` for Blazor Server circuit loss:

```csharp
public 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.

## 7. ElementReference

Pass DOM elements via `@ref`, not string IDs:

```razor
<canvas @ref="_canvasRef" width="600" height="400"></canvas>
```

```csharp
await _chart.InitializeAsync(_canvasRef);
```

## Checklist

- [ ] JS is in collocated `.razor.js` with `export` — no `window.*` globals
- [ ] All interop in `OnAfterRenderAsync` or event handlers — never during prerender
- [ ] `IAsyncDisposable` catches `JSDisconnectedException`
- [ ] `DotNetObjectReference` 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 needed
- [ ] `ElementReference` instead of string IDs
- [ ] Related operations batched into single interop calls (both .NET→JS and JS→.NET)

## Common Mistakes Checklist

| 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 |

© dotnet · MIT · dotnet/skills

Similar skills

Everything for Frontend and UI →

From the same set101

system-text-json-net11 A skill that tells the agent how to use three new System.Text.Json APIs in .NET 11 instead of writing the old workarounds. CX Codex +1 3,100installs maui-safe-area Safe area and edge-to-edge layout guidance for .NET 10+. Solves system bar overlaps and keyboard padding across Android, iOS, and Mac Catalyst. CX Codex +1 3,200installs maui-shell-navigation Set up Shell navigation in a .NET MAUI app, including tabs, flyout menus, and page transitions. Use it when routes or navigation parameters need attention. CX Codex +1 3,100installs maui-collectionview A reference skill for CollectionView in .NET MAUI: item templates, grids, grouping, incremental loading and swipe actions. Useful when the agent writes list XAML. CX Codex +1 3,200installs convert-blazor-server-to-webapp Guides a pre-.NET 8 Blazor Server app to the Blazor Web App model. Use it when replacing the old host setup while retaining interactive server rendering. CX Codex +1 3,000installs extension-points This skill maps how MSBuild discovers hooks and imports: CustomBefore/CustomAfter, wildcard directories, NuGet auto-imports. Useful when assets go missing or get replaced. CX Codex +1 3,000installs item-management Guides AI agents through MSBuild ItemGroup semantics and item expressions. It covers Include, Remove, Update operations, item batching, and metadata transforms. CX Codex +1 3,000installs plan-ui-change Splits a complex Blazor page into focused components before the agent writes any code. Useful once a screen has three or more visual sections. CX Codex +1 3,000installs

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor