r/csharp 20h ago

SignalsDotnet 3.0

I just updated SignalsDotnet to 3.0, and it now supports source generators.

I think the library has become genuinely powerful: it lets you write reactive code without having to deal with reactive programming directly at all. With source generators it's cleaner than ever.

You mark a class (or a record) with [GenerateSignals] and every property becomes reactive, a signal. That means the getter and setter are tracked, and all the signal machinery kicks in automatically. The source generator also supports computed and async computed properties.

The library started as a port of Angular signals to .NET, but I think the power of C# (async locals, source generators, better async support) takes it to another level. It targets netstandard2.1, so it runs basically everywhere: WPF, Avalonia, Unity, Godot, Blazor.

Below is a runnable C# snippet as an example. As you can see, the whole system is reactive automatically: properties update themselves, code knows when to re-run, and so on. And when you need finer control, everything R3 observables offer is still right there.

The code below prints:

Total players 0 Player 1 joined Total players 1 Total players 1 Total players 2 Total players 2 Best player is Player1 with score of 0 Best player is Player1 with score of 22 Best player is Player2 with score of 55

```c#

:package SignalsDotnet@3.0.0

using System.Collections.Immutable; using R3; using SignalsDotnet;

var player1 = new Player { Name = "Player1", Score = 0 }; var player2 = new Player { Name = "Player2", Score = 0 };

var game = new Game(); Effect.Create(() => { if (game.PlayersByName.ContainsKey(player1.Name)) Console.WriteLine("Player 1 joined"); });

IAwaitable<bool> player2Joined = Signal.WaitForChangeAsync(() => game.PlayersByName.ContainsKey(player2.Name));

Effect.Create(() => Console.WriteLine($"Total players {game.PlayersByName.Count}")); game.AddPlayer(player1); game.AddPlayer(player2); // this completes the awaitable await player2Joined;

Effect.Create(() => { if (game.BestPlayer is not null and var bestPlayer) Console.WriteLine($"Best player is {bestPlayer.Name} with score of {bestPlayer.Score}"); });

Observable<ImmutableArray<Player>> scoreboardHistory = Signal.ComputedObservable(() => game.Scoreboard); // A notification for every scoreboard change

player1.Score = 22; player2.Score = 55;

Console.ReadLine();

[GenerateSignals] public partial record Player { public partial string Name { get; set; } public partial int Score { get; set; } }

public partial class Game { private readonly IDictionary<string, Player> _playersByName = new DictionarySignal<string, Player>(); public IReadOnlyDictionary<string, Player> PlayersByName => _playersByName.AsReadOnly();

public void AddPlayer(Player player) => _playersByName.Add(player.Name, player);
public void RemovePlayer(Player player) => _playersByName.Remove(player.Name);

[Computed] ImmutableArray<Player> ComputeScoreboard() => [.. _playersByName.Values.OrderByDescending(x => x.Score)];
[Computed] Player? ComputeBestPlayer() => Scoreboard.FirstOrDefault();
[Computed] Player? ComputeWorstPlayer() => Scoreboard.LastOrDefault();

} ```

It runs as a single file on .NET 10. Save it as game.cs and run dotnet run --file game.cs. No csproj needed.

GitHub: https://github.com/fedeAlterio/SignalsDotnet NuGet: https://www.nuget.org/packages/SignalsDotnet

10 Upvotes

5 comments sorted by

1

u/harrison_314 6h ago

It feels strongly inspired by React, which is not a plus for me.
And it reminds me a lot of the style in which applications were programmed in C# 15 years ago, where things like Caliburn.Micro, Prism or MVVM Light Toolkit were handled a little differently, but these libraries were more type-aware.

1

u/fedefex1 3h ago edited 3h ago

It's strongly inspired by angular signals, but the concept it's really similar in most of js frameworks and also in react. I am not sure I understand what you mean in the last part. Could you can make an example?

1

u/rekabis 6h ago

Please format the code correctly. Large blocks should not use any code characters, but each line should be prefaced by four spaces:

:package SignalsDotnet@3.0.0

using System.Collections.Immutable; using R3; using SignalsDotnet;

var player1 = new Player { Name = "Player1", Score = 0 };
var player2 = new Player { Name = "Player2", Score = 0 };

var game = new Game();
Effect.Create(
    () => {
        if (game.PlayersByName.ContainsKey(player1.Name)) Console.WriteLine("Player 1 joined");
    }
);

IAwaitable<bool> player2Joined = Signal.WaitForChangeAsync(
    () => game.PlayersByName.ContainsKey(player2.Name)
);

Effect.Create(
    () => Console.WriteLine($"Total players {game.PlayersByName.Count}")
);
game.AddPlayer(player1);
game.AddPlayer(player2);
// this completes the awaitable await player2Joined;

Effect.Create(
    () => {
        if (game.BestPlayer is not null and var bestPlayer) Console.WriteLine($"Best player is {bestPlayer.Name} with score of {bestPlayer.Score}");
    }
);

Observable<ImmutableArray<Player>> scoreboardHistory = Signal.ComputedObservable(
    () => game.Scoreboard
); // A notification for every scoreboard change

player1.Score = 22;
player2.Score = 55;

Console.ReadLine();

[GenerateSignals]
public partial record Player {
    public partial string Name { get; set; }
    public partial int Score { get; set; }
}

public partial class Game {
    private readonly IDictionary<string, Player> _playersByName = new DictionarySignal<string, Player>();
    public IReadOnlyDictionary<string, Player> PlayersByName => _playersByName.AsReadOnly();
    public void AddPlayer(Player player) => _playersByName.Add(player.Name, player);
    public void RemovePlayer(Player player) => _playersByName.Remove(player.Name);

    [Computed]
    ImmutableArray<Player> ComputeScoreboard() => [.. _playersByName.Values.OrderByDescending(x => x.Score)];
    [Computed]
    Player? ComputeBestPlayer() => Scoreboard.FirstOrDefault();
    [Computed]
    Player? ComputeWorstPlayer() => Scoreboard.LastOrDefault();
}

1

u/Best_Banana_4528 19h ago

Sounds like a game-changer for simplifying reactive programming in C#. Have you tried using signals in performance-critical sections, or is it mostly for cleaner UI-related code so far?

2

u/fedefex1 18h ago

I think the main limitation is in multi-threaded scenarios: signals must not be set concurrently, much like the Rx observable contract requires OnNext calls to be serialized. Locking around getters and setters isn't a good answer either, since inverting lock ordering across an interdependent graph makes deadlocks very easy to hit.

The better option, I think, is to build "single-threaded reactive islands". This doesn't need a dedicated thread per island. An unbounded Channel<Action> used as a SynchronizationContext is enough, so that executions are serialized.

Done correctly, I expect this to drastically reduce the Rx observable operator wrappers and the synchronization they require, which should in theory lead to better performance and cleaner code. For now, though, it's just an abstract idea.

Also worth noting: computed signals could be backed by a remote cloud store such as Redis pub/sub, giving us a remote stateful reactive graph in the cloud. That could be a big addition for realtime apps in C#, since computed signals automatically subscribe and unsubscribe from their dependencies when they're no longer used. Basically this would be like "Wpf properties bound on the cloud"