platform icon indicating copy to clipboard operation
platform copied to clipboard

ComponentStore effect without parameters

Open timdeschryver opened this issue 3 years ago • 15 comments

Information

A question that I frequently see on various channels, is how to implement an effect without parameters. Because this seems to be the number one question, I propose to add an example to the docs.

Documentation page

https://ngrx.io/guide/component-store/effect

I would be willing to submit a PR to fix this issue

  • [X] Yes
  • [ ] No

timdeschryver avatar Apr 22 '22 08:04 timdeschryver

Building on the existing documentation example here. Might not be 100% accurate as I'm writing the code from memory.

Using standalone pipe

import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { pipe, switchMap } from 'rxjs';

import { Movie } from './movie';
import { MoviesService } from './movies.service';

interface MoviesState {
  readonly movies: readonly Movie[];
}

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  movies$: Observable<readonly Movie[]> = this.select(state => state.movies);

  constructor() {
    super(initialState);
    // Preload all movies
    this.#getAllMovies();
  }

  #getAllMovies = this.effect<void>(
    // Standalone observable chain. An Observable<void> will be attached by ComponentStore.
    pipe(
      switchMap(() => this.moviesService.fetchAllMovies().pipe(
        tapResponse(
          movies => this.#replaceMovies(movies),
          (error: HttpErrorResponse) => this.logError(error),
        )
      )
    )
  );
  
  #replaceMovies = this.updater<readonly Movie[]>((state, movies): MoviesState => ({
    ...state,
    movies,
  }));
}

const initialState: MoviesState = {
  movies: [],
};

Using trigger$/source$

import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { pipe, switchMap } from 'rxjs';

import { Movie } from './movie';
import { MoviesService } from './movies.service';

interface MoviesState {
  readonly movies: readonly Movie[];
}

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  movies$: Observable<readonly Movie[]> = this.select(state => state.movies);

  constructor() {
    super(initialState);
    // Preload all movies
    this.#getAllMovies();
  }

  #getAllMovies = this.effect<void>(
    // Can be named whatever, for example source$. The name doesn't matter.
    trigger$ => trigger$.pipe(
      switchMap(() => this.moviesService.fetchAllMovies().pipe(
        tapResponse(
          movies => this.#replaceMovies(movies),
          (error: HttpErrorResponse) => this.logError(error),
        )
      )
    )
  );
  
  #replaceMovies = this.updater<readonly Movie[]>((state, movies): MoviesState => ({
    ...state,
    movies,
  }));
}

const initialState: MoviesState = {
  movies: [],
};

Using of

import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { pipe, switchMap } from 'rxjs';

import { Movie } from './movie';
import { MoviesService } from './movies.service';

interface MoviesState {
  readonly movies: readonly Movie[];
}

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  movies$: Observable<readonly Movie[]> = this.select(state => state.movies);

  constructor() {
    super(initialState);
    // Preload all movies
    this.#getAllMovies();
  }

  #getAllMovies = this.effect<void>(
    // Synchronous observable emitting undefined once to kick off the effect
    () => of().pipe(
      switchMap(() => this.moviesService.fetchAllMovies().pipe(
        tapResponse(
          movies => this.#replaceMovies(movies),
          (error: HttpErrorResponse) => this.logError(error),
        )
      )
    )
  );
  
  #replaceMovies = this.updater<readonly Movie[]>((state, movies): MoviesState => ({
    ...state,
    movies,
  }));
}

const initialState: MoviesState = {
  movies: [],
};

Using state$

import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { pipe, switchMap } from 'rxjs';

import { Movie } from './movie';
import { MoviesService } from './movies.service';

interface MoviesState {
  readonly movies: readonly Movie[];
}

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  movies$: Observable<readonly Movie[]> = this.select(state => state.movies);

  constructor() {
    super(initialState);
    // Preload all movies
    this.#getAllMovies();
  }

  #getAllMovies = this.effect<void>(
    // Synchronous observable emitting undefined once to kick off the effect
    () => this.state$.pipe(
      take(1),
      switchMap(() => this.moviesService.fetchAllMovies().pipe(
        tapResponse(
          movies => this.#replaceMovies(movies),
          (error: HttpErrorResponse) => this.logError(error),
        )
      )
    )
  );
  
  #replaceMovies = this.updater<readonly Movie[]>((state, movies): MoviesState => ({
    ...state,
    movies,
  }));
}

const initialState: MoviesState = {
  movies: [],
};

Credits to @nartc for the standalone pipe technique. Credits to @timdeschryver for the of technique. Credits to @brandonroberts, @alex-okrushko for the state$ technique.

LayZeeDK avatar Apr 22 '22 09:04 LayZeeDK

See RFC: Add "ngrxOnInitStore" lifecycle method to ComponentStore (#3335) for a library hook proposal that might support other techniques.

LayZeeDK avatar Apr 22 '22 09:04 LayZeeDK

@timdeschryver I suggest you mention ComponentStore in the title of this issue to avoid confusion with NgRx Effects.

LayZeeDK avatar Apr 22 '22 09:04 LayZeeDK

I use this for effects without params:

someEffect$ = this.effect(_ => _.pipe(

and this for effects with params:

someEffect$ = this.effect<SomeType>(_ => _.pipe(

Full examples

Without params:

  readonly createClass$ = this.effect(_ => _.pipe(
    withLatestFrom(this.select(s => s.model)),
    tap(([_, model]) => {
      // ... some code
    })
  ));

call:

  addClass() {
    this.store.createClass$();
  }

With param:

  readonly removeAttr$ = this.effect<string>(_ => _.pipe(
    withLatestFrom(this.editorStore.select(s => s.classForm)),
    exhaustMap(([attrKey, classForm]) => {
      //... some code
    })
  ));

call:

  removeAttr(attrKey: string) {
    this.store.removeAttr$(attrKey);
  }

e-oz avatar Apr 23 '22 05:04 e-oz

Thanks for summing up all the different ways @LayZeeDK and @e-oz . Instead of mentioning all of them, I think that we should try to standardize one of these. Does one of these have a clear benefit over the others, or is this more a matter of preference?

As a side note, I don't think the new hook will create a new technique, but it introduces a way to do something (in most of the cases invoke an effect with or without params) when the store and state are initialized.

timdeschryver avatar Apr 23 '22 06:04 timdeschryver

Instead of mentioning all of them, I think that we should try to standardize one of these.

Definitely. Besides gaining knowledge of available techniques, this is the reason for my initial questions regarding initializer effects on Discord earlier this year. Promoting a preferred technique is surely beneficial as has been the case for other NgRx packages.

Does one of these have a clear benefit over the others, or is this more a matter of preference?

Personally, I see the biggest potential in the standalone pipe technique since it is extractable to a separate constant or ES module (file) which allows us to test the observable chain in isolation.

The standalone pipe technique supports the point-free progamming style, removing the need for an intermediary variable with an arbitrary name (trigger$/source$/whatever$).

Besides parameterless effects, The standalone pipe technique is also a good fit for parameterized effects.

Playing the devil's advocate, the standalone pipe technique is a functional programming-style (as is a lot of RxJS) which is unfamiliar to some developers. This could be mitigated providing a more detailed explanation in addition to simple samples. Let's also not assume the worst without getting real world feedback.

LayZeeDK avatar Apr 23 '22 10:04 LayZeeDK

I have a related question: Is the generic type for effect always required? Effects without parameters require then the generic type void?

I made a small example here: https://stackblitz.com/edit/angular-ivy-jsnxmn?file=src%2Fapp%2Fstate.service.ts

The typings of effect (https://github.com/ngrx/platform/blob/13.1.0/modules/component-store/src/component-store.ts#L279) give the impression that void should be the default... I do not understand why we have to write explicitly effect<void>.

Also the docs do not use a generic type for effect: https://ngrx.io/guide/component-store/effect Although it seems to be required.

spierala avatar Apr 27 '22 07:04 spierala

@LayZeeDK I think the trigger$/source$ is the most natural way. Also, that approach is used in the docs already.

standalone pipe: To me the standalone pipe always looks strange. I am so used to obs$.pipe in Angular :) Also the standalone pipe is not even needed if there is only one operator inside the pipe.

The of and state$ approach feel like a hack/work-around. Why create a new Observable or use a totally unrelated one (state$) if effect created it already (trigger$ / source$)?

spierala avatar Apr 27 '22 08:04 spierala

I think the trigger$/source$ is the most natural way. Also, that approach is used in the docs already.

The standalone pipe technique additionally works well for parameterized effects. I agree that consistency is worth considering but maybe the current suggestion is not the best.

Also the standalone pipe is not even needed if there is only one operator inside the pipe.

That's an interesting point. But how is it different than having trigger$.pipe(singleOperation())? trigger$ isn't needed at all for most effects yet .pipe is also needed even for a single operation when using trigger$.

I still see isolated testability (and potential reusability) as in favor of the standalone pipe technique.

LayZeeDK avatar Apr 27 '22 10:04 LayZeeDK

I have a related question: Is the generic type for effect always required? Effects without parameters require then the generic type void?

If we leave out the generic, the default parameter type is unknown.

LayZeeDK avatar Apr 27 '22 10:04 LayZeeDK

The typings of effect (https://github.com/ngrx/platform/blob/13.1.0/modules/component-store/src/component-store.ts#L279) give the impression that void should be the default... I do not understand why we have to write explicitly effect. Also the docs do not use a generic type for effect: https://ngrx.io/guide/component-store/effect

If you use trigger$, then the type is inferred from the type of trigger$

this.effect((movieId$: Observable<string>) => movieId$.pipe());

Not sure why NgRx folks go with the route of typing with Observable rather than using the Generic. Using generic does not require you to import Observable symbol as well as faster/shorter to type.

With the standalone-pipe, there is nothing to infer so the Generic is required.

nartc avatar Apr 27 '22 13:04 nartc

That's an interesting point. But how is it different than having trigger$.pipe(singleOperation())? trigger$ isn't needed at all for most effects yet .pipe is also needed even for a single operation when using trigger$.

@LayZeeDK you could do something like this with a single operation (e.g. mergeMap): this.effect(mergeMap(v => doSomeStuff))

which is basically the same as: this.effect(trigger$ => trigger$.pipe(mergeMap(v => doSomeStuff)))

which is basically the same as: this.effect(pipe(mergeMap(v => doSomeStuff)))

In the last example the standalone pipe is useless, cause there is only one operator passed to the pipe.

spierala avatar Apr 27 '22 14:04 spierala

you could do something like this with a single operation (e.g. mergeMap):

To me, this.effect(mergeMap(v => doSomeStuff)) is closer to this.effect(pipe(mergeMap(v => doSomeStuff))) than this.effect(trigger$ => trigger$.pipe(mergeMap(v => doSomeStuff))).

In the last example the standalone pipe is useless, cause there is only one operator passed to the pipe.

Except for leaving room for additional operations, both trigger$.pipe and pipe are useless for single operations.

LayZeeDK avatar Apr 27 '22 19:04 LayZeeDK

I agree with @LayZeeDK.

The most common variant that I've used and seen is the snippet using the variable, e.g. trigger$ in combination with a pipe to execute an effect. I think that if you're familiar with the syntax, you can use just pipe or xMap, but the friendliest one is the one using source$.pipe.

  getAllMovies = this.effect<void>(
    // Can be named whatever, for example source$. The name doesn't matter.
    trigger$ => trigger$.pipe(
      switchMap(() => this.moviesService.fetchAllMovies().pipe(
        tapResponse(
          movies => this.#replaceMovies(movies),
          (error: unknown) => this.logError(error),
        )
      )
    )
  );

I'm ok with using a similar snippet like the one that was used to illustrate an example.

Thoughts?

timdeschryver avatar May 16 '22 17:05 timdeschryver

This thread deserves a BLOG POST!

alvipeo avatar Sep 04 '22 17:09 alvipeo

@alvipeo I think I will write a blog post for this. I was thinking about it.

@timdeschryver I can take that one. Are we going with the trigger$ => trigger$ ?

Or we can maybe write two kind of solutions. I'm always using the standalone one which doesn't need to introduce a random name. But I get why people prefer the trigger$ one, it looks more like the rxjs pipe mental model.

tomalaforge avatar Dec 22 '22 08:12 tomalaforge

@tomalaforge without trigger$ you can't call an effect without params - TS complaints.

e-oz avatar Dec 22 '22 08:12 e-oz

@e-oz yes you can

  readonly updateTrackerContext = this.effect<number>(
    pipe(
      switchMap((trackerId) =>
        this.http.getTrackerContexts(trackerId).pipe(
          tapResponse(
            (contexts) => this.patchState({ contexts }),
            () => this.patchState({ contexts: undefined })
          )
        )
      )
    )
  );

tomalaforge avatar Dec 22 '22 08:12 tomalaforge

now try to call store.updateTrackerContext(); and you’ll get TS error.

correction: it is possible to call it like this, if you add <void> as type information to effect definition.

e-oz avatar Dec 22 '22 08:12 e-oz

Using trigger$ is good for me @tomalaforge

timdeschryver avatar Dec 22 '22 10:12 timdeschryver

@timdeschryver can you assign me this Issue ?

tomalaforge avatar Dec 22 '22 19:12 tomalaforge