Reactive Programming: A Timed Popup Component with RxJS

Reactive programming is all about streams. It involves combining streams, filtering streams, and transforming streams. In reactive programming, the application does not need to pull for data, but it is notified whenever a new event occurs.

A few months ago, I wrote a post on reactive programming in WPF using ReactiveUI. Recently, we’ve been working on an Angular project and have been using the RxJS library for reactive programming in JavaScript. RxJS follows the reactive programming pattern of observers consuming data by subscribing to the observables. It also implements a set of operators such as map, filter, and reduce, which (generally speaking) take an observable, mutate it in some way, and then return the mutated observable.

These operators have been useful for simple things such as mapping a value to a Boolean, as well as more complex things such as computing the total price of items ordered (thanks to the reduce operator).

The most complex thing I’ve implemented with RxJS (so far!) is a popup component that displays a notification for a specified number of seconds and then disappears. The one caveat is that if the user is hovering on the notification, it does not disappear. Because the notification component receives a stream of notifications from its parent, I wanted to display each notification for a given amount of time before dismissing it.

The Debounce Operator: Dismissing the Latest Notification

At first, the debounce operator seemed like a good solution. This operator is often used for search bar input or a button click where an event should only be triggered a certain amount of time after a user stops doing an action.

I created a second stream, the “debounced stream,” which receives a value following a specified time period after the last notification. When a notification enters the debounced stream, I know to hide that notification.

So a notification is either replaced by another notification (e.g. N2 is replaced by N3) or hidden after the specified interval (N1 is displayed until the end of the interval, then dismissed when N1 enters the debounced stream).  The code looks something like this:


    this.notification$
      .pipe(
        tap(() => {
          this.showNotification = true;
        }),
        debounceTime(this.notificationDisplayTime),
        tap(() => {
          this.showNotification = false;
        })
      )

The Merge and Map Operator: Is the User Hovering?

To determine whether or not the user is hovering, I created another observable based on “mouse over” and “mouse out” events. This observable mapped events to a stream of Boolean values–true if the user is hovering, false if the user is not hovering.

When the two Boolean streams are merged, they create an observable that emits a value whenever the hover state changes. The startWith operator is there simply to set the initial value of isHover as false (not hovering).


this.isHover$ = merge(
      fromEvent(this.smallContainer.nativeElement, 'mouseover').pipe(
        map(() => true)
      ),
      fromEvent(this.smallContainer.nativeElement, 'mouseout').pipe(
        map(() => false)
      )
    ).pipe(startWith(false));

I now have an observable that tells me if the user is hovering or not and an observable that controls whether or not the notification is displayed. Now, I need to get these two observables to work together in determining whether or not to display the notification.

The SwitchMap Operator to the Rescue: Switching between Observables

Though the debounce operator works fine for displaying a notification for a specified amount of time, it is difficult to combine with the hover observable.  I could use the combineLatest operator, but how would I know which notification the latest hover value was for?

For example, if the user had hovered over a notification, the latest value from the hover stream would be true, but there might be a new notification that the user had not yet hovered on. I could try to keep track of which message the hover notification was tied to and yada yada, but isn’t there a better solution than trying to sort that out?

Instead of keeping track of multiple notifications and their hover events at the same time, it would be easier to ditch the observable for the previous notification and just get a new observable for each new notification. Then, the observable would only ever be responsible for deciding to show or not show a specific notification. There is a RxJS observable that does exactly this: switchMap gets rid of the old observable and switches to a new observable.

With switchMap, I could switch to a new observable each time a notification is received.  That new observable would have to worry about only itself when determining whether or not to show the notification based on the interval time and the hover state. Thus, we can eliminate confusion by just throwing away the old observable when we get a new one.

As shown by the diagram, each notification now creates a new observable. That new observable combines the interval stream (the stream of the notifications delayed by an interval) with the hover state stream. The values in this new stream can then be used to determine whether or not to show the notification.

In this case, if the user is not hovering and the specified interval has passed, the notification is hidden. When a new notification comes in, there will be an entirely new observable, so the notification will be shown again.


    this.notification$
      .pipe(
        tap(() => {
          this.showNotification = true;
        }),
        switchMap(popup =>
          combineLatest(interval(popup.displayMS), this.isHover$).pipe(
            filter(([timeout, hover]) => {
              return hover === false;
            })
          )
        ),
        tap(() => {
          this.showNotification = false;
        })
      )

Sample Code

Thanks for stopping by!
You can check out my code on GitHub or StackBlitz.

Conversation
  • Great article! One small hint – you can replace:

    `map(() => true)`

    with:

    `mapTo(true)`

  • rvpanoz says:

    awesome article! thanks for sharing :)

  • Comments are closed.