Skip to content

Commit

Permalink
feat(effects): concatLatestFrom operator
Browse files Browse the repository at this point in the history
  • Loading branch information
David committed Dec 12, 2020
1 parent 6a065fe commit 9136797
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 12 deletions.
71 changes: 71 additions & 0 deletions modules/effects/spec/concat_latest_from.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { of } from 'rxjs';
import { skipWhile } from 'rxjs/operators';
import { hot } from 'jasmine-marbles';
import { concatLatestFrom } from '../src/concat_latest_from';

describe('concatLatestFrom', () => {
describe('no triggering value appears in source', () => {
it('should not evaluate the array', () => {
let evaluated = false;
const toBeLazilyEvaluated = () => {
evaluated = true;
return of(4);
};

const numbers$ = hot('-a-b-', { a: 1, b: 2 }).pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => [toBeLazilyEvaluated()])
);

expect(numbers$).toBeObservable(hot('----'));
expect(evaluated).toBe(false);
});
it('should not evaluate the observable', () => {
let evaluated = false;
const toBeLazilyEvaluated = () => {
evaluated = true;
return of(4);
};

const numbers$ = hot('-a-b-', { a: 1, b: 2 }).pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => toBeLazilyEvaluated())
);

expect(numbers$).toBeObservable(hot('----'));
expect(evaluated).toBe(false);
});
});
describe('a triggering value appears in source', () => {
it('should evaluate the array of observables', () => {
let evaluated = false;
const toBeLazilyEvaluated = () => {
evaluated = true;
return of(4);
};

const numbers$ = hot('-a-b-c-', { a: 1, b: 2, c: 3 }).pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => [toBeLazilyEvaluated()])
);

expect(numbers$).toBeObservable(hot('-----d', { d: [3, 4] }));
expect(evaluated).toBe(true);
});
it('should evaluate the observable', () => {
let evaluated = false;
const toBeLazilyEvaluated = () => {
evaluated = true;
return of(4);
};

const numbers$ = hot('-a-b-c-', { a: 1, b: 2, c: 3 }).pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => toBeLazilyEvaluated())
);

expect(numbers$).toBeObservable(hot('-----d', { d: [3, 4] }));
expect(evaluated).toBe(true);
});
});
});
80 changes: 80 additions & 0 deletions modules/effects/src/concat_latest_from.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Observable, of, OperatorFunction } from 'rxjs';
import { concatMap, withLatestFrom } from 'rxjs/operators';

type TypeOfObservable<T> = T extends Observable<infer U> ? U : never;

/**
* @description
* RxJS operator that lazily evaluates a list of Observables.
*
* @param observablesFactory A function which returns an `Observable[]`.
* @returns Returns an `OperatorFunction` that returns an Observable which combines the source and latest from Observables
*
* @usageNotes
*
* ** Use to mitigate performance impact of `store.select` **
* ```ts
* effectName$ = createEffect(
* () => this.actions$.pipe(
* ofType(FeatureActions.actionOne),
* // The call to this.store.select will not be performed
* // until actionOne is received
* concatLatestFrom(() => [this.store.select(getDataState)]),
* filter(([action, data]) => data.enabled),
* map(() => FeatureActions.actionTwo())
* )
* );
* ```
*/
export function concatLatestFrom<T extends Observable<unknown>[], V>(
observablesFactory: (value: V) => [...T]
): OperatorFunction<V, [V, ...{ [i in keyof T]: TypeOfObservable<T[i]> }]>;
/**
* @description
* RxJS operator that lazily evaluates an Observable.
*
* @param observableFactory A function which returns an `Observable`.
* @returns Returns an `OperatorFunction` that returns an Observable which combines the source and latest from Observable
*
* @usageNotes
*
* ** Use to mitigate performance impact of `store.select` **
* ```ts
* effectName$ = createEffect(
* () => this.actions$.pipe(
* ofType(FeatureActions.actionOne),
* // The call to this.store.select will not be performed
* // until actionOne is received
* concatLatestFrom(() => this.store.select(getDataState)),
* filter(([action, data]) => data.enabled),
* map(() => FeatureActions.actionTwo())
* )
* );
* ```
*/
export function concatLatestFrom<T extends Observable<unknown>, V>(
observableFactory: (value: V) => T
): OperatorFunction<V, [V, TypeOfObservable<T>]>;

export function concatLatestFrom<
T extends Observable<unknown>[] | Observable<unknown>,
V
>(
observablesFactory: (value: V) => T extends Observable<unknown>[] ? [...T] : T
) {
return concatMap((value) => {
const observables = observablesFactory(value);
const observablesAsArray = Array.isArray(observables)
? observables
: [observables];
return of(value).pipe(withLatestFrom(...observablesAsArray));
}) as OperatorFunction<
V,
[
V,
...(T extends Observable<unknown>[]
? { [i in keyof T]: TypeOfObservable<T[i]> }
: [TypeOfObservable<T>])
]
>;
}
1 change: 1 addition & 0 deletions modules/effects/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export {
OnInitEffects,
} from './lifecycle_hooks';
export { USER_PROVIDED_EFFECTS } from './tokens';
export { concatLatestFrom } from './concat_latest_from';
11 changes: 3 additions & 8 deletions projects/example-app/src/app/core/effects/router.effects.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';

import { of } from 'rxjs';
import { concatMap, map, tap, withLatestFrom } from 'rxjs/operators';
import { map, tap } from 'rxjs/operators';

import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { routerNavigatedAction } from '@ngrx/router-store';

Expand All @@ -16,11 +15,7 @@ export class RouterEffects {
() =>
this.actions$.pipe(
ofType(routerNavigatedAction),
concatMap((action) =>
of(action).pipe(
withLatestFrom(this.store.select(fromRoot.selectRouteData))
)
),
concatLatestFrom(() => this.store.select(fromRoot.selectRouteData)),
map(([, data]) => `Book Collection - ${data['title']}`),
tap((title) => this.titleService.setTitle(title))
),
Expand Down
6 changes: 2 additions & 4 deletions projects/ngrx.io/content/guide/effects/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,7 @@ export class CollectionEffects {
() =>
this.actions$.pipe(
ofType(CollectionApiActions.addBookSuccess),
concatMap(action => of(action).pipe(
withLatestFrom(this.store.select(fromBooks.getCollectionBookIds))
)),
concatLatestFrom(action => this.store.select(fromBooks.getCollectionBookIds)),
tap(([action, bookCollection]) => {
if (bookCollection.length === 1) {
window.alert('Congrats on adding your first book!');
Expand All @@ -339,7 +337,7 @@ export class CollectionEffects {

<div class="alert is-important">

**Note:** For performance reasons, use a flattening operator in combination with `withLatestFrom` to prevent the selector from firing until the correct action is dispatched.
**Note:** For performance reasons, use a flattening operator like `concatLatestFrom` to prevent the selector from firing until the correct action is dispatched.

</div>

Expand Down
49 changes: 49 additions & 0 deletions projects/ngrx.io/content/guide/effects/operators.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,52 @@ export class AuthEffects {
) {}
}
</code-example>

## `concatLatestFrom`

The `concatLatestFrom` operator functions similarly to `withLatestFrom` with one important difference-
it lazily evaluates the provided Observable factory.

This allows you to utilize the source value when selecting additional sources to concat.

Additionally, because the factory is not executed until it is needed, it also mitigates the performance impact of creating some kinds of Observables.

For example, when selecting data from the store with `store.select`, `concatLatestFrom` will prevent the
selector from being evaluated until the source emits a value.

The `concatLatestFrom` operator takes an Observable factory function that returns an array of Observables, or a single Observable.

<code-example header="router.effects.ts">
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';

import { map, tap } from 'rxjs/operators';

import {Actions, concatLatestFrom, createEffect, ofType} from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { routerNavigatedAction } from '@ngrx/router-store';

import * as fromRoot from '@example-app/reducers';

@Injectable()
export class RouterEffects {
updateTitle$ = createEffect(
() =>
this.actions$.pipe(
ofType(routerNavigatedAction),
concatLatestFrom(() => this.store.select(fromRoot.selectRouteData)),
map(([, data]) => `Book Collection - ${data['title']}`),
tap((title) => this.titleService.setTitle(title))
),
{
dispatch: false,
}
);

constructor(
private actions$: Actions,
private store: Store<fromRoot.State>,
private titleService: Title
) {}
}
</code-example>

0 comments on commit 9136797

Please sign in to comment.