Maintain Self-resetting State in Your Observable Streams using the RxJS scan Operator

Rares Matei
InstructorRares Matei
Share this video with your friends

Social Share Links

Send Tweet
Published 5 years ago
Updated 4 years ago

Our manager, unaware of how much our functional reactive approach has kept us delivering these features on time and bug-free, decides they want a small new feature added to the spinner before the next release: a percentage indicator, showing how many tasks have finished out of the total started since the spinner was last shown. So in this lesson we will be looking at combining the existing observables that gives the up to date count of tasks, together with a more advanced use of the scan operator, to create a stream that tells us the total count of tasks that have been launched since the spinner was last shown, and how many of them have completed. We’ll also take advantage of scan’s disposal behavior to reset its state when the spinner hides.

Instructor: [00:00] Our virtual manager congratulates us on the quick turnaround of the last two feature requests but then suggests another improvement we could make. Users want to see a percentage progress indicator of how many tasks are left to display in the background.

[00:15] We can kind of imagine that in our head, right? We see a spinner here. As tasks are going down, a percentage will slowly increase up to 100 percent, at which point the spinner will hide. As we've seen with previous requirements, it helps to understand the problem before diving into writing code.

[00:34] Imagine an empty array. Then, suddenly, a task starts, so the spinner shows. There's no point in showing 0percent when nothing has loaded. It doesn't help or offer any important info. Then a few more tasks start as well. We now have four loading tasks. 0percent of them have finished. Then one of them finishes. 25 percent of them have finished. Then another finishes. 50 percent are complete now.

[01:00] Then another, 75 percent complete. Just before the last one has a chance to complete as well, a new one starts. That brings our total loaded count back a bit to 60 percent out of a total five. Another one starts. Now only 50 percent out of the total have completed. Now they start completing again and again. Now we have five completed out of a total of six, which is 83 percent.

[01:26] How do we show this percentage number on the screen? It turns out that our initLoadingSpinner function takes two parameters, a total and a completed. Let's mock them up here. As in our visualization, the total will be six and the completed will be five. Our library, based on these two, will calculate the percentage completed out of the total and display it on the screen.

[01:55] If we go back to the app and launch a task, we can see it displaying 83 percent, but this is static. We need to make this number dynamic based on how many tasks are actively loading and have completed since we started showing it.

[02:11] To make it easier and allow us to pass in these two numbers, I'll wrap this observable in a function that accepts them as arguments. Where can we get these two numbers from? If we go back to visualizing this, if our total is the length of the array, then it can be calculated from how many have completed and how many are still yet to load.

[02:35] To solve our problem, we just need to know how many have completed and how many are still loading. To get how many are loading, we already have an observable we've built earlier, the currentLoadCount. That's why it's good to keep abstractions that make sense on their own. You don't know when you'll need them later.

[02:55] Let's look at what happens when our currentLoadCount observable emits. When the number of loading tasks goes up, our total goes up as well. When the currentLoadCount goes down, that's a sign that a task has completed, so we increase completed by one.

[03:12] When it goes down again from three to two and then to one, we keep increasing completed. When it starts going up again, we leave completed alone. If you watch total, it's always going to reflect the sum of these two. When number of loading tasks goes down again, completed goes up.

[03:31] Let me define a loadStats observable that will take the currentLoadCount. Because we need to keep track of state, I'll pipe it to a scan which will accept a function. I'll refer to the previous state as loadStats. The new loadingCount will be loadingUpdate.

[03:52] What kind of state will this return? We need to know total and completed to pass to our spinner. I'll initialize them with 0for now. To calculate completed, we need to know whether the loadingCount has gone up or down. For that, we need to keep track of the previous loadingCount as well.

[04:12] The loads went down if the new loadingUpdate is smaller than the previous loading. The current value of completed tasks, if loads went down, will be the previous value + 1. Otherwise, it stays the same.

[04:31] Completed will be this one. Previous loading will become the new loadingUpdate. Total will be completed plus the loadingUpdate. I'll also define the initialState. They will all start from 0What happens when all tasks complete, and we're at 100 percent again? We want to reset the total and the completed back to 0again as well, so they are ready for the next time we need to show the spinner and start calculating the percentage.

[04:59] One solution will be to add a special condition in our scan so that whenever loading update is 0we also reset completed and total back to 0It turns out we don't even have to think about resetting the state.

[05:13] I'll show you what I mean by creating an observable called spinnerWithStats which will listen to the loadStats and switch to showing a spinner with the correct percentage anytime our stats change.

[05:30] Now, instead of showing the simple spinner, we'll show a spinnerWithStats. Remember that scan keeps track of its state only while it has subscribers? Whenever the spinner needs to be shown, we'll switch to this observable which internally will initialize the new state for scan and will start tracking the percentage.

[05:51] Whenever we need to dispose of it, it will delete and reset any state for the percentage that we were keeping track of. Because we tied the lifecycle of our spinner and loadStats observables together, they'll get created and disposed of together, which will handle resetting the scan state for us.

[06:11] This is a case where we want the state for the scan to be local, and we want to take advantage of the fact that it maintains state only while the observable it's a part of is active. Let's test it.

[06:24] I'll create a few slow tasks, then a few quick ones, and then a few slow ones again. You'll notice that when I create new tasks, the percentage goes down. Then it starts going up again, all the way up to 100 percent, at which point it hides. Important, if I start it up again, it starts again from 0We can see that the resetting has worked.

[06:46] Takeaways from this? If you have state, and that state needs to be reset, say, when this goes back down to 0consider the lifecycle of the observable scan belongs to rather than manually resetting it. This is powerful because we can focus on what this observable does, which is tracking the loading stats, and not have to worry about when to clean up and do the resets.

[07:09] In this case, we found another observable that it had to share some lifecycle with. We combined them into this other stream. It's at this upper level where we decide its lifecycle and, with it, the cleanup of the state.

~ 3 years ago

Hi,

I have started the task on my own, just for a study, but I encountered an issue, would you be able to explain it?

This is my solution:

const spinnerTotal = shouldShowSpinner.pipe(
  mergeMapTo(currentLoadCount),
  tap((...args) => console.log('currentLoadCount', args)),
  scan(
    (acc, currentLoadCountInScan) => {

      if (acc.isFirst) {
        acc.isFirst = false;
        acc.total = currentLoadCountInScan;
      } else {
        switch (true) {
          case (acc.prev < currentLoadCountInScan):
            acc.total += 1;
            break;
          case (acc.prev > currentLoadCountInScan):
            acc.completed += 1;
            break;
        }
      }

      console.log('acc.total', acc.total);
      console.log('acc.completed', acc.completed);

      acc.prev = currentLoadCountInScan;

      return acc;

      // acc.total = currentLoadCountInScan > acc.total ? currentLoadCountInScan : acc.total;
      // acc.completed = currentLoadCountInScan > acc.total ? currentLoadCountInScan : acc.total;
    },
    {
      total: 0,
      completed: 0,
      prev: 0,
      isFirst: true,
    }
  ),
  // TODO Via react component
  tap(({ total }) => {
    let el = document.createElement('h1');
    el.id = '123';
    el.style.position = 'absolute';
    el.style.right = 0;
    el.style.bottom = 0;

    if (document.getElementById('123')) {
      el.remove();
      el = document.getElementById('123');
    } else {
      document.body.appendChild(el);
    }

    el.innerText = total;
  })
);

let s = spinnerTotal
  .pipe(
    switchMap(({ total, completed }) => {
      return showSpinner(total, completed)
        .pipe(
          takeUntil(shouldHideSpinner)
        );
    }),
  )
  .subscribe({
    next: () => {
      console.log('next', s);
    },
    complete () {
      console.log('complete');
    }
  });

The problem is that scan state is not refreshed and I cannot understand why is that? What am I doing wrong?

To compare with your solution my just starts to observe via shouldShowSpinner then I do mergeMapTo(currentLoadCount) to get the numbers, then in the end I switch map it to showSpinner.

I created a pull request in your repo https://github.com/rarmatei/egghead-thinking-reactively/pull/19

Thanks!

Markdown supported.
Become a member to join the discussionEnroll Today