Fine-grained reactive state for .NET. Wrap a value in a signal, read it inside a computation, and the computation re-runs by itself whenever that value changes — no manual subscriptions, no PropertyChanged plumbing, no dependency lists to keep in sync.
Signals are built on R3 (a modern ReactiveX implementation), so every signal is also an Observable<T> and the whole Rx operator set stays available to you.
var firstName = new Signal<string>("Ada");
var lastName = new Signal<string>("Lovelace");
var fullName = Signal.Computed(() => $"{firstName.Value} {lastName.Value}");
Console.WriteLine(fullName.Value); // Ada Lovelace
firstName.Value = "Grace";
Console.WriteLine(fullName.Value); // Grace LovelacefullName discovered its own dependencies simply by reading them. Nothing declared that it depends on firstName.
Ask for the fields you want with a GraphQL-like syntax, and get a stream that pushes a new projection whenever any signal behind them changes — see Queries for the full walkthrough.
@page "/counter"
@using SignalsDotnet
@using R3
@using SignalsDotnet.Blazor
<TrackedScope>
<div>
<h1>Counter</h1>
<p>
Count: @_count.Value <br />
</p>
<TrackedScope>
<p>Now: @_now.Value</p>
</TrackedScope>
<button class="btn btn-primary"
@onclick="() => _count.Value++">
Click me
</button>
</div>
</TrackedScope>
@code {
readonly CancellationDisposable _cd = new();
readonly Signal<int> _count = new(0);
IReadOnlySignal<DateTime> _now = null!;
protected override void OnInitialized()
{
_now = Observable
.Interval(TimeSpan.FromSeconds(1))
.Select(_ => DateTime.Now)
.TakeUntil(_cd.Token)
.ToSignal();
}
}- Get Started
- Core Concepts
- Source Generator
- Basic Examples
- Signal Types
- Computed Signals & Linked Signals
- Effects
- Advanced Features
- Subscription Strategies
- Blazor Integration
- Queries (alpha)
Adoption is incremental. Hold state in signals instead of plain fields and properties, read those signals wherever you derive something from them, and the derivations keep themselves current. There is no container to configure and no framework to buy into — signals are ordinary objects you can introduce one at a time.
Every signal also implements INotifyPropertyChanged.
| Type | Role |
|---|---|
Signal<T> |
Writable signal holding a value of type T |
IReadOnlySignal<T> |
Read-only signal — computed or readonly |
IAsyncReadOnlySignal<T> |
Read-only signal backed by an async computation |
ISignal<T> |
Writable signal interface, used by linked signals |
IAsyncSignal<T> |
Writable signal backed by an async computation |
CollectionSignal<T> |
Signal wrapping an ObservableCollection |
DictionarySignal<TKey, TValue> |
Reactive dictionary with per-key tracking |
- Runs Everywhere — MAUI, WPF, Avalonia, Uno Platform, Blazor, Unity, Godot, and plain .NET
- Automatic Dependency Tracking — dependencies are discovered as they are read, not declared
- Computed Signals — derived values that stay in sync by themselves
- Async Support — asynchronous computations with cancellation and concurrency control
- Deep Collection Reactivity — collection signals react to the collection and to what is inside it
- Effects — side effects that re-run when the signals they touch change
- Signal Events — notifications that fire even when the value is unchanged
- Full Rx Power — every signal is an
Observable, so the entire R3/ReactiveX ecosystem applies - Leak Resistant — weak subscriptions and ref-counting keep long-lived sources from pinning objects alive
- Source Generator — declare partial properties, skip the boilerplate
Declaring signals by hand gets repetitive. Mark a partial class with [GenerateSignals], declare partial auto properties, and the generator backs each one with a Signal<T>:
[GenerateSignals]
public partial class Person
{
public partial string Name { get; set; }
public partial int Age { get; set; }
}Name and Age now read and write like ordinary properties, but they are signals underneath — assigning to them notifies anything that depends on them. For each property you also get a {Name}Signal member exposing the underlying IReadOnlySignal<T>, for when you need the signal itself rather than its value:
var person = new Person { Name = "Ada", Age = 36 };
var summary = Signal.Computed(() => $"{person.Name} is {person.Age}");
person.Age = 37; // summary recomputes
person.AgeSignal.Values.Subscribe(Print); // the signal behind the propertyThe same class can declare derived properties. A method named Compute<PropertyName> marked [Computed] generates the property it computes, and [AsyncComputed] does the same for work that takes a CancellationToken:
[GenerateSignals]
[GenerateNotifyPropertyChanged]
public partial class Person
{
public partial string Name { get; set; }
public partial int Age { get; set; }
[Computed]
string ComputeFullName() => $"{Name} {Age}";
[AsyncComputed(ConcurrentChangeStrategy = ConcurrentChangeStrategy.CancelCurrent)]
async ValueTask<bool> ComputeIsAdult(CancellationToken token)
{
await Task.Delay(100, token);
return Age >= 18;
}
}That yields:
| Member | From | Notes |
|---|---|---|
Name, Age |
partial properties | read/write, backed by signals |
NameSignal, AgeSignal |
partial properties | IReadOnlySignal<T> access |
FullName + FullNameSignal |
[Computed] ComputeFullName() |
method must be named Compute<PropertyName> |
IsAdult + IsAdultSignal + IsIsAdultComputing |
[AsyncComputed] |
takes a CancellationToken, returns Task<T>/ValueTask<T> |
ModelChanged |
all writable signals | IReadOnlySignal<Person> that re-emits the instance on any change |
PropertyChanged |
[GenerateNotifyPropertyChanged] |
optional; pass false to disable |
Other attributes: [Signal] backs a single property without annotating the class, and [SignalIgnore] excludes one.
A method marked [Effect] becomes an effect created for you: it runs once when the instance is constructed and re-runs whenever a signal it read changes. The effect is held for the lifetime of the instance in a private field, so nothing extra is exposed on the type. [AsyncEffect] does the same for a method taking a CancellationToken and returning ValueTask or Task:
[GenerateSignals]
public partial class SearchViewModel
{
public partial string Term { get; set; }
[Effect]
void LogTerm() => Logger.LogInformation("Searching {Term}", Term);
[AsyncEffect(ConcurrentChangeStrategy = ConcurrentChangeStrategy.CancelCurrent)]
async ValueTask Search(CancellationToken token)
{
Results = await SearchAsync(Term, token);
}
}[Effect] requires a parameterless, non-static void method (SIG014–SIG016); [AsyncEffect] requires a non-static method taking exactly one CancellationToken and returning ValueTask or Task (SIG017). ConcurrentChangeStrategy defaults to ScheduleNext — see ConcurrentChangeStrategy.
If you declare no constructor, the generator emits a parameterless one that initializes the signals. As soon as you declare your own constructors, the generator emits none, and each of yours must either call the generated InitializeSignals() or chain to another constructor with : this(...); otherwise it reports SIG013. Call it before touching any generated member:
[GenerateSignals]
public partial class Person
{
public partial string Name { get; set; }
[Computed]
string ComputeShout() => Name.ToUpperInvariant();
public Person(string name)
{
InitializeSignals();
Name = name;
}
}InitializeSignals() is protected on classes (private on structs), and it is generated even for a type with no signal members, so a constructor can call it unconditionally.
A computed signal that tracks every writable signal property and yields the model itself, so it re-emits whenever any of them changes:
person.ModelChanged.Values.Subscribe(p => Console.WriteLine(p));
var revision = Signal.Computed(() =>
{
_ = person.ModelChanged.Value;
return DateTime.UtcNow;
});Computed and async computed properties are not tracked, since they derive from the same signals. It is not generated for structs, or when a ModelChanged member already exists.
Records are supported and keep their value semantics: the generator emits PrintMembers, Equals, GetHashCode, and a copy constructor that use only the data properties, so signals stay out of ToString() and equality, and with produces an independent copy.
[GenerateSignals]
public partial record Person
{
public partial string Name { get; set; }
public partial int Age { get; set; }
}
var a = new Person { Name = "Ada", Age = 36 };
Console.WriteLine(a); // Person { Name = Ada, Age = 36 }
Console.WriteLine(a == new Person { Name = "Ada", Age = 36 }); // True
var older = a with { Age = 40 }; // independent copy, a.Age is still 36Positional records (primary constructors) are not supported and report SIG011, because their generated properties and constructor conflict with the generated ones. record struct is supported, except that with copies signal references rather than cloning them.
All generated members carry [IgnoreDataMember] and [JsonIgnore], so only the data properties are serialized and DTOs round-trip:
var json = JsonSerializer.Serialize(new Person { Name = "Ada", Age = 36 });
// {"Name":"Ada","Age":36}
var restored = JsonSerializer.Deserialize<Person>(json);[JsonIgnore] is only emitted when System.Text.Json is available in the consuming project.
CanLogin recomputes on every keystroke in either field, with nothing wiring the two together:
public class LoginViewModel
{
public Signal<string> Username { get; } = new();
public Signal<string> Password { get; } = new();
public IReadOnlySignal<bool> CanLogin { get; }
public LoginViewModel()
{
CanLogin = Signal.Computed(() => !string.IsNullOrWhiteSpace(Username.Value)
&& !string.IsNullOrWhiteSpace(Password.Value));
}
}Commands can ride along on that signal. The pattern below is Prism's DelegateCommand, but any MVVM framework works the same way:
public static T RaiseCanExecuteChangedAutomatically<T>(this T @this) where T : DelegateCommand
{
var signal = Signal.Computed(@this.CanExecute, config => config with { SubscribeWeakly = false });
signal.Subscribe(_ => @this.RaiseCanExecuteChanged());
_ = signal.Value;
return @this;
}The factory applies one deactivation trigger and one error handler to everything it creates. IsUsernameValid re-runs when Username changes — cancelling the previous request — and IsComputing lets the UI disable the button while it is in flight:
public class LoginViewModel
{
public Signal<bool> IsDeactivated { get; } = new(false);
public Signal<string?> Username { get; } = new("");
public Signal<string> Password { get; } = new("");
public IAsyncReadOnlySignal<bool> IsUsernameValid { get; }
public IReadOnlySignal<bool> CanLogin { get; }
public LoginViewModel()
{
var factory = ComputedSignalFactory.Default
.DisconnectEverythingWhen(IsDeactivated.Values)
.OnException(exception => Logger.LogError(exception, "Computation failed"));
IsUsernameValid = factory.AsyncComputed(
async token => await IsUsernameValidAsync(Username.Value, token),
false,
ConcurrentChangeStrategy.CancelCurrent);
CanLogin = factory.Computed(() => !IsUsernameValid.IsComputing.Value
&& IsUsernameValid.Value
&& !string.IsNullOrWhiteSpace(Password.Value));
}
async Task<bool> IsUsernameValidAsync(string? username, CancellationToken token)
{
await Task.Delay(3000, token);
return username?.Length > 2;
}
}YoungestPerson recomputes when a city, house, room, or person is added or removed and when any single person's Age changes — four levels down, with no subscription code:
public class YoungestPersonViewModel
{
public CollectionSignal<ObservableCollection<City>> Cities { get; } = new();
public IReadOnlySignal<PersonCoordinates?> YoungestPerson { get; }
public YoungestPersonViewModel()
{
YoungestPerson = Signal.Computed(() =>
{
var people = from city in Cities.Value.EmptyIfNull()
from house in city.Houses.Value.EmptyIfNull()
from room in house.Rooms.Value.EmptyIfNull()
from person in room.People.Value.EmptyIfNull()
select new PersonCoordinates(person, room, house, city);
return people.DefaultIfEmpty().MinBy(x => x?.Person.Age.Value);
});
}
}
public class City { public CollectionSignal<ObservableCollection<House>> Houses { get; } = new(); }
public class House { public CollectionSignal<ObservableCollection<Room>> Rooms { get; } = new(); }
public class Room { public CollectionSignal<ObservableCollection<Person>> People { get; } = new(); }
public class Person { public Signal<int> Age { get; } = new(); }
public record PersonCoordinates(Person Person, Room Room, House House, City City);Every signal exposes Values, an Observable<T> that emits the current value and then every change. FutureValues is the same stream without the current value, for when you only care about what happens next.
The workhorse: a writable box around a T. It raises PropertyChanged when the value changes.
// Basic signal
public Signal<Person> Person { get; } = new();
// Signal with custom equality comparer
public Signal<Person> Person2 { get; } = new(config => config with
{
Comparer = new CustomPersonEqualityComparer()
});
// Signal with initial value
public Signal<string> Username { get; } = new("initial value");
// Signal that always raises PropertyChanged (even for same values)
public Signal<int> Counter { get; } = new(config => config with
{
RaiseOnlyWhenChanged = false
});Configuration Options:
Comparer— customIEqualityComparer<T>deciding what counts as a changeRaiseOnlyWhenChanged— raisePropertyChangedonly on an actual change (default:true)SubscribeWeakly— hold upstream subscriptions weakly (default:false)SubscriptionStrategy— when the upstream subscription is active; see Subscription Strategies
Changing Global Defaults:
Defaults apply to every signal created afterwards, so set them once at startup:
// Set new global defaults
ReadonlySignalConfiguration.Default = new(
RaiseOnlyWhenChanged: true,
SubscribeWeakly: true,
SubscriptionStrategy: SubscriptionStrategy.RefCount
);
// All new signals will use these defaults
var signal = new Signal<int>();
var linkedSignal = Observable.Interval(TimeSpan.FromSeconds(1)).ToSignal();Wraps an ObservableCollection (or any INotifyCollectionChanged) and listens on two channels at once:
- Replacement of the collection itself, through the
Valueproperty - Mutation of its contents —
Add,Remove,Clear, and the rest
That second channel is what makes deep reactivity work: a computed signal reading such a collection recomputes when items come and go, and — if the items themselves hold signals — when their properties change. Example 3 above walks four levels of this.
// Basic collection signal
public CollectionSignal<ObservableCollection<Person>> People { get; } = new();
// Collection signal with throttling to batch notifications
public CollectionSignal<ObservableCollection<Person>> People { get; } = new(
collectionChangedConfiguration: config => config.ThrottleOneCycle(UIReactiveScheduler)
);Why throttle? A call like AddRange() fires one CollectionChanged event per item, and each one would otherwise trigger a recomputation. Throttling collapses the burst into a single notification per UI frame.
Configuration Options:
collectionChangedConfiguration— how collection change events are processed (throttling, filtering, …)propertyChangedConfiguration— the signal's own property-changed behaviorSubscribeWeakly— subscribe to collection events weakly to avoid pinning the collection (default:false)
Implements IDictionary<TKey, TValue> with tracking at the granularity of individual keys. A computed signal that reads Scores["player1"] depends on that key alone — writes to any other key leave it untouched.
public class ViewModel
{
public DictionarySignal<string, int> Scores { get; } = new();
public ViewModel()
{
Scores["player1"] = 100;
Scores["player2"] = 150;
var player1Score = Signal.Computed(() =>
{
return Scores.TryGetValue("player1", out var score) ? score : 0;
});
Scores["player1"] = 200;
}
}Key Features:
Fine-Grained Key Tracking: subscriptions follow the keys actually read on the last run. Below, flipping useA moves the dependency from "a" to "b" and the stale one is released:
var dictionary = new DictionarySignal<string, int>();
dictionary["a"] = 1;
dictionary["b"] = 2;
var useA = new Signal<bool>(true);
var score = Signal.Computed(() =>
{
return useA.Value ? dictionary["a"] : dictionary["b"];
});
_ = score.Value;
useA.Value = false;Reactive Views: Keys, Values, and Count are tracked as well:
var keyCount = Signal.Computed(() => dictionary.Keys.Count);
var totalScore = Signal.Computed(() => dictionary.Values.Sum());Operations: the full IDictionary surface works, and every mutation is reactive:
dictionary.Add("player3", 75);
dictionary.Remove("player1");
dictionary.ContainsKey("player2");
dictionary.Clear();
dictionary["player3"] = 200;Memory Efficient: because per-key subscriptions are dropped as soon as a computation stops reading that key, dictionaries with churning or unbounded key sets do not accumulate dead trackers.
// Create signals using factory methods
var signal = Signal.Create<string>();
var signalWithValue = Signal.Create("initial");
// Convert Observable to Signal
Observable<int> observable = /* ... */;
IReadOnlySignal<int> signal = observable.ToSignal();
ISignal<int> linkedSignal = observable.ToLinkedSignal();
// Create collection signal from existing collection
ObservableCollection<Person> collection = new();
IReadOnlySignal<ObservableCollection<Person>> signal = collection.ToCollectionSignal();
// Create from observable with configuration
var signal = Observable.Interval(TimeSpan.FromSeconds(1))
.ToSignal(config => config with { RaiseOnlyWhenChanged = false });
// Create with ref-count subscription strategy (unsubscribe when no listeners)
var refCountSignal = Observable.Interval(TimeSpan.FromSeconds(1))
.ToSignal(config => config with { SubscriptionStrategy = SubscriptionStrategy.RefCount });A computed signal is a value defined by an expression rather than by assignment. It watches whatever that expression reads and recomputes when any of it changes. Computed signals are ref-counted by default, so an unobserved one does no work at all — see Subscription Strategies.
var firstName = new Signal<string>("John");
var lastName = new Signal<string>("Doe");
// Automatically updates when firstName or lastName changes
var fullName = Signal.Computed(() => $"{firstName.Value} {lastName.Value}");
Console.WriteLine(fullName.Value); // "John Doe"
firstName.Value = "Jane";
Console.WriteLine(fullName.Value); // "Jane Doe"A linked signal is computed, but you can also write to it. The manual value holds until the source changes, at which point the computation takes over again:
var source = new Signal<int>(10);
var linked = Signal.Linked(() => source.Value * 2);
Console.WriteLine(linked.Value); // 20
// Can be manually overridden
linked.Value = 100;
Console.WriteLine(linked.Value); // 100
// Automatically recomputes when source changes
source.Value = 5;
Console.WriteLine(linked.Value); // 10var username = new Signal<string>();
var isUsernameValid = Signal.AsyncComputed(
async cancellationToken =>
{
var user = username.Value;
return await ValidateUsernameAsync(user, cancellationToken);
},
defaultValue: false,
ConcurrentChangeStrategy.CancelCurrent
);
// Check if computation is running
if (isUsernameValid.IsComputing.Value)
{
Console.WriteLine("Validating...");
}ComputedSignalFactory lets you apply one policy — error handling, a deactivation trigger, a scheduler — to a whole group of signals instead of repeating it at each call site:
public class LoginViewModel
{
public Signal<bool> IsDeactivated { get; } = new(false);
public LoginViewModel()
{
var computedFactory = ComputedSignalFactory.Default
.DisconnectEverythingWhen(IsDeactivated.Values)
.OnException(exception =>
{
Logger.LogError(exception, "Computation error");
});
// All signals created from this factory will be cancelled when IsDeactivated is true
IsUsernameValid = computedFactory.AsyncComputed(
async cancellationToken => await IsUsernameValidAsync(Username.Value, cancellationToken),
false,
ConcurrentChangeStrategy.CancelCurrent
);
CanLogin = computedFactory.Computed(() =>
!IsUsernameValid.IsComputing.Value &&
IsUsernameValid.Value &&
!string.IsNullOrWhiteSpace(Password.Value)
);
// Effects are also created from the factory
computedFactory.Effect(UpdateApiCalls);
}
public Signal<string?> Username { get; } = new();
public Signal<string> Password { get; } = new();
public IAsyncReadOnlySignal<bool> IsUsernameValid { get; }
public IReadOnlySignal<bool> CanLogin { get; }
async Task<bool> IsUsernameValidAsync(string? username, CancellationToken cancellationToken)
{
await Task.Delay(3000, cancellationToken);
return username?.Length > 2;
}
void UpdateApiCalls()
{
// Effect logic here
}
}An async computation takes time, and a dependency may change before it finishes. ConcurrentChangeStrategy says what to do about it:
CancelCurrent— cancel the in-flight computation and restart immediately. Right for validation and search-as-you-type, where only the latest result matters.ScheduleNext— let the current run finish, then run once more (at most one queued). Right when the computation has side effects or must not be interrupted.
Either way, DisconnectEverythingWhen cancellation still applies.
There is no magic in the dependency tracking, just bookkeeping around the Value getter:
- Before running the computation, the signal installs itself as the current tracker
- Every
Valuegetter that runs reports itself to that tracker - When the computation returns, the signal subscribes to exactly the signals that reported in
- Any of them changing re-runs the computation, which re-collects the dependency set from scratch
Because the set is rebuilt each run, dependencies follow your control flow. A branch that wasn't taken creates no subscription, and a dependency abandoned on the latest run is released.
An effect tracks dependencies exactly like a computed signal, but produces no value — it exists for what it does. Reach for one when the reaction to a change is logging, navigation, persistence, or a call out to something else.
public class ViewModel
{
public Signal<int> Counter { get; } = new();
public ViewModel()
{
// Effect runs immediately and re-runs whenever Counter changes
var effect = new Effect(() =>
{
Console.WriteLine($"Counter value: {Counter.Value}");
});
}
}public class ViewModel
{
public Signal<string> SearchTerm { get; } = new();
public ViewModel()
{
var effect = new Effect(async cancellationToken =>
{
var term = SearchTerm.Value;
await SearchAsync(term, cancellationToken);
}, ConcurrentChangeStrategy.CancelCurrent);
}
}Writing several signals in a row would normally run dependent effects once per write, including on the inconsistent intermediate states. Wrap the writes in an atomic operation and effects run once, at the end:
Effect.AtomicOperation(() =>
{
signal1.Value = 1;
signal2.Value = 2;
signal3.Value = 3;
// Effect runs only once after all changes
});
// Async version
await Effect.AtomicOperationAsync(async () =>
{
await Task.Yield();
signal1.Value = 1;
await Task.Yield();
signal2.Value = 2;
// Effect runs only once after all changes
});Pass a scheduler to control where and when the effect body runs — useful for marshalling to a UI thread or coalescing to a frame:
var scheduler = TimeProvider.System;
var effect = new Effect(() =>
{
// This will be scheduled on the specified scheduler
DoSomething();
}, scheduler);Sometimes a computation needs to read a signal without depending on it. Signal.Untracked() and the UntrackedValue shortcuts read the current value while staying invisible to the tracker:
public class LoginViewModel
{
public LoginViewModel()
{
// Using Untracked() method
CanLogin = Signal.Computed(() =>
{
return !string.IsNullOrWhiteSpace(Username.Value) &&
Signal.Untracked(() => !string.IsNullOrWhiteSpace(Password.Value));
});
// Using UntrackedValue property
CanLogin = Signal.Computed(() => !string.IsNullOrWhiteSpace(Username.Value) &&
!string.IsNullOrWhiteSpace(Password.UntrackedValue));
// For collection signals
var anyPeople = Signal.Computed(() => People.UntrackedValue);
var anyPeople2 = Signal.Computed(() => People.UntrackedCollectionChangedValue);
}
public CollectionSignal<ObservableCollection<Person>> People { get; } = new();
public Signal<string> Username { get; } = new();
public Signal<string> Password { get; } = new();
public IReadOnlySignal<bool> CanLogin { get; }
}Signal.InsideComputed tells you whether a computation is currently collecting dependencies. Custom reactive sources can use it to skip building tracking state for reads that nothing is observing:
A signal event notifies on every Invoke(), even when nothing about the value changed. Use it for things that happen rather than things that are — a refresh request, a submitted command, a tick:
public class ViewModel
{
public ISignal<Unit> RefreshRequested { get; } = Signal.CreateEvent();
public void RequestRefresh()
{
RefreshRequested.Invoke(); // Always triggers notification
}
public ViewModel()
{
var effect = new Effect(() =>
{
RefreshRequested.Track(); // Track the event
// This runs every time Invoke() is called
PerformRefresh();
});
}
}Merge several signals into one observable that fires whenever any of them changes, regardless of their types:
var signal1 = new Signal<int>();
var signal2 = new Signal<string>();
var signal3 = new Signal<bool>();
Observable<Unit> anyChanged = Signal.WhenAnyChanged(signal1, signal2, signal3);
anyChanged.Subscribe(_ => Console.WriteLine("At least one signal changed"));Turns a boolean observable into a signal of CancellationTokens: each time the flag goes true, the current token is cancelled and a fresh one takes its place. Handy for tying async work to a lifecycle such as view deactivation:
Observable<bool> isDeactivated = this.IsDeactivated();
IReadOnlySignal<CancellationToken> cancellationSignal = CancellationSignal.Create(isDeactivated);
// Use the cancellation token in async operations
await SomeAsyncOperation(cancellationSignal.Value);SubscriptionStrategy controls how long a computed or observable-backed signal stays subscribed to its source:
Persistent(default) — subscribes once on first value access and keeps that subscription for the signal's lifetime. Pick it when the signal must not miss anything while unobserved, or when re-subscribing to the source is expensive.RefCount(opt-in) — subscribes while at least one observer is listening toValues/FutureValues, and unsubscribes when the last one goes away. An unobserved signal costs nothing, and a re-observed one starts up again. This propagates: when a ref-counted computed goes idle it releases its dependencies, so a whole derived graph can wind down behind a closed view.
var signal = Signal.Computed(() => a.Value + b.Value,
config => config with { SubscriptionStrategy = SubscriptionStrategy.RefCount });
var ticking = Observable.Interval(TimeSpan.FromSeconds(1))
.ToSignal(config => config with { SubscriptionStrategy = SubscriptionStrategy.RefCount });Defaults can be changed globally:
ReadonlySignalConfiguration.Default = ReadonlySignalConfiguration.Default with
{
SubscriptionStrategy = SubscriptionStrategy.RefCount
};With
RefCount, a signal is inert until something observes it, and while idle its value is whatever it last saw. Subscribe toValues(not onlyFutureValues) to activate it and get the current value. In XAML and Blazor this is automatic — a binding or aTrackedScopeis itself an observer.
The SignalsDotnet.Blazor package lets components re-render on their own when the signals they read change.
TrackedScope marks a reactive region of markup. Every signal read through .Value while that region renders becomes a dependency of it, and a change to any of them re-renders that region alone rather than the whole component. Scopes nest, so you can keep a frequently-changing value from invalidating everything around it. Updates are dispatched via InvokeAsync(StateHasChanged), so they land on the right SynchronizationContext.
<TrackedScope>
<p>Current count is: @_count.Value</p>
</TrackedScope>This Blazor signal integration is inspired by Steven Giesel's excellent blog post, Signals in Blazor.
Alpha.
SignalsDotnet.QueryandSignalsDotnet.AspNetCoreship as prerelease packages; the API may still change.
A client asks for the fields it wants with a GraphQL-like string, and gets a stream that pushes a new projection every time any signal behind those fields changes. Only the selected fields are read, so changes to everything else (including nested models and collection elements) cause no emission. A query can also call methods with arguments, and their results are tracked the same way.
Install SignalsDotnet.AspNetCore; it brings SignalsDotnet.Query and SignalsDotnet with it. The steps below build a live dashboard endpoint, a browser explorer for it, and a client that consumes it.
An ordinary signals model. Nothing here knows about queries or HTTP. [Computed] members recompute themselves when the signals they read change.
[GenerateSignals]
public partial class Sensor
{
public partial string Name { get; set; }
public partial double Reading { get; set; }
public partial bool IsOnline { get; set; }
}
[GenerateSignals]
public partial class Dashboard
{
public partial string Title { get; set; }
[SignalIgnore]
public CollectionSignal<ObservableCollection<Sensor>> Sensors { get; } = new();
[Computed]
int ComputeOnlineCount() => Sensors.Value?.Count(x => x.IsOnline) ?? 0;
[Computed]
double ComputeAverage()
{
var online = Sensors.Value?.Where(x => x.IsOnline).ToArray() ?? [];
return online.Length == 0 ? 0 : Math.Round(online.Average(x => x.Reading), 2);
}
[SignalQueryable]
public Sensor? GetSensorByIndex(int index) => Sensors.Value?.ElementAtOrDefault(index);
}Properties and [Computed] members are queryable as fields. A method annotated with [SignalQueryable] is callable with arguments — see Calling Methods.
builder.Services.AddSingletonSignalIsland<Dashboard>();That registers a SignalIsland<Dashboard>, the model plus the Synchronization Context that serializes access to it. Constructor dependencies are resolved through ActivatorUtilities, so a model taking services in its constructor just works. AddScopedSignalIsland<T> and AddTransientSignalIsland<T> are also available, and each has an overload taking a factory when you want to build the instance yourself.
Whatever writes to the model (a hosted service, a message handler, an endpoint) goes through InvokeAsync, which queues the delegate onto the island's Synchronization Context. It has sync, async, and value-returning overloads:
await island.InvokeAsync(dashboard => dashboard.Title = "Live");
var ticks = await island.InvokeAsync(dashboard => dashboard.Ticks);SwitchToIslandContextAsync is also available: it is an awaitable that moves the caller onto the island's Synchronization Context and hands back the model.
var dashboard = await island.SwitchToIslandContextAsync(cancellationToken);Inject the island and hand the query to TypedResults.SignalComputed:
app.MapGet("/api/dashboard/stream", (SignalIsland<Dashboard> island, SignalsQueryString query, CancellationToken cancellationToken) =>
TypedResults.SignalComputed(island, query, cancellationToken))
.WithSignalIslandDiscovery();SignalsQueryString binds the query from the query string parameter, and the endpoint pushes a new projection as server-sent events whenever a selected signal changes. It rejects a missing query, a malformed one, and one naming a field or method that does not exist on Dashboard, each with a 400 and a message saying what was wrong — so client mistakes surface as errors rather than as a stream that silently never emits. It takes an optional JsonSerializerOptions.
WithSignalIslandDiscovery is what makes the endpoint visible to the query explorer below; it infers the island type from the handler's SignalIsland<T> parameter, so it works on any handler you write. Leave it off and the endpoint still streams, it just does not show up in the dropdown.
A client subscribing to /api/dashboard/stream?query={ title onlineCount } gets an event whenever Title or a sensor's IsOnline changes, and nothing when an unselected field does.
The projection is yielded immediately, then again on every relevant change, and stops when the request is cancelled. A slow client never blocks the model: the stream is backed by a bounded channel of capacity 1 that drops the oldest value, so it receives the latest state rather than every intermediate one. Serialization follows SignalsQueryExtensions.DefaultJsonOptions (web defaults).
For full control over parsing and error shape, go one level down to ReadComputedValuesAsync:
app.MapGet("/api/dashboard/stream", (SignalIsland<Dashboard> island, string? query, CancellationToken token) =>
{
if (!SignalsQuery.TryParse(query, out var selection))
return Results.BadRequest(new { error = $"'{query}' is not a valid query." });
return TypedResults.ServerSentEvents(island.ReadComputedValuesAsync(selection, cancellationToken: token));
}).WithSignalIslandDiscovery();MapSignalsQueryUi mounts a query explorer, served as a single self-contained page with no external assets:
app.MapSignalsQueryUi("/signals");Open /signals and you get an editor to write a query against, and a live view of the events coming back. It discovers every island endpoint marked with WithSignalIslandDiscovery on the same app, so a dropdown switches between them and the schema drives:
- autocomplete (
Ctrl/⌘+Space) over fields and queryable methods, inserting a call template with the caret at the first argument - syntax highlighting of fields, arguments, literals, and braces, with the offending character underlined when a query is malformed
- inline validation as you type, reporting the parse error and its line and column before you ever subscribe
Events stream into the right-hand pane as they arrive, each one inspectable as raw JSON or copyable to the clipboard. If the connection drops, the page reconnects on its own with exponential backoff up to 8 seconds.
Keyboard: Ctrl/⌘+Enter subscribes, Ctrl/⌘+. stops, Ctrl/⌘+K clears the events. format reindents the query, and copy url yields the full stream URL with the query encoded, ready to paste into a client.
This is a development tool. It exposes the shape of your model, so map it behind whatever authorization your app uses, or only in development:
if (app.Environment.IsDevelopment())
app.MapSignalsQueryUi("/signals");Send the query as a query-string parameter and read the response with SseParser. Field names are camelCase, matching the web defaults used to serialize them:
var query = """
{
title
onlineCount
sensors { name reading isOnline }
}
""";
var url = $"/api/dashboard/stream?query={Uri.EscapeDataString(query)}";
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(token);
await foreach (var item in SseParser.Create(stream).EnumerateAsync(token))
Console.WriteLine(item.Data);Each event carries only the requested fields. See the Playground projects for the full working example.
A query is a brace-delimited selection set. Fields are separated by whitespace or commas, and nest to any depth:
{ title onlineCount }
{ title, average, sensors { name reading } }
Field names follow the JsonSerializerOptions in use, so with the web defaults they are camelCase and a PascalCase name is rejected. A bare title is shorthand for { title }. Selecting an object without a nested set returns it whole, and applied to a collection a query projects each element.
A field can be given an alias with alias: field, which renames it in the output:
{ heading: title online: onlineCount }
var query = SignalsQuery.Parse("{ title sensors { name } }");
if (!SignalsQuery.TryParse(userInput, out var safe))
return Results.BadRequest();TryParse returns false on malformed input instead of throwing, so use it for anything client-supplied. A string also converts implicitly to SignalsQuery.
Parsing only validates the shape of the query. Names are resolved against T when the query is compiled, and an unknown or non-selectable field throws FormatException there, so an endpoint taking queries from clients should catch it as well as calling TryParse.
A query compiles to an ordinary selector, useful on its own:
Func<Dashboard, object?> selector = query.ToQuerySelector<Dashboard>();A query can call methods, not just read properties. Annotate a method with [SignalQueryable] to expose it:
[GenerateSignals]
public partial class Dashboard
{
[SignalIgnore]
public CollectionSignal<ObservableCollection<Sensor>> Sensors { get; } = new();
[SignalQueryable]
public Sensor? GetSensorByIndex(int index) => Sensors.Value?.ElementAtOrDefault(index);
[SignalQueryable]
public IReadOnlyList<Sensor> GetSensorsAbove(double threshold, bool onlineOnly = true) =>
Sensors.Value?.Where(x => (!onlineOnly || x.IsOnline) && x.Reading > threshold).ToList() ?? [];
}Arguments are named, GraphQL-style, and the result takes a nested selection set like any other field:
{
title
getSensorByIndex(index: 0) { name reading }
getSensorsAbove(threshold: 20) { name }
}
Calls are tracked like everything else: a method runs inside the projection, so every signal it reads becomes a dependency and the stream pushes a new value whenever one of them changes. A method reading nothing selected elsewhere still re-emits on its own dependencies, and changes to signals it never touches emit nothing.
Opting in is per method. A public method without the attribute is not callable, and querying it reports that it needs annotating. Putting [SignalQueryable] on the class instead exposes all of its public methods at once.
Methods are callable at any depth: on nested objects, on each element of a collection, and on the result of another call.
{
sensors { name readingIn(unit: "F") }
getSensorByIndex(index: 0) { readingIn(unit: "K") }
}
Argument values may be integers, floating-point numbers, quoted strings, true, false, and null. They convert to the parameter type, so an integer literal binds to a double parameter, and strings bind to enums, Guid, TimeSpan, DateTime, and DateTimeOffset. Parameters with a default value may be omitted. Overloads resolve by which argument names are supplied.
Because a method name alone is the output key, two calls to the same method need aliases to coexist:
{ c: readingIn(unit: "C") f: readingIn(unit: "F") }
Argument binding is checked when the query is compiled, so an unknown method, a missing required argument, an unknown argument name, or a value of the wrong type throws FormatException there rather than mid-stream. An exception thrown by the method body itself surfaces on the stream, so keep queryable methods total — return null or an empty sequence rather than throwing.
Methods returning void, Task, or ValueTask, generic method definitions, and methods with ref/out parameters are never queryable.
Signals are not thread-safe, but a server model is touched by many concurrent requests. That is what the island in SignalIsland<T> means: the instance is bound to a Synchronization Context with single-threaded semantics, so work queued to it is serialized, one callback at a time, never overlapping. Your model needs no locks, even as a singleton serving concurrent connections.
This is single-threaded semantics, not a dedicated thread. Callbacks are pumped on the thread pool, so successive operations may run on different threads. What is guaranteed is that they never run concurrently. Don't rely on thread affinity or [ThreadStatic] state in your model.
This is why access goes through InvokeAsync or SwitchToIslandContextAsync rather than touching the model directly from wherever you happen to be. The instance itself is created lazily, on first use.
This project is licensed under the terms specified in the LICENSE file.
Contributions are welcome! Please feel free to submit issues or pull requests.

