platform icon indicating copy to clipboard operation
platform copied to clipboard

ComponentStore: Allow multiple selector functions to store.select

Open jaufgang opened this issue 4 years ago • 16 comments

Currently, store.select can either be passed a selector function, or alternately multiple selectors can be combined by passing 2 or more selectors with a project function. It would be convenient if each of the "combine selectors" could accept either a selector or a function, in order to concisely select multiple properties from the state that don't already have selectors defined:

this.store.select(
  state => state.foo,
  state => state.bar,
  (foo, bar) => ({ foo, bar })
 );

Basically, if the select function encounters a function as one of the arguments instead of an observable, all it would have to do is wrap the function internally with this.select(selectorFn)

Signature for the overload for two selectors would be something like

select<R, S1, S2>(
    s1: Observable<S1> | (s: T) => S1, 
    s2: Observable<S2> | (s: T) => S2,
    projector: (s1: S1, s2: S2) => R, 
   config?: SelectConfig
): Observable<R>;

Describe any alternatives/workarounds you're currently using


this.store.select(
  this.store.select(state => state.foo),
  this.store.select(state => state.bar),
  (foo, bar) => ({ foo, bar })
 );

jaufgang avatar Dec 22 '20 21:12 jaufgang

Note that @ngrx/store supports this.

from https://ngrx.io/guide/store/selectors#using-selectors-for-multiple-pieces-of-state:

export const selectUser = (state: AppState) => state.selectedUser;
export const selectAllBooks = (state: AppState) => state.allBooks;
 
export const selectVisibleBooks = createSelector(
  selectUser,
  selectAllBooks,
  (selectedUser: User, allBooks: Book[]) => {
    if (selectedUser && allBooks) {
      return allBooks.filter((book: Book) => book.userId === selectedUser.id);
    } else {
      return allBooks;
    }
  }
);

jaufgang avatar Dec 22 '20 22:12 jaufgang

Since this is the state of a component, I'm not sure if we need this. What would be the benefit of this, in comparison to simply selecting those props from the state?

vm$ = this.store.select(state => ({  foo: state.foo, bar: state.bar }))

timdeschryver avatar Dec 23 '20 14:12 timdeschryver

What would be the benefit of this, in comparison to simply selecting those props from the state?

vm$ = this.store.select(state => ({  foo: state.foo, bar: state.bar }))

This will emit a new value every time the state changes, even if foo and bar haven't changed. The selector creates a new object, so the internal call to distinctUntilChanged emits every time.

Take a look here to see this in action: https://stackblitz.com/edit/ngrx-component-store-selectors?file=src/app/app.component.ts&devtoolsheight=50 (observe the console to see logging output. Selector A is output multiple times, but selector B is only output once)

This actually could be documented better to ensure other devs understand the difference.

jaufgang avatar Dec 23 '20 15:12 jaufgang

@timdeschryver @alex-okrushko I can take this one

markostanimirovic avatar Dec 25 '20 00:12 markostanimirovic

IIRC, we discussed this when the implementation was on going, but I can't find it anymore... I think we decided not to do it because it shouldn't be a big performance hit in combination that it would require more code (and we would want to use the existing selectors).

timdeschryver avatar Dec 30 '20 12:12 timdeschryver

Generally, I like the idea. @timdeschryver @markostanimirovic maybe we can explore it past v11?

alex-okrushko avatar Dec 30 '20 17:12 alex-okrushko

Sounds good to me 👍

timdeschryver avatar Jan 04 '21 19:01 timdeschryver

I can look at this. I'm thinking the simplest implementation is creating a selector under-the-hood when multiple selectors are passed to select:

// the return type would be inferred normally
const selections: Observable<[ResultOfSelectorOne, ResultOfSelectorTwo]> = this.store.select(selectorOne, selectorTwo);

// in `select` method
const combinedSelector = args.length > 1 ? createSelector(...args, (...args) => (...slices) => slices) : args[0];

david-shortman avatar Nov 11 '21 14:11 david-shortman

I disagree with adding this as I think it muddies up the separation between selectors and the store

brandonroberts avatar Nov 11 '21 14:11 brandonroberts

Ah, fair point

david-shortman avatar Nov 11 '21 14:11 david-shortman

This could be implemented without using createSelector so the divisions could remain in place.

david-shortman avatar Nov 11 '21 14:11 david-shortman

Since this is already supported, I can see why store.select doesn't need this feature:

store.select(createSelector(
    selectOneThing,
    selectAnotherThing,
    selectAThirdThing
  )
);

However, this requires a dependence on createSelector.

This anonymous method is also harder to unit test, so it's likely that the anonymous selector should be extracted into its own named selector.

But, that creates more boilerplate.

My assessment is:

Pros to store.select supporting multiple selectors

  • Natural API for getting multiple values
  • Developer gets free performance benefits
  • Avoids pitfalls like a developer using combineLatest with multiple store.selects where they may experience issues with first emissions
  • Reduces boilerplate of one-time selectors

Cons

  • New API (increases complexity of project) for something technical achievable already
  • There is a trivial addition of time added to check for distinct changes compared to only having to use ===

david-shortman avatar Nov 12 '21 12:11 david-shortman

To be clear, I am not recommending the API from the original issue post, which does look like the createSelector api with a series of selectors and a projector.

I'm suggesting select is able to take in a variadic number of map functions only.


export function select<T, Results extends unknown[]>(
  ...mapFns: [...{ [i in keyof Results]: (state: T) => Results[i] }]
): (source$: Observable<T>) => Observable<Results>;

david-shortman avatar Nov 12 '21 12:11 david-shortman

Here's another possible solution and convenience.

Today, components may select many values like so:

this.valueOne$ = this.store.select(selectValueOne);
this.valueTwo$ = this.store.select(selectValueTwo);
this.valueThree$ = this.store.select(selectValueThree);
this.valueFour$ = this.store.select(selectValueFour);

This could be simplified:

this.data = this.store.selectMany({
  valueOne: selectValueOne,
  valueTwo: selectValueTwo,
  valueThree: selectValueThree,
  valueFour: selectValueFour
});

In the template, the result could be used:

value three is: {{ data.valueThree$ | async }}

This proposed API for a "selectMany" function:

  • creates independent observables for each piece of data, keeping them decoupled
  • reduces boilerplate (reduces variable declarations and invocations of select)

david-shortman avatar Mar 25 '22 22:03 david-shortman

Honestly, the suggested selectMany above doesn't look any better than having 4 individual observables.

What would be more helpful though, is creating a single observable view model from that:

this.vm$ = this.store.selectMany({
  valueOne: selectValueOne,
  valueTwo: selectValueTwo,
  valueThree: selectValueThree,
  valueFour: selectValueFour
});

// and then in the template
<ng-container *ngrxLet="vm$ as vm">
  One: {{ vm.valueOne }}

amakhrov avatar Jul 08 '22 01:07 amakhrov

Honestly, the suggested selectMany above doesn't look any better than having 4 individual observables.

What would be more helpful though, is creating a single observable view model from that:

Your suggested API is nice for creating a single observable. My intention for recommending separate streams was for potentially extreme/unnecessary performance concerns. Whenever any of the values changes, a new "view model" object will be created (presumably from using combineLatest), potentially causing more areas of the template to be re-evaluated than needed.

david-shortman avatar Jul 08 '22 03:07 david-shortman