Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Angular Signals Ported to .NET and C#

5.00/5 (3 votes)
26 Dec 2023CPOL4 min read 8.9K  
Description of a library that allows to use a porting of Angular Signals in .NET MVVM Frameworks
The discussion revolves around implementing a User Registration View in a generic XAML-based UI Framework using the MVVM pattern. It addresses two bugs in the code related to updating the UI when properties change, presenting classical solutions involving manual property change notifications and a more complex solution using ReactiveX, along with a novel approach using Signals, exploring their benefits and drawbacks. Additionally, it extends into a complex example showcasing the usage of Signals to represent dynamic data dependencies in UI.

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

C#
public class UserRegistrationViewModel
{
    public string Name { get; set; }
    public string Surname { get; set; }
    public string FullName => $"{Name} {Surname}";
}

UserRegistrationView

XML
<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 or Surname TexBoxes, the Name and Surname properties are updated correctly, however there is nothing that tells the UI that the FullName property has changed, and so the TextBlock binded to it will not update, and it will always be empty.
  • Similarly, if we set the ViewModel Name and Surname 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

C#
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 and Surname properties now reference the FullName 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

C#
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 and Surname Properties are no more dependent on FullName.
  • 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 ObservableCollections.

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 the WhenAnyValue.
  • 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

C#
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; }
}
XML
<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 a T. It contains a Property Value that returns the actual value of the T. When the Value is set, the PropertyChanged event is raised. This is the property the UI should be binded to.
  • A CollectionSignal<TObservableCollection> is a Signal which Value is an ObservableCollection (or more generally, something that implements the INotifyCollectionChanged). It listens for changes of both the property Value, and modifications of the ObservableCollection
  • A computed Signal is a Readonly Signal that will return the value computed by a function. It will automatically detect changes of all the signals accessed inside the body of the function (works also for CollectionSignal), and when one of them changes, the function is recomputed and the PropertyChanged is automatically raised for us. The Value 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

C#
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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)