Today we will create a progress bar with an array of observables. The tutorial in its current form is not a real-world example but think of it as when you have to keep track of like 20 requests, how easy will this make your life. So, let's start.
To have a look at what we are trying to create follow this link
To start let us first create a few mock requests:-
const requestOne = of("first").pipe(delay(500));
const requestTwo = of("second").pipe(delay(800));
const requestThree = of("third").pipe(delay(1100));
const requestFour = of("fourth").pipe(delay(1400));
const requestFive = of("fifth").pipe(delay(1700));
So now we have 5 mock requests emitting data after a certain delay.
We should create a basic progress bar using HTML and CSS
<div class="progress-container">
<div class="progress" id="progress"></div>
</div>
<button id="load">
Load Data
</button>
<div id="data"></div>
.progress-container {
height: 2em;
border: 2px solid #ff00ff;
width: 100%;
}
.progress-container .progress {
height: 100%;
background-color: #ffff00;
transition: all 0.6s ease;
width: 0px;
}
#load {
width: 50%;
margin: 1em 25%;
}
#data {
width: 100%;
margin: 1em 0;
text-align: center;
}
This will create a basic progress bar.
Let's think about what we need to achieve this exercise
Get all observables at one place
Get completion of observables as a stream
Start observable stream on click of a button
Start the processing of data observables on click of the button
Get data given by each observable
Display this data one by one
Count the number of emissions
Update progress bar percentage as observables complete
We have many operators like of, from, fromEvent by using from we can create an Observable from an Array of observables. Which may work for our solution so let us try that:-
const observables: Array<Observable<string>> = [
requestOne,
requestTwo,
requestThree,
requestFour,
requestFive
];
const array$ = from(observables);
Now we have an array of observables that we receive one at a time i.e. we get a stream of observables. We need to subscribe to each of these inner observables and track there completion. We have a few choices for that like merge, concat, concatAll, mergeAll. We will see each of these in detail and select what works for our use case:-
Merge: Creates an output Observable which concurrently emits all values from every given input Observable
Concat: Creates an output Observable which sequentially emits all values from given Observable and then moves on to the next.
concatAll: Converts a higher-order Observable into a first-order Observable by concatenating the inner Observables in order.
mergeAll: Converts a higher-order Observable into a first-order Observable which concurrently delivers all values that are emitted on the inner Observables.
There are many other combination operators but these are the ones we will look at today. Let's study each one of them and see which works best for us.
After thinking with from we have created a higher-order observable(an observable that emits observables) so we can reject concat and merge. We are left with concatAll and mergeAll. I think we can use both but merge all will start all the observable concurrently, to show the difference between requests I chose concatAll which will emit from the next observable ofter the completion of the previous observable. This will show us the data loading more clearly.
Let's move now to the third step and create an observable which help us to listen to the click event, we can use fromEvent for this:-
const clicks$ = fromEvent(loadButton, "click");
We should now subscribe to the requests observable on the click of the button but to do that we need to refresh the subscription on each click because on each click the previous subscription is rendered useless. Yeah seems like a perfect place to use switchMap but here we are not bothered with the value passed by the source observable it's always a click. So, I found an even better operator when we are not bothered with what has been passed by the source observable we can use switchMapTo this will map our clicks to requests, and we are not bothered by what is emitted by the click$ observable. Here is how we do it.
const progress$ = clicks$.pipe(
switchMapTo(requests$),
);
On each click, our requests are triggered. We should now get the data from this observable and show what data is being emitted. To do that we write a helper function that will display the data and pass it to the progress subscription.
const updateContent = newContent => {
content.innerHTML += newContent;
};
const displayData = data => {
updateContent(`<div class="content-item">${data}</div>`);
};
progress$.subscribe(displayData);
So we completed the first part and were able to get the data now we only need to count the completed subscriptions and display the progress bar accordingly.
const count$ = array$.pipe(
count()
);
Okay, we have the count now here comes the tricky part we need to monitor the completion of each request and add to it on completion of each request. After some time I found out two operators which can do our work of incrementing that are scan and reduce but reduce only returns the final value of the accumulator and we should get the latest value from the scan and divide it but the count. We add scan to the progress stream:-
progress$.pipe(
scan(current => current + 1, 0)
)
We just need to combine the values from the last two observables and we can take many routes but for the sake of the tutorial and learnrxjs from where we copy these apps, we will use the withLatestFrom operator. This will return us the latest value, lets us combine it with another value from another observable, and gives us a project function where we get both of these values as follows.
progress$.pipe(
scan(current => current + 1, 0),
withLatestFrom(count$, (current, count) => current / count),
)
On subscribing to this observable we can see that this returns us .2, .4, and so on completion of each observable. we just need a helper function which we can use in tap or subscribe whichever way you want to use it.
const updateProgress = progressRatio => {
console.log("Progress Ratio: ", progressRatio);
progressBar.style.width = 100 * progressRatio + "%";
if (progressRatio === 1) {
progressBar.className += " finished";
} else {
progressBar.className = progressBar.className.replace(" finished", "");
}
};
Now let us get to the final result.
progress$.pipe(
scan(current => current + 1, 0),
withLatestFrom(count$, (current, count) => current / count),
)
.subscribe(updateProgress);
but it makes our progress bar go over 100% on each subsequent click. We need to clear or accumulator scan okay we need to cancel our previous data this seems like a place where we can use switchMap and we need to insert it in the click observable stream. We need to refactor it as following
const ratio$ = progress$.pipe(
scan(current => current + 1, 0),
withLatestFrom(count$, (current, count) => current / count),
);
clicks$.pipe(
switchMapTo(ratio$)
)
.subscribe(updateProgress);
I used switchMapTo because we are not concerned with the data emitted by the click event but still progress bar is not working but after taking a careful look at the code our progress observable is unicast and we are piping at two different locations. To make it multicat we use the share operator on progress$.
const progress$ = clicks$.pipe(
switchMapTo(requests$),
share()
);
This completes our exercise you can see results at this link. Lets have a final look at our code
// Import stylesheets
import "./style.css";
import { Observable, of, fromEvent, from } from "rxjs";
import {
delay,
switchMapTo,
concatAll,
count,
scan,
withLatestFrom,
share,
tap
} from "rxjs/operators";
const requestOne = of("first").pipe(delay(500));
const requestTwo = of("second").pipe(delay(800));
const requestThree = of("third").pipe(delay(1100));
const requestFour = of("fourth").pipe(delay(1400));
const requestFive = of("fifth").pipe(delay(1700));
const loadButton = document.getElementById("load");
const progressBar = document.getElementById("progress");
const content = document.getElementById("data");
const updateProgress = progressRatio => {
console.log("Progress Ratio: ", progressRatio);
progressBar.style.width = 100 * progressRatio + "%";
if (progressRatio === 1) {
progressBar.className += " finished";
} else {
progressBar.className = progressBar.className.replace(" finished", "");
}
};
const updateContent = newContent => {
content.innerHTML += newContent;
};
const displayData = data => {
updateContent(`<div class="content-item">${data}</div>`);
};
const observables: Array<Observable<string>> = [
requestOne,
requestTwo,
requestThree,
requestFour,
requestFive
];
const array$ = from(observables);
const requests$ = array$.pipe(concatAll());
const clicks$ = fromEvent(loadButton, "click");
const progress$ = clicks$.pipe(
switchMapTo(requests$),
share(),
);
const count$ = array$.pipe(
count()
);
const ratio$ = progress$.pipe(
scan(current => current + 1, 0),
withLatestFrom(count$, (current, count) => current / count),
);
clicks$
.pipe(
switchMapTo(ratio$)
)
.subscribe(updateProgress);
progress$.subscribe(displayData);
This is it for today.
If you like my work please support me at https://www.buymeacoffee.com/ajitsinghkaler
Top comments (0)