MutationObserver
React to DOM changes from C#: ObserveMutations is an extension method on any ElementReference that reports added and removed nodes, attribute changes and text edits as strongly typed records - no polling, no JavaScript.
Capture the element with the ref directive and call ObserveMutations with the IJSRuntime and a handler. With no options it defaults to watching added and removed nodes across the whole subtree - the most common case. Start observing, then add and remove items to see childList records arrive.
IJSRuntime js
<div @ref="container">...</div>
{
private ElementReference container;
private ButilSubscription? subscription;
private async Task Start()
{
// defaults to ChildList = true, Subtree = true
subscription = await container.ObserveMutations(js, records =>
{
foreach (var record in records)
{
Console.WriteLine($"{record.Type}: +{record.AddedCount} / -{record.RemovedCount}");
}
});
}
}Set Attributes to true to receive a record for every attribute change on the target. AttributeOldValue includes the previous value in each record, and AttributeFilter narrows the watch to specific attribute names so unrelated changes stay silent.
IJSRuntime js
<div @ref="container" data-state="idle">...</div>
{
private ElementReference container;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
await container.ObserveMutations(js, records =>
{
foreach (var record in records)
{
Console.WriteLine($"{record.AttributeName}: {record.OldValue} -> changed");
}
}, new MutationObserverOptions
{
Attributes = true,
AttributeOldValue = true,
AttributeFilter = ["data-state"],
});
}
}data-state and data-other are what change.
CharacterData watches edits to text nodes; pair it with Subtree so text anywhere inside the container is covered, and CharacterDataOldValue to receive the previous text. Incrementing the counter below makes Blazor update a text node inside the observed container.
IJSRuntime js
<div @ref="container">...</div>
{
private ElementReference container;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
await container.ObserveMutations(js, records =>
{
foreach (var record in records)
{
Console.WriteLine($"text changed, was: {record.OldValue}");
}
}, new MutationObserverOptions
{
CharacterData = true,
CharacterDataOldValue = true,
Subtree = true,
});
}
}A subtree observer over a region Blazor re-renders sees a batch of records per render, so without a gate one render becomes one interop round trip. MinInterval caps how often the handler is called, leading-edge with a trailing send so the batch describing the settled tree still arrives. Note what it drops: whole batches, not individual records. A handler that has to see every mutation - a change log, an undo stack - must leave it unset. Burst-add ten items below and count the callbacks against the ten renders that produced them.
IJSRuntime js
<div @ref="container">...</div>
{
private ElementReference container;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
await container.ObserveMutations(js, records =>
{
// called at most once every 250 ms, however many
// renders happened in between
}, new MutationObserverOptions
{
ChildList = true,
Subtree = true,
MinInterval = TimeSpan.FromMilliseconds(250),
});
}
}DOM nodes cannot cross the interop boundary, so each MutationRecord is a flattened summary: the mutation type, the target's tag name and id, the attribute name and old value where applicable, and how many nodes were added or removed.
IJSRuntime js
<div @ref="container">...</div>
{
private ElementReference container;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
await container.ObserveMutations(js, records =>
{
foreach (var record in records)
{
var type = record.Type; // "childList" | "attributes" | "characterData"
var tag = record.TargetTagName; // e.g. "DIV"
var id = record.TargetId; // element id, when present
var attr = record.AttributeName; // attributes mutations only
var old = record.OldValue; // when *OldValue options are enabled
var added = record.AddedCount; // childList mutations only
var removed = record.RemovedCount;
}
});
}
}ObserveMutations returns a ButilSubscription; disposing it disconnects the observer and releases the interop reference. Dispose every observer you start - at the latest in your component's DisposeAsync, as this page does.
IAsyncDisposable
private ButilSubscription? subscription;
public async ValueTask DisposeAsync()
{
if (subscription is not null)
{
await subscription.DisposeAsync();
}
}API reference
Task<ButilSubscription> ObserveMutations(IJSRuntime js, Action<MutationRecord[]> handler, MutationObserverOptions? options = null)bool ChildList { get; set; }bool Attributes { get; set; }bool CharacterData { get; set; }bool Subtree { get; set; }bool AttributeOldValue { get; set; }bool CharacterDataOldValue { get; set; }string[]? AttributeFilter { get; set; }string Type { get; set; }string TargetTagName { get; set; }string? TargetId { get; set; }string? AttributeName { get; set; }string? AttributeNamespace { get; set; }string? OldValue { get; set; }int AddedCount { get; set; }int RemovedCount { get; set; }TimeSpan? MinInterval { get; set; }Guid Id { get; }ValueTask DisposeAsync()