loading

Watch child-list changes

ObserveMutations

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.

Razor
@inject IJSRuntime js

<div @ref="container">...</div>

@code {
    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}");
            }
        });
    }
}
Live sample
Observed container
child-list output
Results will appear here when you interact with the samples.

Watch attributes

MutationObserverOptions.Attributes / AttributeOldValue / AttributeFilter

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.

Razor
@inject IJSRuntime js

<div @ref="container" data-state="idle">...</div>

@code {
    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"],
        });
    }
}
Live sample
Observed container - its data-state and data-other are what change.
attributes output
Results will appear here when you interact with the samples.

Watch text changes

MutationObserverOptions.CharacterData / CharacterDataOldValue

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.

Razor
@inject IJSRuntime js

<div @ref="container">...</div>

@code {
    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,
        });
    }
}
Live sample
Observed container - change count: 0
text changes output
Results will appear here when you interact with the samples.

Rate limiting

MutationObserverOptions.MinInterval

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.

Razor
@inject IJSRuntime js

<div @ref="container">...</div>

@code {
    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),
        });
    }
}
Live sample
Observed container - 0 item(s)
rate limiting output
Results will appear here when you interact with the samples.

Reading records

MutationRecord

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.

Razor
@inject IJSRuntime js

<div @ref="container">...</div>

@code {
    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;
            }
        });
    }
}
Live sample
Observed container - 0 item(s)
records output
Results will appear here when you interact with the samples.

Stopping

ButilSubscription

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.

Razor
@implements IAsyncDisposable

private ButilSubscription? subscription;

public async ValueTask DisposeAsync()
{
    if (subscription is not null)
    {
        await subscription.DisposeAsync();
    }
}
Live sample
stopping output
Results will appear here when you interact with the samples.
Note:
Blazor renders count as mutations too The observer sees every DOM change inside the watched subtree - including the ones Blazor itself makes when it re-renders. That is exactly what powers the demos above, but when watching a region your own components render, expect records for Blazor's diff updates as well as for external changes.

API reference

Member
Signature
Description
ObserveMutations
Task<ButilSubscription> ObserveMutations(IJSRuntime js, Action<MutationRecord[]> handler, MutationObserverOptions? options = null)
Extension method on ElementReference. Starts observing and returns a disposable subscription. Null options default to ChildList and Subtree.
MutationObserverOptions.ChildList
bool ChildList { get; set; }
Watch for added or removed children.
MutationObserverOptions.Attributes
bool Attributes { get; set; }
Watch for attribute changes on the target.
MutationObserverOptions.CharacterData
bool CharacterData { get; set; }
Watch for character-data changes within the target.
MutationObserverOptions.Subtree
bool Subtree { get; set; }
Apply the chosen options to the entire subtree, not just the immediate target.
MutationObserverOptions.AttributeOldValue
bool AttributeOldValue { get; set; }
Include the previous attribute value in each record.
MutationObserverOptions.CharacterDataOldValue
bool CharacterDataOldValue { get; set; }
Include the previous character-data value in each record.
MutationObserverOptions.AttributeFilter
string[]? AttributeFilter { get; set; }
Optional whitelist of attribute names to watch. Null means all.
MutationRecord.Type
string Type { get; set; }
One of "attributes", "characterData" or "childList".
MutationRecord.TargetTagName
string TargetTagName { get; set; }
Tag name of the target node, or empty for non-element targets.
MutationRecord.TargetId
string? TargetId { get; set; }
Id of the target node when present.
MutationRecord.AttributeName
string? AttributeName { get; set; }
Attribute name (attributes mutations only).
MutationRecord.AttributeNamespace
string? AttributeNamespace { get; set; }
Attribute namespace (attributes mutations only).
MutationRecord.OldValue
string? OldValue { get; set; }
Previous value when the matching *OldValue option was enabled.
MutationRecord.AddedCount
int AddedCount { get; set; }
Number of nodes added (childList mutations only).
MutationRecord.RemovedCount
int RemovedCount { get; set; }
Number of nodes removed (childList mutations only).
MutationObserverOptions.MinInterval
TimeSpan? MinInterval { get; set; }
The shortest time between two calls into the handler. null or zero (the default) forwards every batch. Drops whole batches, so leave it unset when every record matters.
ButilSubscription.Id
Guid Id { get; }
The internal listener id of the subscription.
ButilSubscription.DisposeAsync
ValueTask DisposeAsync()
Disconnects the observer and releases the interop reference. Idempotent.
An unhandled error has occurred. Reload 🗙