Angular Signals Ported to .NET and C#





5.00/5 (2 votes)
Description of a library that allows to use a porting of Angular Signals in .NET MVVM Frameworks
- Github: GitHub - SignalsDotnet
- Nuget: GitHub - fedeAlterio/SignalsDotnet
Understanding the Problem
Let's suppose we are creating a user registration view in a generic XAML based UI Framework using the MVVM pattern. In practice, we need to create a UserRegistrationView
and a UserRegistrationViewModel
.
UserRegistrationViewModel
public class UserRegistrationViewModel
{
public string Name { get; set; }
public string Surname { get; set; }
public string FullName => $"{Name} {Surname}";
}
UserRegistrationView
<StackPanel HorizontalAlignment="Stretch"
VerticalAlignment="Center">
<TextBox Text="{Binding Name, Mode=TwoWay}" />
<TextBox Text="{Binding Surname, Mode=TwoWay}" />
<TextBlock Text="{Binding FullName}"/>
</StackPanel>
The code above contains two bugs:
- Whenever the user writes to the
Name
orSurname
TexBox
es, theName
andSurname
properties are updated correctly, however there is nothing that tells the UI that theFullName
property has changed, and so theTextBlock
binded to it will not update, and it will always be empty. - Similarly, if we set the
ViewModel
Name
andSurname
properties by code, the UI will not be updated.
Of course, this problem can be solved in several different ways.
First Solution: Manually Raise the PropertyChanged Event in the Setter
public class UserRegistrationViewModel : ViewModelBase
{
string _name;
public string Name
{
get => _name;
set
{
if(Set(ref _name, value))
RaisePropertyChanged(nameof(FullName));
}
}
string _surname;
public string Surname
{
get => _surname;
set
{
if(Set(ref _surname, value))
RaisePropertyChanged(nameof(FullName));
}
}
public string FullName => $"{Name} {Surname}";
}
This is a classical solution. In the Name
property setter, we set the backing field and we raise the PropertyChanged
event for both the Name
and the FullName
properties.
What's Good
- Solves this specific problem
What's Bad
- There is a lot of added boilerplate code that does not represent our business logic.
Name
andSurname
properties now reference theFullName
even if there is no reason at all.- We can do this approach only if we can modify the setter of the properties, and that could be a problem if we depend on properties of other classes.
- We can't do something similar if we depend on elements of an
ObservableCollection
.
Second Solution: ReactiveX & ReactiveUI
public class UserRegistrationViewModel : ViewModelBase
{
public UserRegistrationViewModel()
{
this.WhenAnyValue(@this => @this.Name, @this => @this.Surname)
.Subscribe(_ => RaisePropertyChanged(nameof(FullName)));
}
string _name;
public string Name
{
get => _name;
set => Set(ref _name, value);
}
string _surname;
public string Surname
{
get => _surname;
set => Set(ref _surname, value);
}
public string FullName => $"{Name} {Surname}";
}
What's Good
- Solves the problem.
Name
andSurname
Properties are no more dependent onFullName
.- We can use all ReactiveX operators on our properties!
- Works also for properties we don't own or nested properties (with some edge cases that should be kept in mind).
- A similar approach can be done also for
ObservableCollection
s.
What's Bad
- There is a small setup that has to be done in the constructor that has to be written manually. If, for example, we'd like to make the
FullName
property depend on a third property, we must remember to add it also on theWhenAnyValue
. - In cases more complex than this, the setup requires some not trivial knowledge of reactive operators, and all their edge cases
Note: The solution above is not the approach suggested by ReactiveUI. The FullName
property should indeed be computed using a Select
operator on the WhenAnyValue
and should be converted to an ObservableForPropertyHelper
. This works fine in simple cases but
could be messy if the property depends on elements contained in an ObservableCollection
. In my opinion, the pure functional approach has its drawbacks in some cases.
Signals
public class UserRegistrationViewModel
{
public UserRegistrationViewModel()
{
Fullname = Signal.Computed(() => $"{Name.Value} {Surname.Value}");
}
public Signal<string?> Name { get; } = new();
public Signal<string?> Surname { get; } = new();
public IReadOnlySignal<string?> Fullname { get; }
}
<StackPanel HorizontalAlignment="Stretch"
VerticalAlignment="Center">
<TextBox Text="{Binding Name.Value, Mode=TwoWay}" />
<TextBox Text="{Binding Surname.Value, Mode=TwoWay}" />
<TextBlock Text="{Binding Fullname.Value}"/>
</StackPanel>
What is a Signal?
- A
Signal<T>
is a wrapper for aT
. It contains a PropertyValue
that returns the actual value of theT
. When theValue
is set, thePropertyChanged
event is raised. This is the property the UI should be binded to. - A
CollectionSignal<TObservableCollection>
is a Signal whichValue
is anObservableCollection
(or more generally, something that implements theINotifyCollectionChanged
). It listens for changes of both the propertyValue
, and modifications of theObservableCollection
- A computed
Signal
is a ReadonlySignal
that will return the value computed by a function. It will automatically detect changes of all thesignal
s accessed inside the body of the function (works also forCollectionSignal
), and when one of them changes, the function is recomputed and thePropertyChanged
is automatically raised for us. TheValue
cannot be set manually. - All signals implement
IObservable<T>
, so we can still apply ReactiveX operators if we need them. - Computed Signals subscribe to other signals weakly, to avoid memory leaks.
A Complex Example
Consider this scenario. We have some cities with some houses, each house has some rooms, each room has some people inside, each person has an age. Nothing here is immutable, so people can go in and out rooms, houses can be built and destroyed, cities can appear and disappear, and people can grow old and all collections can even be null
.
Problem: Represent in the UI the youngest people with their city, house, room and age.
Solution with Signals
public class Person
{
public Signal<int> Age { get; } = new();
}
public class Room
{
public CollectionSignal<ObservableCollection<Person>> People { get; } = new();
}
public class House
{
public CollectionSignal<ObservableCollection<Room>> Roooms { get; } = new();
}
public class City
{
public CollectionSignal<ObservableCollection<House>> Houses { get; } = new();
}
public class YoungestPeopleViewModel
{
public YoungestPeopleViewModel()
{
YoungestPerson = Signal.Computed(() =>
{
var people = from city in Cities.Value.EmptyIfNull()
from house in city.Houses.Value.EmptyIfNull()
from room in house.Roooms.Value.EmptyIfNull()
from person in room.People.Value.EmptyIfNull()
select new PersonCoordinates(person, room, house, city);
var youngestPerson = people.DefaultIfEmpty()
.MinBy(x => x?.Person.Age.Value);
return youngestPerson;
});
}
public IReadOnlySignal<PersonCoordinates?> YoungestPerson { get; set; }
public CollectionSignal<ObservableCollection<City>> Cities { get; } = new();
}
public record PersonCoordinates(Person Person, Room Room, House House, City City);
How Does It Work?
Basically, the getter (not the setter!) of the Signals
property Value
raises a static
event that notifies someone just requested that signal. This is used by the Computed signal before executing the computation function. The computed signals register to that event (filtering out notifications of other threads), and in that way they know, when the function returns, what signals have been just accessed.
At this point, it subscribes to the changes of all those signals in order to know when it should recompute again the value. When any signal changes, it repeats the same reasoning and tracks what signals are accessed before recomputing the next value (etc.).
History
- 27th December, 2023: Initial version