JS Interop & Prerendering
Key Points
IJSRuntimeinvokes JS from C#.IJSObjectReferenceholds JS objects across calls (avoid round-trip serialization).- Only call JS in
OnAfterRender(firstRender)or later — DOM 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;DotNetObjectReferencefor 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
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
(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:
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
✅ Correct: OnAfterRender
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender) await JS.InvokeVoidAsync("init");
}
❌ Wrong: forgetting Dispose
✅ Correct: IAsyncDisposable
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.