Commit 75b9dc26 authored by Julian's avatar Julian
Browse files

improve withLatestFrom

parent 36335da9
...@@ -5,7 +5,7 @@ Combination operators or creation functions enable us to process emissions ...@@ -5,7 +5,7 @@ Combination operators or creation functions enable us to process emissions
from different `Observables` and transform them into a single emission. from different `Observables` and transform them into a single emission.
The resulting value is an array with the last emitted value of all included Observables. The resulting value is an array with the last emitted value of all included Observables.
In this very example, we will utilize the `forkJoin` operator. In this very exercise, we will utilize the `forkJoin` operator.
On first sight it is a perfect match for combining HTTP Requests since it waits until all On first sight it is a perfect match for combining HTTP Requests since it waits until all
combined operators `complete` before emitting a result. combined operators `complete` before emitting a result.
...@@ -29,13 +29,12 @@ result$ ...@@ -29,13 +29,12 @@ result$
}) })
``` ```
The visual representation of the above example: The visual representation of the above example:
The gray boxes at the bottom of the operator scope symbolize a cache which stores the last emitted value of each included Observable.
![forkJoin http calls](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-forkJoin-http_michael-hladky.png) ![forkJoin http calls](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-forkJoin-http_michael-hladky.png)
_forkJoin http calls_ _forkJoin http calls_
The gray boxes at the bottom of the operator scope symbolize a cache which stores the last emitted value of each included Observable. If any of the sources raises an `error`, it gets forwarded, and the resulting Observable errors.
If any of the sources raises an `error`, it gets forwarded, and the resulting Observable errors.
![forkJoin error](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-forkJoin-error_michael-hladky.png) ![forkJoin error](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-forkJoin-error_michael-hladky.png)
_forkJoin error_ _forkJoin error_
......
...@@ -58,7 +58,7 @@ export class BlogService { ...@@ -58,7 +58,7 @@ export class BlogService {
this.state$.next({ this.state$.next({
...this.state$.getValue(), ...this.state$.getValue(),
posts: upsertEntities(this.state$.getValue().posts, [newPost], 'id') posts: upsertEntities(this.state$.getValue().posts, [newPost], 'id')
}) });
}, console.log); }, console.log);
} }
......
...@@ -15,14 +15,16 @@ _current solution: instant updates visualized_ ...@@ -15,14 +15,16 @@ _current solution: instant updates visualized_
![desired solution: opt-in updates visualized](./assets/images/Reactive-architecture-and-ux-patterns_angular_opt-in-updates_michael-hladky.png) ![desired solution: opt-in updates visualized](./assets/images/Reactive-architecture-and-ux-patterns_angular_opt-in-updates_michael-hladky.png)
_desired solution: opt-in updates visualized_ _desired solution: opt-in updates visualized_
For this exercise we have to extend our `Component` with extra functionalities. For this exercise we have to extend our `Component`.
```Typescript ```Typescript
// start.withLatestFrom.component.ts
@Component() @Component()
export class StartWithLatestFromComponent { export class StartWithLatestFromComponent {
optInListClick$ = new Subject(); // performs the opt-in update on button click optInListClick$ = new Subject(); // performs the opt-in update on button click
numNewItems$: Observable<number>; // the derived number of new items available for display numNewItems$: Observable<number>; // the derived number of new items available for display
feed$: Observable<BlogPost[]>; // the new feed to display. use blog$ and optIntListClick$ to calculate feed$: Observable<BlogPost[]>; // the new feed to display. use blog$ and optInListClick$ to calculate
} }
``` ```
...@@ -30,4 +32,6 @@ export class StartWithLatestFromComponent { ...@@ -30,4 +32,6 @@ export class StartWithLatestFromComponent {
Utilize the `withLatestFrom` combination operator and implement a _staging_ area for new arriving `BlogPost`. Utilize the `withLatestFrom` combination operator and implement a _staging_ area for new arriving `BlogPost`.
Use the new introduced properties `optInListClick$`, `numNewItems$` and `feed$` to implement your solution. You should Use the new introduced properties `optInListClick$`, `numNewItems$` and `feed$` to implement your solution. You should
end up replacing the view binding of `blog$` by `feed$`. Use `blog$` and `optInListClick$` to calculate the new feed. end up replacing the view binding of `blog$` by `feed$`.
Use `blog$` and `optInListClick$` to compute the new feed.
# Opt-in updates - Solution # Opt-in updates - Solution
Staging new arriving data with `withLatestFrom`: Let's take a look at what we have implemented.
At first, we used `combineLatest` in order to create an `Observable` source representing
the number of new items to opt-in to. This represents the indication for the user when new updates
are available for display.
**Component**
```Typescript ```Typescript
// solution.withLatestFrom.component.ts // solution.withLatestFrom.component.ts
blog$ = combineLatest([
this.blogService.posts$.pipe(filter(l => !!l.length)),
this.blogService.comments$.pipe(filter(l => !!l.length))
]).pipe(
map(([list, items]) => toBlogPosts(list, items)),
shareReplay(1)
);
feed$: Observable<BlogPost[]> = concat(
this.blog$.pipe(take(1)),
this.optInListClick$.pipe(
withLatestFrom(this.blog$),
map(([_, items]) => items)
),
shareReplay(1)
);
numNewItems$: Observable<number> = combineLatest([ numNewItems$: Observable<number> = combineLatest([
this.blog$, this.blog$,
this.feed$ this.feed$
...@@ -29,6 +17,34 @@ numNewItems$: Observable<number> = combineLatest([ ...@@ -29,6 +17,34 @@ numNewItems$: Observable<number> = combineLatest([
.pipe( .pipe(
map(([a, b]) => Math.abs(a.length - b.length)) map(([a, b]) => Math.abs(a.length - b.length))
); );
```
Then, we have built the opt-in update logic by using the `optInListClick$` as trigger
to emit the _latest_ value of `blog$` utilizing `withLatestFrom`.
```Typescript
// solution.withLatestFrom.component.ts
optInUpdate$: Observable<BlogPost[]> = this.optInListClick$.pipe(
withLatestFrom(this.blog$),
map(([_, items]) => items)
);
```
Finally, we built the `feed$`. We wanted `feed$` to start with the first value emitted by `blog$`. That is
why we have combined the `optInUpdate$` with the first emission of `blog$` using the `concat` creation
function.
```Typescript
// solution.withLatestFrom.component.ts
feed$: Observable<BlogPost[]> = concat(
this.blog$.pipe(take(1)),
this.optInUpdate$
).pipe(shareReplay(1));
``` ```
Great! ... we need some text here :) Great! We learned how to build a mechanism for user controlled opt-in updates with `withLatestFrom`.
# Opt-in updates - Theory # withLatestFrom behavior and gotchas
The previous example showcased how to combine multiple ongoing `Observables` to one result. However, there are situations The previous example showcased how to combine multiple ongoing `Observables` to one result. However, there are
where it is necessary to access the latest value from an `Observable` after a specific event occurs. situations where it is necessary to access the latest value from an `Observable` after a specific event occurs.
`withLatestFrom` combines the last _emitted_ value of the a provided source `Observable` to an active stream of data. `withLatestFrom` combines the last _emitted_ value of the provided source `Observable` to an active stream of data.
In this example we want to get the latest value of the `timer$` whenever a user clicks on the document. We will utilize the `withLatestFrom` operator in this exercise to setup a _staging_ area for new incoming `BlogPost`
in order to achieve user controller opt-in updates for the view.
## Behavior
An example where the latest value of `b$` is logged after a user clicks on the document.
```Typescript ```Typescript
import { fromEvent, interval } from 'rxjs'; import { fromEvent, interval } from 'rxjs';
import { withLatestFrom } from 'rxjs/operators'; import { withLatestFrom } from 'rxjs/operators';
const click$ = fromEvent(document, 'click'); // get the click event const input$ = fromEvent(document, 'click'); // get the click event
const timer$ = interval(1000); const b$ = interval(1000);
const result = click$.pipe(withLatestFrom(timer$)); const result = input$.pipe(withLatestFrom(b$));
result.subscribe(([clickEvent, latestTimerValue]) => console.log(latestTimerValue)); // logs the latest value of the timer result.subscribe(([clickEvent, latestTimerValue]) => console.log(latestTimerValue)); // logs the latest value of the timer
``` ```
![The visual representation of this example](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-theory-example_michael-hladky.png)
_The visual representation of this example_
If the _outer_ `Observable`, you originally subscribed to, or the _inner_ `Observable` which you applied `withLatestFrom`
to raises an `error`, the end result will be an `error` too.
![withLatestFrom outer error](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-outer-error_michael-hladky.png)
_withLatestFrom outer error_
![withLatestFrom inner error](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-inner-error_michael-hladky.png)
_withLatestFrom inner error_
If the `Observable` within `withLatestFrom` raises a `completion` event, the stream will continue using the last emitted
value of the source.
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-outer-ongoing_michael-hladky.png)
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-outer-error_michael-hladky.png)
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-inner-error_michael-hladky.png)
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-outer-complete_michael-hladky.png)
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-inner-complete_michael-hladky.png) ![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-inner-complete_michael-hladky.png)
_withLatestFrom inner complete_
Gotcha => startWith ## 💡 Gotcha(s)!
`withLatestFrom` will only emit values if the provided source ever emitted a value.
The following illustration visualizes this behavior. Even though `input$` emits `A` and `B`, the `subscriber` doesn't get
notified about the changes.
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-outer-ongoing_michael-hladky.png)
_withLatestFrom waits until at least one value was emitted_
...@@ -14,15 +14,13 @@ import { BlogBasicService, BlogPost, toBlogPosts } from 'shared'; ...@@ -14,15 +14,13 @@ import { BlogBasicService, BlogPost, toBlogPosts } from 'shared';
</mat-form-field> </mat-form-field>
<button mat-raised-button color="primary" (click)="addPost()">Add Post</button> <button mat-raised-button color="primary" (click)="addPost()">Add Post</button>
<ng-container *ngIf="(numNewItems$ | async) as numItems"> <button mat-raised-button color="accent"
<button mat-raised-button color="accent" [disabled]="(numNewItems$ | async) === 0"
[disabled]="numItems === 0" (click)="optInListClick$.next($event)">
(click)="optInListClick$.next($event)"> New posts: ({{(
New posts: ({{( numNewItems$ | async
numItems )}})
)}}) </button>
</button>
</ng-container>
<div *ngIf="feed$ | async as blog"> <div *ngIf="feed$ | async as blog">
<mat-list> <mat-list>
...@@ -47,14 +45,16 @@ export class SolutionWithLatestFromComponent { ...@@ -47,14 +45,16 @@ export class SolutionWithLatestFromComponent {
shareReplay(1) shareReplay(1)
); );
optInUpdate$: Observable<BlogPost[]> = this.optInListClick$.pipe(
withLatestFrom(this.blog$),
map(([_, items]) => items)
);
feed$: Observable<BlogPost[]> = concat( feed$: Observable<BlogPost[]> = concat(
this.blog$.pipe(take(1)), this.blog$.pipe(take(1)),
this.optInListClick$.pipe( this.optInUpdate$
withLatestFrom(this.blog$), ).pipe(shareReplay(1));
map(([_, items]) => items)
),
shareReplay(1)
);
numNewItems$: Observable<number> = combineLatest([ numNewItems$: Observable<number> = combineLatest([
this.blog$, this.blog$,
this.feed$ this.feed$
......
...@@ -15,15 +15,12 @@ import { BlogPost, toBlogPosts } from 'shared'; ...@@ -15,15 +15,12 @@ import { BlogPost, toBlogPosts } from 'shared';
</mat-form-field> </mat-form-field>
<button mat-raised-button color="primary" (click)="addPost()">Add Post</button> <button mat-raised-button color="primary" (click)="addPost()">Add Post</button>
<ng-container *ngIf="(numNewItems$ | async) as numItems"> <button mat-raised-button color="accent"
<button mat-raised-button color="accent" (click)="optInListClick$.next($event)">
[disabled]="numItems === 0" New posts: ({{(
(click)="optInListClick$.next($event)"> numNewItems$ | async
New posts: ({{( )}})
numItems </button>
)}})
</button>
</ng-container>
<div *ngIf="blog$ | async as blog"> <div *ngIf="blog$ | async as blog">
<mat-list> <mat-list>
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment