Signals in Blazor

6/20/2026
14 minute read

Signals - more often used in Angular - are a nice way to tell the UI that something has changed. Blazor doesn't have that natively, but how would it look, if it did?

Signals

To understand why signals are useful, let's have a look at the following code:

@page "/NoSignal"
@implements IDisposable
<h3>No Signal use</h3>

<p>Elapsed time: @elapsedSeconds seconds</p>

@code {
    private Timer? timer;
    private int elapsedSeconds;

    protected override void OnInitialized()
    {
        timer = new Timer(_ =>
        {
            elapsedSeconds += 1;
        }, null, 0, 1000);
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}

Disclaimer: I will mainly have a look at Server- and Client-side Rendering. It's in the nature of things that SSR (static server rendering) wouldn't benefit in the same way from such a system.


We have a timer that updates the elapsedMilliseconds variable every second. However, the UI won't update to reflect this change because Blazor doesn't know that it needs to re-render the component when elapsedMilliseconds changes. We could call StateHasChanged() inside the timer callback to force a re-render, but that means that we have to take care of that every time!

Angular and Blazor do have similar triggers when it comes to checking for UI updates (let's assume we have OnPush and zoneless changedetection in Angular):

  • Event handlers (e.g. onclick, oninput, etc.)
  • Input parameters (e.g. [Parameter] public string Name { get; set; })

Info: StateHasChanged does not trigger a re-render! It only tells Blazor that it needs to check if the UI needs to be updated. Where Angular and Blazor are a bit different is with async operations: Blazor can also render after a incomplete Task and once it finishes (especially with lifecycles events and event handlers).

But both would not trigger a re-render when we update a variable in a timer callback, because that is not an event handler (as in: Not triggered by the user so the system can observe that easily) or an input parameter.

Angular would solve the problem with signals like this:

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-signal',
  template: `
    <h3>Signal use</h3>
    <p>Elapsed time: {{ elapsedTime() }} seconds</p>
  `
})
export class SignalComponent {
  elapsedTime = signal(0);
    constructor() {
        setInterval(() => {
            this.elapsedTime.update(value => value + 1);
        }, 1000);
    }
}

Of course there is much much more with signals, like computed and effect which lets you build graphs and they are only evaluated lazily when needed, see: https://angular.dev/guide/signals

But the point is that this system for UI updates is really elegant. So let's try something similar in Blazor!

Unfortunately, Blazor doesn't have signals natively, but we can create a simple implementation ourselves. And our system has many many restrictions!

Signal<T> in Blazor

Let's start with how we gonna use it, then we will run through the details:

@page "/Signal"
@implements IDisposable

<h3>Signal use</h3>

<Reactive>
    <p>Elapsed time: @elapsedMilliseconds.Value milliseconds</p>
    <p>Computed elapsed time: @elapsedSeconds.Value seconds</p>
</Reactive>

@code {
    private readonly Signal<int> elapsedMilliseconds = Signals.Signal(0);
    private Computed<int> elapsedSeconds = null!;
    private Timer? timer;

    protected override void OnInitialized()
    {
        elapsedSeconds = Signals.Computed(() => elapsedMilliseconds.Value / 1000);
        timer = new Timer(
            _ => elapsedMilliseconds.Update(value => value + 1000),
            null,
            TimeSpan.Zero,
            TimeSpan.FromSeconds(1));
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}

The API is losely that what Angular has: Signal<T> and Computed<T>. To create a signal, we call Signals.Signal(initialValue) and to create a computed value we call Signals.Computed(() => ...). The computed value will be re-evaluated whenever one of the signals it depends on changes. If you spin up the example (see resources below), you will see that the UI updates every second without us having to call StateHasChanged() manually. That is a big win! As a user I don't have to care about that, I just want to see the UI update when something changes.

Reactive Component

Here is a bit more code - I will break down the important parts. The hardest part of a signal system is knowing who is currently reading a value so we can link them together. Instead of forcing you to pass dependencies manually, we use an ambient context with AsyncLocal.

internal static class ReactiveContext
{
    private static readonly AsyncLocal<IReactiveObserver?> CurrentObserver = new();

    public static void Track(IReactiveSource source) =>
        CurrentObserver.Value?.DependencyRead(source);
        
    // ...
}

Whenever a Computed value or a reactive component starts rendering, it registers itself as the CurrentObserver. Signal<T> tracks reads. Because of that context, our Signal<T> doesn't just return a raw value when you call Value. It informs the tracking system:

public T Value
{
    get
    {
        ReactiveContext.Track(this); // "Hey, someone is reading me right now!"
        lock (gate)
        {
            return value;
        }
    }
}

If a signal does change (Set or Update) we notify all subscribers that they need to re-render. The Reactive component is a wrapper around a RenderFragment that subscribes to signals and re-renders when they change.

Computed is kind of the same - but it can be source and observer at the same time. It can be read by other signals and it can read other signals. When a Computed value is re-evaluated, it will re-register its dependencies.

Here almost the full code:

public static class Signals
{
    public static Signal<T> Signal<T>(T initialValue) => new(initialValue);

    public static Computed<T> Computed<T>(Func<T> computation) => new(computation);
}

public sealed class Signal<T> : IReactiveSource
{
    private readonly Lock gate = new();
    private readonly HashSet<IReactiveObserver> observers = [];
    private T value;

    internal Signal(T initialValue)
    {
        value = initialValue;
    }

    public T Value
    {
        get
        {
            ReactiveContext.Track(this);

            lock (gate)
            {
                return value;
            }
        }
    }

    public void Set(T newValue)
    {
        IReactiveObserver[] observersToNotify;

        lock (gate)
        {
            if (EqualityComparer<T>.Default.Equals(value, newValue))
            {
                return;
            }

            value = newValue;
            observersToNotify = [.. observers];
        }

        Notify(observersToNotify);
    }

    public void Update(Func<T, T> updater)
    {
        ArgumentNullException.ThrowIfNull(updater);

        IReactiveObserver[] observersToNotify;

        lock (gate)
        {
            var newValue = updater(value);
            if (EqualityComparer<T>.Default.Equals(value, newValue))
            {
                return;
            }

            value = newValue;
            observersToNotify = [.. observers];
        }

        Notify(observersToNotify);
    }

    void IReactiveSource.Subscribe(IReactiveObserver observer)
    {
        lock (gate)
        {
            observers.Add(observer);
        }
    }

    void IReactiveSource.Unsubscribe(IReactiveObserver observer)
    {
        lock (gate)
        {
            observers.Remove(observer);
        }
    }

    private static void Notify(IReactiveObserver[] observersToNotify)
    {
        foreach (var observer in observersToNotify)
        {
            observer.DependencyChanged();
        }
    }
}

public sealed class Computed<T> : IReactiveSource, IReactiveObserver
{
    private readonly Func<T> computation;
    private readonly Lock gate = new();
    private readonly HashSet<IReactiveObserver> observers = [];
    private readonly HashSet<IReactiveSource> dependencies = [];
    private T? value;
    private bool isDirty = true;
    private bool isComputing;

    internal Computed(Func<T> computation)
    {
        ArgumentNullException.ThrowIfNull(computation);
        this.computation = computation;
    }

    public T Value
    {
        get
        {
            ReactiveContext.Track(this);

            lock (gate)
            {
                if (!isDirty)
                {
                    return value!;
                }

                if (isComputing)
                {
                    throw new InvalidOperationException("A computed value cannot depend on itself.");
                }

                isComputing = true;

                try
                {
                    ClearDependencies();

                    using (ReactiveContext.Observe(this))
                    {
                        value = computation();
                    }

                    isDirty = false;
                    return value;
                }
                finally
                {
                    isComputing = false;
                }
            }
        }
    }

    void IReactiveObserver.DependencyRead(IReactiveSource source)
    {
        lock (gate)
        {
            if (dependencies.Add(source))
            {
                source.Subscribe(this);
            }
        }
    }

    void IReactiveObserver.DependencyChanged()
    {
        IReactiveObserver[] observersToNotify;

        lock (gate)
        {
            if (isDirty)
            {
                return;
            }

            isDirty = true;
            observersToNotify = [.. observers];
        }

        foreach (var observer in observersToNotify)
        {
            observer.DependencyChanged();
        }
    }

    void IReactiveSource.Subscribe(IReactiveObserver observer)
    {
        lock (gate)
        {
            observers.Add(observer);
        }
    }

    void IReactiveSource.Unsubscribe(IReactiveObserver observer)
    {
        lock (gate)
        {
            observers.Remove(observer);
        }
    }

    private void ClearDependencies()
    {
        foreach (var dependency in dependencies)
        {
            dependency.Unsubscribe(this);
        }

        dependencies.Clear();
    }
}

Now if we have a look at the Reactive component:

using BlazorSignals.Reactivity;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace BlazorSignals.Components;

public sealed class Reactive : ComponentBase, IReactiveObserver, IDisposable
{
    private HashSet<IReactiveSource> dependencies = [];
    private HashSet<IReactiveSource>? dependenciesReadDuringRender;
    private bool renderRequested;
    private bool isDisposed;

    [Parameter, EditorRequired]
    public RenderFragment ChildContent { get; set; } = null!;

    protected override void BuildRenderTree(RenderTreeBuilder builder)
    {
        var previousDependencies = dependencies;
        var currentDependencies = new HashSet<IReactiveSource>();
        dependenciesReadDuringRender = currentDependencies;

        try
        {
            using (ReactiveContext.Observe(this))
            {
                builder.AddContent(0, ChildContent);
            }
        }
        finally
        {
            dependenciesReadDuringRender = null;

            foreach (var dependency in previousDependencies
                         .Where(dependency => !currentDependencies.Contains(dependency)))
            {
                dependency.Unsubscribe(this);
            }

            dependencies = currentDependencies;
        }
    }

    void IReactiveObserver.DependencyRead(IReactiveSource source)
    {
        if (dependenciesReadDuringRender!.Add(source) && !dependencies.Contains(source))
        {
            source.Subscribe(this);
        }
    }

    void IReactiveObserver.DependencyChanged()
    {
        if (isDisposed || Interlocked.Exchange(ref renderRequested, true))
        {
            return;
        }

        _ = InvokeAsync(() =>
        {
            Interlocked.Exchange(ref renderRequested, false);

            if (!isDisposed)
            {
                StateHasChanged();
            }
        });
    }

    public void Dispose()
    {
        isDisposed = true;

        foreach (var dependency in dependencies)
        {
            dependency.Unsubscribe(this);
        }

        dependencies.Clear();
    }
}

It basically just tracks the dependencies during rendering and subscribes to them. When a dependency changes, it requests a re-render of the component.

Resources

An error has occurred. This application may no longer respond until reloaded.Reload x