Our content is free thanks to ag-Grid

ag-Grid is the industry leading JavaScript datagrid

ag-grid.com

Understanding @ngrx/component-store selector debouncing

Post Editor

@ngrx/component-store selectors have a debounce option that lets the state 'settle' before emitting. In this article we will demonstrate what this means in practice before walking through the code that makes this possible.

7 min read
post-image

Understanding @ngrx/component-store selector debouncing

@ngrx/component-store selectors have a debounce option that lets the state 'settle' before emitting. In this article we will demonstrate what this means in practice before walking through the code that makes this possible.

image
image
7 min read
7 min read

@ngrx/component-store selectors have a debounce option that lets the state 'settle' before emitting. In this article we will demonstrate what this means in practice before walking through the code that makes this possible.

NgRx Component Store

I have started using @ngrx/component-store to manage component state in my applications and I am loving it! In this post I am not going to explain how or why to use component-store. But if you want to know more, check out this video by Alex Okrushko.

Debounce Selectors

In this post I want to take a closer look at the {debounce} config option for the select method. Here is what the docs say about debouncing.

Selectors are synchronous by default, meaning that they emit the value immediately when subscribed to, and on every state change. Sometimes the preferred behavior would be to wait (or debounce) until the state "settles" (meaning all the changes within the current microtask occur) and only then emit the final value. In many cases, this would be the most performant way to read data from the ComponentStore, however its behavior might be surprising sometimes, as it won't emit a value until later on. This makes it harder to test such selectors.

At first I did not understand what this meant, so I built an example in Stackblitz to see what difference the flag made to a selector.

Demo Setup

We setup the component store as part of the AppComponent with a boolean toggle state.

interface AppCompState {
  toggle: boolean;
}

We then create two selectors on this toggle, one which we debounce and the other we don't.

update$ = this.select((s) => s.toggle, { debounce: false });

updateDebounced$ = this.select((s) => s.toggle, { debounce: true });

As the docs speak about selectors being synchronous, I have created two methods that watch the toggle state and then toggle it back. It's a bit like a naughty child turning the TV back on as soon as you turn it off!

The important difference is that we include a delay(0) in the second toggler to make the toggleState call asynchronous.

// Set up synchronous auto toggle back
this.select((s) => s.toggle)
  .pipe(take(1))
  .subscribe(() => this.toggleState());

// Set up asynchronous auto toggle back using delay(0)
this.select((s) => s.toggle)
  .pipe(delay(0), take(1))
  .subscribe(() => this.toggleState());

We trigger these actions by two different buttons in the demo app.

Synchronous Updates

When we click on Update Sync only the selector with debounce: false emits any values. Without debouncing the selector emits every changed toggle value.

Synchronou Updates

However, the selector that is debouncing emits no change. Why is this? The value of the toggle starts as true, gets set to false before being set back to true. This all happens synchronously, (in the same microtask) and is debounced by the debounceSync function. At the end of the microtask the value is still true and the selector does not emit. There is a distintUntilChanged in the select method that ensures this.

Asynchronous Updates

When we click on Update Async both selectors now emit values. The debounceSync function, as the name suggests, only debounces synchronous updates. Now the debounced selector emits every toggle change as each occurs in a different microtask.

Asynchronous Update

What does this all mean?

Performance

As the docs suggest using debounce: true can improve the performance of your app as the selectors will only emit new values at the end of a microtask. In our demo app this means the selector would not emit at all resulting in no further actions / re-rendering. Debouncing avoids unnecessary work.

Consistency

State emitted by a debounced selector may be more consistent or logically correct. For example, if the selector relies on multiple properties, which are interdependent, then we want them to have reached a valid state before the selector emits. Setting {debounce:true} ensures we do not emit all the intermediary values which could originate from a temporary 'invalid state'.

The debounceSync() code

The code is located here in NgRx and can also be found in rxjs-etc where it was originally created by Nicholas Jamieson. As it is published with the MIT license and all conditions are fulfilled by NgRx, they are able to avoid having an extra dependency but still benefit from this feature.

Selector Debouncing

Inside the select method of component-store is the following line of code. This takes our debounce config value and either adds the debounceSync() operator or returns the original source with no change.

this.stateSubject$.pipe(
     config.debounce ? debounceSync() : (source$) => source$,
     map(projector)
);

Below is the code for debounceSync. When I first looked at this I had no idea what was going on! So I reached for our trusty friend console.log and started experimenting in Stackblitz. You too can try this here.

export function debounceSync<T>(): MonoTypeOperatorFunction<T> {
  return source =>
    new Observable<T>(observer => {
      let actionSubscription: Subscription | undefined;
      let actionValue: T | undefined;
      const rootSubscription = new Subscription();
      rootSubscription.add(
        source.subscribe({
          complete: () => {
            console.log("COMPLETE", { actionSubscription, actionValue });
            if (actionSubscription) {
              observer.next(actionValue);
            }
            observer.complete();
          },
          error: error => {
            console.log("ERROR", { actionSubscription });
            observer.error(error);
          },
          next: value => {
            console.log("NEXT", { actionSubscription, value });
            actionValue = value;
            if (!actionSubscription) {
              actionSubscription = asapScheduler.schedule(() => {
                console.log("ASAP", { actionSubscription, actionValue });
                observer.next(actionValue);
                actionSubscription = undefined;
              });
              rootSubscription.add(actionSubscription);
            }
          }
        })
      );
      return rootSubscription;
    });
}

Demo Setup

To run the code I setup two streams. One uses interval to be asynchronous and the other uses from to run synchronously.

console.warn("Before interval");

interval(1).pipe(
  debounceSync(),
  take(3)
).subscribe(val => console.log("interval", val));

console.warn("Before from");

from([10, 20, 30]).pipe(
  debounceSync()
).subscribe(val => console.log("fromArray", val));

console.warn("After From");

This along with the logging I added into the debounceSync() function gives the following output.

Console Output

The first thing to notice is that the interval code does not appear between the "Before interval" and "Before from". This is because this interval code is running asynchronously. What's more interesting is that it appears right at the end, even after our fromArray code! This demonstrates how synchronous code is executed before asynchronous and that Observables can run synchronously.

This behaviour comes down to the Event Loop. If you are not familiar with the Event loop, it would be worth reading this fantastic article to learn more before carrying on. (It even has animations to demonstrate it!)

The Differences

If we compare the two outputs, we get an idea of what the code is doing. On the left we have the synchronous from stream and on the right the asynchronous interval stream.

Log output, asynchronous code logs ASAP

In both cases the first value comes in, and as the actionSubscription is undefined, it creates one. This actionSubscription uses the asapScheduler to register its call-back method.

asapScheduler: Perform task as fast as it can be performed asynchronously

In the call-back it passes the value to the next observer in the chain and also clears the current actionSubscription.

next: value => {
   console.log("NEXT", { actionSubscription, value });
   actionValue = value;
   if (!actionSubscription) {
       actionSubscription = asapScheduler.schedule(() => {
           console.log("ASAP", { actionSubscription, actionValue });
           observer.next(actionValue);
           actionSubscription = undefined;
       });
       rootSubscription.add(actionSubscription);
   }
}

Synchronous

With a fully synchronous event stream the actionSubscription never gets to fire. This is because all the synchronous events are on the call stack and these must be executed before the asapScheduler can run. So any new values just update the internal actionValue without being passed to the next observer.

As we are using the from operator it completes synchronously after the 3 values are emitted and before the asapScheduler has the chance to run. This is why the complete method checks for the existence of an outstanding actionSubscription and if it is defined, it then emits the current actionValue to the observer before completing. Without this check no values would be emitted.

complete: () => {
    if (actionSubscription) {
        observer.next(actionValue);
    }
    observer.complete();
},

Note with the synchronous code only the last value actually gets emitted. This is why this function is called debounceSync because it debounces synchronous events.

Asynchronous

With the asynchronous events we see that every value gets emitted via the actionSubscription. This is because the asapScheduler will get the opportunity to run between the asynchronous interval events. Each time the asapScheuler fires the current actionSubscription is cleared. So the next value will also setup a new actionSubscription to enable that to be emitted via the call-back too.

Summary

The debounceSync() operator works by recording each new event in a local variable actionValue which will only be emitted when the observable completes or after the call stack has been cleared (all synchronous events have completed). The asynchronous call-back uses the asapScheduler to keep any introduced delay to a minimum.

After writing this article the following section in the docs makes a lot more sense to me.

Sometimes the preferred behaviour would be to wait (or debounce) until the state "settles" (meaning all the changes within the current microtask occur) and only then emit the final value.

Multiple Selectors

At this point it is worth noting that as long as all the events are synchronous you may find that debouncing enables feedback loops in selectors to stabilize before emitting. Our example has not covered this use case, but it is the main advantage of debouncing selectors as I see it.

Alternative Implementation

This function is equivalent to using the debounceTime operator with a delay value of 0 setup with the asapScheduler.

debounceTime(0, asapScheduler)

Alex Okrushko, from the NgRx core team, said that this was the first thought for synchronous debouncing however this has the side effect of creating extra timers that are not required for this specific behaviour.  Nicholas Jamieson  suggested to use this custom operator, debounceSync which he created to ensure the best possible performance for the NgRx Component Store.

Further Reading

I learnt a lot about this feature from the NgRx Discord channel which you should definitely join if you have not already!

We have seen how component store is benefitting from debouncing selectors but what if you could apply the same kind of debouncing to your entire Angular app? This is exactly the kind of work that is happening right now with event coalescing and the ramifications could be huge for application performance.

Discuss with community

Share

About the author

author_image
Stephen Cooper

Stephen is a Senior Engineer at G-Research specialising in web technologies. When not coding you will find him out and about with his three little explorers.

author_image

About the author

Stephen Cooper

Stephen is a Senior Engineer at G-Research specialising in web technologies. When not coding you will find him out and about with his three little explorers.

About the author

author_image
Stephen Cooper

Stephen is a Senior Engineer at G-Research specialising in web technologies. When not coding you will find him out and about with his three little explorers.

THIS AD MAKES CONTENT FREE

Make Angular CLI faster

Learn how

Featured articles