Search code examples
angulartypescriptngrxngrx-entity

How do I create a NGRX selector that combines data from two different feature modules?


I want to do this:

export const selectPlacesForCurrentCustomer = createSelector(
   selectAllPlaces,
   selectedCustomerId,
   (places: Place, customer_id: string) => places.filter((place) => place.customer === customer_id)
) 

However when I do this, I get this error:

Argument of type 'MemoizedSelector<State, string, DefaultProjectorFn>' is not assignable to parameter of type 'Selector<State, string>'.

Here is a simplified version of my application that reproduces the issue. https://stackblitz.com/edit/angular-10-ngrx-issue?file=src/app/reducers/index.ts

Here's a screenshot of my state. I want to combine these two things: enter image description here

**************** Background ******************

I have two separate feature stores. Each store uses NGRX/Entity.

  1. Places
  2. Customer

I would like to create a selector that pulls data from both. Here's a simplified interface for both models:

export interface Place {
_id: string;
customer: string;
...
}

export interface Customer {
_id: string;
}

Customer Module

The Customer Feature has multiple stores. I import this feature into my customer module like so:

 StoreModule.forFeature(fromCustomer.customersFeatureKey, fromCustomer.reducers)

The customer feature state looks like this:


export interface CustomerState {
    [fromCustomers.customerFeatureKey]: fromCustomers.State; //I'm want pull data from here!!
    [fromPaymentMethods.paymentmethodFeatureKey]: fromPaymentMethods.State;
    [fromInvoices.invoiceFeatureKey]: fromInvoices.State;
    [fromSubscriptions.subscriptionFeatureKey]: fromSubscriptions.State;
}

// This is what I pass to the `forFeature` method in my customer module. 
export function reducers(state: CustomerState | undefined, action: Action) {
  return combineReducers({
    [fromCustomers.customerFeatureKey]: fromCustomers.reducer,
    [fromPaymentMethods.paymentmethodFeatureKey]: fromPaymentMethods.reducer,
    [fromInvoices.invoiceFeatureKey]: fromInvoices.reducer,
    [fromSubscriptions.subscriptionFeatureKey]: fromSubscriptions.reducer
  })(state, action);
}

If we examine the fromCustomers.State state, this is what that looks like:

export interface State extends EntityState<Customer> {
  selectedCustomerId: string | null; // --> This is the first piece of data I'm trying to select.
  loaded: boolean;
  loading: boolean;
  processing: boolean;
}
 
export const adapter: EntityAdapter<Customer> = createEntityAdapter<Customer>({
    selectId: customer => customer._id,
    sortComparer: false
});
 
export const initialState: State = adapter.getInitialState({
    selectedCustomerId: null,
    loaded: false,
    loading: false,
    processing: false
});

...

As we can see above, this were I finally setup the EntityAdapter. The important piece of data that I'm trying to select from this feature is: selectedCustomerId.

I have a selector in this feature that looks like this:

export const selectedCustomerId = createSelector(
    selectCustomersEntitiesState,
    (state) => state.selectedCustomerId
)

Place Module

Now that we have customer module summarized, let's move on to the Place Feature.

Similar to the customer module, I'm importing this into the place module file.

This is what my Place State looks like:

export interface PlaceState {
    [fromPlaces.placeFeatureKey]: fromPlaces.State;
    //[fromSearch.searchFeatureKey]: fromSearch.State; // This is where you should add search for assignments
}

export interface State extends fromRoot.State {
  [placesFeatureKey]: PlaceState;
}

// This is what I pass to the `forFeature` method in my place module. 
export function reducers(state: PlaceState | undefined, action: Action) {
  return combineReducers({
    [fromPlaces.placeFeatureKey]: fromPlaces.reducer,
  })(state, action);
}

If we dive down into the [fromPlaces.placeFeatureKey] you'll see where I'm setting up the EntityAdapter for the place model.

export interface State extends EntityState<Place> {
  selectedPlaceId: string | null;
  loaded: boolean;
  loading: boolean;
  processing: boolean;
}
 
export const adapter: EntityAdapter<Place> = createEntityAdapter<Place>({
    selectId: place => place._id,
    sortComparer: false
});
 
export const initialState: State = adapter.getInitialState({
    selectedPlaceId: null,
    loaded: false,
    loading: false,
    processing: false
});

I have a selector for all places:

export const {
  selectIds: selectPlaceIds,
  selectEntities: selectPlaceEntities,
  selectAll: selectAllPlaces, // --> This second thing I'm trying to select. 
  selectTotal: selectTotalPlaces,
} = fromPlaces.adapter.getSelectors(selectPlacesEntitiesState);

The selector from place feature that I'm interested in is selectAllPlaces.

I hope you're still with me. **Now that I've set up the background, I would like to explain what I what hope to do, but can't figure out how.

********** I want to do this: **********

export const selectPlacesForCurrentCustomer = createSelector(
   selectAllPlaces,
   selectedCustomerId,
   (places: Place, customer_id: string) => places.filter((place) => place.customer === customer_id)
) 

However when I do this, I get this error:

Argument of type 'MemoizedSelector<State, string, DefaultProjectorFn>' is not assignable to parameter of type 'Selector<State, string>'.


Solution

  • Problem Debugging

    Lets try to debug this code where the error is being thrown

    export const selectPlacesForCurrentCustomer = createSelector(
      selectAllPlaces,
      selectedCustomerId,
      (customer_id: string, places: Place[]) =>
        places.filter(place => place.customer === customer_id)
    );
    

    Below is a screenshot of IDE indicating the error is thrown in the line selectedCustomerId

    Initial Error

    In the error we also notice below line

    Property '[customersFeatureKey]' is missing in type 'import("/~/src/app/place/reducers/index").State' but required in type 'import("/~/src/app/customer/reducers/index").State'.

    Now lets try to Invert the parameters

    export const selectPlacesForCurrentCustomer = createSelector(
      selectedCustomerId,
      selectAllPlaces,
      (places: Place[], customer_id: string) =>
        places.filter(place => place.customer === customer_id)
    );
    

    Now we see the error is no longer on selectedCustomerId but now on selectAllPlaces and the error has changed

    Property '[placesFeatureKey]' is missing in type 'import("/~/src/app/customer/reducers/index").State' but required in type 'import("/~/src/app/place/reducers/index").State'.
    

    Inverted Parameters

    Explanation

    Below is the definition of createSelector()

    createSelector(selectors: [Selector<State, S1>, Selector<State, S2>], projector: (s1: S1, s2: S2) => Result): MemoizedSelector<State, Result>
    
    

    Notice something, State Selector<State, S1> and Selector<State, S2> have The same State

    Below is an extract from the docs to explain why this error is being thrown

    The createSelector can be used to select some data from the state based on several slices of the same state.

    Take note of the line several slices of the same state.

    So in your case you are passing two different State, State from places that has a '[placesFeatureKey]' and State from customers which has a '[customersFeatureKey]' which are incompatible

    Solution

    Option 1 - Typecast to the same type State

    
    export const selectPlacesForCurrentCustomer = createSelector(
      selectAllPlaces as MemoizedSelector<State, Place[]>,
      selectedCustomerId as MemoizedSelector<State, string>,
      (places: Place[], customer_id: string) =>
        places?.filter(place => place.customer === customer_id)
    );
    

    See sample demo of this solution

    Option 2 - Instead of creating a selector use piping in the observable stream

    You can simply employ piping to combine the two Streams in the component or a service

      allPlaces$ = this.store.select(selectAllPlaces);
      selectedCustomerId$ = this.store.select(selectedCustomerId);
      selectPlacesForCurrentCustomer$ = combineLatest([
        this.allPlaces$,
        this.selectedCustomerId$
      ]).pipe(
        map(([places, customer_id]) =>
          places.filter(place => place.customer === customer_id)
        )
      );
    
    

    See sample demo of this solution

    Option 3 - Create a selector that Selects the whole state and extract the relevant function

    export const selectPlacesForCurrentCustomer = (state: State) => {
      const places = selectAllPlaces(state as any);
      const customerId = selectedCustomerId(state as any);
      return places.filter(place => place.customer === customerId);
    };
    

    See sample demo