Angular Signals. What does it aim to solve?

Priyank Bhardwaj
7 min readApr 28, 2023

--

Ever since the first PR where Signals were hinted to be introduced, the angular community is at unrest. Why? Let’s dig.

Where it all started

Automated change detection was a decision that came out during initial days of Angular. The idea was that view state can live and mutate anywhere in the application. Be it simple Boolean value in a component or an array object or list nested deeply in an object of some global service. The change detection mechanism in Angular will detect the changes and update the DOM.

It gave developers freedom to structure the application in whatever way made sense to them. Extract complicated logic into helpers or services. Flow of data was never a concern, no matter where we put and change our state, Angular’s global change detection will check the changes and update those values to DOM.

Then what were the challenges? Why are we moving when all parts of the machine are at their places?

There are 2 main issues with the current change detection mechanism

Time bound checking of changes

Angular depends on ZoneJS for automatic change detection. Now ZoneJS is a libary that binds native browser APIs and notifies the framework whenever there is a event trigger. This means before anything can happen in Angular side, ZoneJS needs to be load and run, this is the first tradeoff.

This doesn’t look much but it means Angular kind of comes with built in performance deficit compared to other frameworks.

Another problem here is the number of events that are bound to DOM are significant( event listeners, setTimeouts, Promises etc). This makes Angular overcheck and do unnecessary work just for detecting changes to the model.

Single direction flow of data (unidirectional data flow)

Angular change detection works on the component Tree and that is a tedious process. It has to ensure the application stay performant even with a greater number of components in DOM. Not to mention efficiency cannot be compromised.

Angular’s change detection runs in DOM order, and checks every binding between the model and the DOM only once. This traversal process and checking the changes over the time and years has come out to be having some downsides.

The major downside what has been highlighted is lack of bi-directional change detection. Let’s do this with an example.

@Component({
selector: 'app-child',
standalone: true,
imports: [CommonModule],
template: `
<h1>This is Child</h1>
`,
})
export class ChildComponent implements OnChanges {
@Input()
public modified = false;

private parent = inject(ParentComponent);

public ngOnChanges() {
if (this.modified) {
this.parent.text = 'from child';
}
}
}

@Component({
selector: 'my-app',
standalone: true,
imports: [ChildComponent],
template: `
{{text}}
<app-child [modified]='true'/>
`,
})
export class ParentComponent {
text = 'from parent';
}

This will result in the most famous error which still confuses newbies

ExpressionChangedAfterItHasBeenCheckedError

This is a simple scenario and a single flow of operation and before updating the parent Angular has already checked it’s value. There would be numerous number of instances where the flow of data is not always unidirectional and it simply doesn’t fit top to bottom approach of data flow. Of course there are work arounds like using a promise that kind of is a hacky workaround and is not a sound solution to this problem.

Then was this problem left as is?

Current performance improvement startegy.

There are couple of ways the performance quotient has been targeted earlier. One of the common ways is the OnPush change detection strategy and Angular handling the subscription management using AsyncPipe.

The OnPush change detection strategy excludes the component marked as OnPush and all its children from the default change detection mechanism. As per the article written by Angular University

Use the CheckOnce strategy, meaning that automatic change detection is deactivated until reactivated by setting the strategy to Default (CheckAlways). Change detection can still be explicitly invoked. This strategy applies to all child directives and cannot be overridden.

What does this indicate? If one of the components high up in the tree is OnPush then all the other components will have to support OnPush as well. Thus UI library authors have to build their library OnPush compatible.

RxJS

RxJS can describe the complete asynchronous behavior of each feature in its declaration. RxJS operators describe actual behavior. If you don’t use them explicitly, you will be redefining them implicitly in scattered, repetitive logic coupled to business logic.

RxJS is extensively used in Angular. For example HttpClient, which exposes the response of HTTP call through an observable. Another one is FormControl which has a property called valueChanges which is a multicasting observable emitting an event every time the value of the control changes.

A combination of OnPush, AsyncPipe and Observable has become a major pattern to build perfomant Angular apps. But RxJS is not the solution. Why ?

Angular uses RxJS for events, to expose stream of events more specifically and these streams do not have a current value. Let’s say we have a plathora of click events being triggered on an Angular app. Can you ask what is the current click event? Nope. Instead you’ll ask what is the recent click event. That is where RxJS BehaviorSubject came in. You see a Behavior have a current value and you’ll always be able to ask, what is this behavior’s current value?
We do acknowledge BehaviorSubject can hold up a value and address the issue we saw earlier. But as soon as we pipe it through an operator, like map() join() etc, it becomes an observable and loses the fundamentals of being a behavior that it should always hold a current value.

Then does Signal Solve this problem?

Angular team wanted to introduce a reactive primitive that they can integrate with Angular’s templating engine to notify the framework when the value bound to the view changes and needs to be updated in the DOM. Not to mention glitch free execution is a major goal of such primitive. What this means is blocking the user code to see an intermediate state where only some reactive elements have been updated i.e. as soon as you run the element every source should be updated.

Let’s see one example to exactly understand what we mean with glitch-free.

@Component({
selector: 'got',
standalone: true,
imports: [CommonModule],
template: `
<p>{{combinedStatement$ | async}}</p>
<p>{{counter}}</p>

<button (click)="changeStatement()">Who knows nothing?</button>
`,
})
export class GOTComponent {
public firstWord = new BehaviorSubject('You Know');
public secondWord = new BehaviorSubject('Nothing');

public counter = 0;

public combinedStatement$ = combineLatest([this.firstWord, this.secondWord]).pipe(
tap(() => {
this.counter++;
}),
map(([firstWord, secondWord]) => `${firstWord} ${secondWord}`)
);

public changeStatement() {
this.firstWord.next('Jon');
this.secondWord.next('Snow');
}
}
  • We have two BehaviorSubjects having 2 separate words.
  • Combine them into an observable combinedStatement$ that will concatenate the two.
  • Declare a counter that increments every time combinedStatement$ emits.
  • We have a changeStatement() function that triggers and modifies both the words at the same time.

Before the click event we had.

Once we click the UI updates to:

Isn’t it odd? We clicked only once and observable emitted twice, that explains the counter value increasing with a value 2. There is no doubt an intermediate state that was so fast it wasn’t visible and trackable, and the final value emitted instantly replaced that intermediate state.

We can get rid of this duplicate execution for sure, by adding debounceTime to our observable.

 public combinedStatement$ = combineLatest([this.firstWord, this.secondWord]).pipe(
debounceTime(0),
tap(() => {
this.counter++;
}),
map(([firstWord, secondWord]) => `${firstWord} ${secondWord}`)
);
glitch free execution

Exact same functionality with Signal primitive

export class GOTComponent {
public firstWord = signal ('You Know');
public secondWord = signal('Nothing');

signalCounter = 0;

combinedStatement = computed(() => {
this.signalCounter++;
console.log('signal statement change');
return `${this.firstWord()} ${this.secondWord()}`;
});

public changeStatement() {
this.firstWord.next('Jon');
this.secondWord.next('Snow');
}
}

Even if we spam the “Who knows nothing?” button, the count won’t go up rather sticks to value 2. This same functionality can be done in RxJS by using another operator distinctUntilChanged.

Does this mean RxJS is gonna be deprecated in future versions? Nope.

There are numerous operations and functionalities that RxJS brings to plate which signals cannot. For example it lets developers declare asynchronous streams, listen to input change events, take them as params to HTTP calls and then map the response to the model we need to view all in one shot.

The take away here is both RxJS and Signals are reactive by nature. They both solve different issues, complement each other and together they will be extremely powerful in building complex Angular apps.

Now since we have the problem cleared, let’s see what this new reactive primitive called signals brings to the plate. In detail of course.

--

--

Priyank Bhardwaj
Priyank Bhardwaj

Written by Priyank Bhardwaj

Tech aficionado weaving digital wonders with Angular, JavaScript, .NET, NodeJS. Unleashing innovation, one line of code at a time.

No responses yet