Skip to content

JS Interop & Prerendering

Key Points

  • IJSRuntime invokes JS from C#. IJSObjectReference holds JS objects across calls (avoid round-trip serialization).
  • Only call JS in OnAfterRender(firstRender) or laterDOM ready; WASM/Server runtime ready.
  • Prerendering: server renders HTML before interactive runtime loads. Faster first paint; component runs twice (prerender + interactive). Beware of side effects.
  • C# from JS: [JSInvokable] static or instance methods; DotNetObjectReference for instance.
  • Module pattern: ship JS as ES modules; load via IJSRuntime.InvokeAsync<IJSObjectReference>("import", ".../my.js").

Concepts (deep dive)

Calling JS from C

@inject IJSRuntime JS

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await JS.InvokeVoidAsync("console.log", "hello");
            var width = await JS.InvokeAsync<int>("eval", "window.innerWidth");
        }
    }
}

InvokeVoidAsync (no return) / InvokeAsync<T> (typed return).

IJSObjectReference (efficient)

private IJSObjectReference? _module;
private IJSObjectReference? _chart;

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

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

Holds reference to JS object; subsequent calls don't re-serialize.

JS module file

// wwwroot/js/chart.js
export function createChart(element, data) {
    const c = new SomeChart(element, data);
    return {
        update(newData) { c.update(newData); },
        dispose() { c.destroy(); }
    };
}

Modular; auto-loaded; SourceMap-friendly.

Calling C# from JS

public class Calculator
{
    [JSInvokable]
    public static int Add(int a, int b) => a + b;

    [JSInvokable("calculate")]
    public Task<int> InstanceCalc(int x) => Task.FromResult(x * 2);
}
// Static
const result = await DotNet.invokeMethodAsync('MyApp', 'Add', 1, 2);

// Instance
const ref = ...;   // DotNetObjectReference passed in
const result = await ref.invokeMethodAsync('calculate', 5);
private DotNetObjectReference<Calculator>? _ref;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        _ref = DotNetObjectReference.Create(this);
        await JS.InvokeVoidAsync("registerCallback", _ref);
    }
}

public ValueTask DisposeAsync() { _ref?.Dispose(); return ValueTask.CompletedTask; }

Element references

<canvas @ref="_canvas"></canvas>

@code {
    ElementReference _canvas;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
            await JS.InvokeVoidAsync("initCanvas", _canvas);
    }
}

Prerendering

@rendermode @(new InteractiveServerRenderMode(prerender: true))

Server renders the HTML before starting the interactive circuit / loading WASM. The component runs twice: once during prerender (no JS available, no interactivity), once when interactive.

Pitfalls:

// ❌ JS in OnInitialized — fails during prerender
protected override async Task OnInitializedAsync()
{
    await JS.InvokeAsync(...);   // JS not available; throws
}

// ✅ Defer to OnAfterRender
protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
        await JS.InvokeAsync(...);
}

Detecting prerender

@inject IComponentContext Ctx

if (Ctx.IsPrerendering) { /* skip JS */ }

(Note: API name may vary by version; check current docs.)

State across prerender + interactive

PersistentComponentState:

[Inject] PersistentComponentState State { get; set; } = default!;

protected override async Task OnInitializedAsync()
{
    if (State.TryTakeFromJson<UserData>("user", out var u) && u is not null) _user = u;
    else _user = await Service.GetUserAsync();

    State.RegisterOnPersisting(() => { State.PersistAsJson("user", _user); return Task.CompletedTask; });
}

Server prerender fetches data; persists. WASM picks up.

IJSInProcessRuntime (WASM only)

For WASM, sync JS calls available:

if (JS is IJSInProcessRuntime jsInProc)
    var v = jsInProc.Invoke<string>("...");

Sync; no Task overhead. Browser-only.

Streaming interop (.NET 8+)

// Stream large data from C# to JS
using var ms = new MemoryStream(bigData);
await JS.InvokeVoidAsync("downloadFile", "report.bin", new DotNetStreamReference(ms));

// JS to C#
var jsStream = await JS.InvokeAsync<IJSStreamReference>("getFile");
using var stream = await jsStream.OpenReadStreamAsync();

Avoids whole-blob serialization.

Common pitfalls

  • JS in OnInitialized — fails on prerender.
  • Disposing JS objects — leaks if forgotten.
  • DotNetObjectReference not disposed — leaks.
  • Static JSInvokable with disposable state — global state issues.
  • Treating prerender like interactive — events don't fire.

Performance

  • Each JS call has serialization overhead. Batch when possible.
  • IJSObjectReference avoids re-passing arguments on each call.
  • IJSInProcessRuntime (WASM) is fastest.

Loading external JS

@page "/chart"
@inject IJSRuntime JS

<script src="https://cdn.example.com/chart.js"></script>

@* OR via App.razor / wwwroot/index.html *@

For module imports, prefer await JS.InvokeAsync<IJSObjectReference>("import", url).

Globalization

WASM ships ICU library. For Server, server-side. Locale-sensitive operations work.

Security

JS interop runs JS in user's browser. Don't pass un-validated data; standard XSS rules apply. Razor escapes by default; @Html.Raw-like ops in JS interop are risky.


Code: correct vs wrong

❌ Wrong: JS in OnInitialized

protected override Task OnInitializedAsync() => JS.InvokeVoidAsync("init").AsTask();

✅ Correct: OnAfterRender

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender) await JS.InvokeVoidAsync("init");
}

❌ Wrong: forgetting Dispose

_module = await JS.InvokeAsync<IJSObjectReference>("import", "./m.js");
// never disposed

✅ Correct: IAsyncDisposable

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

Design patterns for this topic

Pattern 1 — "JS as ES modules"

  • Intent: modular; explicit imports.

Pattern 2 — "IJSObjectReference for handles"

  • Intent: avoid re-serialize on every call.

Pattern 3 — "OnAfterRender(firstRender) for init"

  • Intent: DOM ready; safe.

Pattern 4 — "PersistentComponentState across prerender"

  • Intent: avoid double-fetch.

Pattern 5 — "Streaming interop for big data"

  • Intent: avoid whole-blob serialization.

Pros & cons / trade-offs

Pattern Pros Cons
Module imports Clean One-time import cost
IJSObjectReference Efficient Manual dispose
Streaming Big data Setup
Prerender Fast paint Double-render

When to use / when to avoid

  • Use modules + IJSObjectReference for any non-trivial JS.
  • Use prerender for SEO / fast paint.
  • Avoid JS in OnInitialized.
  • Avoid forgetting Dispose.

Interview Q&A

Q1. When can you call JS? After OnAfterRender(firstRender). Earlier: prerender → JS not available.

Q2. IJSObjectReference? Holds JS object across calls. No re-serialization.

Q3. Calling C# from JS? [JSInvokable] static or instance + DotNetObjectReference.

Q4. Prerendering — what runs twice? The component. Once during server prerender (no JS), once interactive. Side effects can fire twice.

Q5. PersistentComponentState? Save data from prerender; pick up in interactive WASM. Avoid double-fetch.

Q6. ElementReference? Token referencing a DOM element. Pass to JS.

Q7. IJSInProcessRuntime? WASM-only sync interop. Faster.

Q8. Streaming interop? DotNetStreamReference / IJSStreamReference for big binary.

Q9. Module loading? InvokeAsync<IJSObjectReference>("import", "./m.js"). ES module.

Q10. Disposing — what? IJSObjectReference, DotNetObjectReference. Both leak otherwise.

Q11. Static vs instance JSInvokable? Static: app-global. Instance: needs DotNetObjectReference of an instance.

Q12. Why ES module pattern? Bundling-friendly; tree-shaken; clean isolation.


Gotchas / common mistakes

  • ⚠️ JS too early — OnInitialized.
  • ⚠️ Side effects in prerender — duplicated.
  • ⚠️ No Dispose — leaks.
  • ⚠️ Big payload via JSON — use streaming.

Further reading