Ngrx best practice in multiresponse - having multiple effects, actions and reducers or single effect, action and reducer

685 Views Asked by At

I am facing a problem regarding where to put some business logic, either in effects and have multiple actions which each one will map to a reducer, or having a single action and do the logic in reducers.

I will explain the situation:

We have a GraphQL request which is kinda multiresponse, it retrieves customer extra info and products based on the customer id. So from the service, I am returning something like that:

getCustomerDetail(): Observable<CustomerDetail> {
  ... graphql request
  .map(response => {
    return {
      customerDetail: response.customerDetail,
      products: response.products
    }
  })
}

This observable will emit twice, once for customerDetail and products as undefined, and once with the opposite, products and customerDetail as undefined.

This service method is called in an effect returning a success or error action, and we have several actions linked to it with filter in order to dispatch a concrete action (i.e. one for customer detail and another one for products)

getCustomerData$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(CustomerActions.detailPageOpened),
      mergeMap((action) => {
        return this.customerDatasource
          .getCustomerDetail({ id: action.id })
          .pipe(
            map((res) => {
              if (!res?.error) {
                return CustomerActions.getCustomerDataSuccess({
                  data: res
                });
              }

              // ... getErrorActionByPath find an action associated to the path property in error     (customerDetail or products)
              
              const errorAction = this.getErrorActionByPath(
                res?.error,
                action.interactionId
              );
              
              return errorAction;
            })
          );
      })
    );

 getCustomerDetail$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(CustomerActions.getCustomerDataSuccess),
      filter((action) => action.data.customerDetail !== undefined),
      map((action) => {
        return CustomerActions.getCustomerDetailSuccess({
          customerDetail: action.data.customerDetail
        });
      })
    );
  });

 // ... another effect like this one for products

Each action dispatched by the filtered effects will be linked to a reducer to update the state with the value from the action.

In terms of best practice, my question is: what would be the best approach for doing such a thing?

Returning the same action in each query response and map it to a different reducer (it will increase de boilerplate code as I would need 2 actions per data emitted, success and error)?

Or dispatching always the same action linked to a single reducer which responsability would be to update the concrete properties (it would turn into a multiple call to the same reducer and we may lose the tracking of the actions as it would be always the same action)?

Many thanks in advance!

3

There are 3 best solutions below

4
timdeschryver On BEST ANSWER

I'm not sure if I understand the question correctly, but... It's recommended to use actions as unique events, for your case this means a success and error action for each API response. This practice is called good action hygiene, for more info see https://ngrx.io/guide/eslint-plugin/rules/good-action-hygiene

The dispatched action can then be handled by one or multiple reducers and/or effects.

1
hmartini On

After working with NGRX / RxJs for a few years now, my advice for you would be to keep all NGRX elements (reducers, selectors and effects) clean. If you don't, you'll end up with different areas where your business logic resides - sometimes it's in effects, sometimes in reducers, sometimes in selectors. In the end, you will probably have a modular system, but each individual module can seem very unorganised.

My second advice is, you should use RxJs operators and custom operators whenever you can. This also keeps your code clean and you can reduce wild condition trees.

Actually, business logic is (with a few exception) almost always map or filter logics, so I always include corresponding services (or utils) in my projects:

+state
|
-- *.actions.ts
-- *.effects.ts
-- *.selectors.ts
-- *.reducer.ts
-- *.mappers.ts
-- *.filters.ts

So here my refactoring approach, based on your code. I added some simplifications for readability:

Api Request

// *.service.ts
getCustomerDetail$(): Observable<CustomerDetail> {
   return graphqlRequest.pipe(mapToCustomerDetailsResponse());
}

// *.mappers.ts
export const mapToCustomerDetailsResponse = () => map(({ customerDetails, products }: SomeResponseType) => ({
  customerDetails,
  products
}));

Effect Handling

// *.effects.ts
getCustomerData$ = createEffect(() =>
this.actions$.pipe()
    ofType(CustomerActions.detailPageOpened),
    exhaustMap(({ id, interactionId }) => 
        this.customerDataSource.getCustomerDetails$({ id })).pipe(
            map((res) => iif(() =>
               !res?.error,
               of(this.CustomerActions.getCustomerDataSuccess({ data: res })),
               of(this.CustomerActions.getErrorActionByPath(res?.error, interactionId ))
            ))
        )  
    )   
);

getCustomerDetails$ = createEffect(() =>
    this.actions$.pipe(
        ofType(CustomerActions.getCustomerDataSuccess),
        filterCustomerDetail(),
        map(({ customerDetail }) => CustomerActions.getCusterDetailSuccess({ customerDetail })))
    );

//*.filters.ts
export const filterCustomerDetails = () => filter(({ data: { customerDetail }}) => customerDetails !== undefined);

Maybe the syntax is not 100% correct, but I hope you get an idea. In that way, it's allways clear, where the magic happens. The ngrx elements should do what they were created to do => manage the page state and nothing more.

2
wlf On

Returning two responses from the query is what complicates this, otherwise it would be straightforward.

I would suggest a solution broadly similar to what you proposed but combining the effects into one and therefore reducing the overall number of actions. Note that there is only one real side effect here which is calling the API, I generally model one effect per side effect but nevertheless using effects solely to map actions is a valid pattern.

I would use these actions (based on events):

  • Detail Page Opened
  • Customer Details Loaded
  • Products Loaded
  • Customer Query Errored (using a single action here as assumed/appears that the query will error without reference to a particular response)

It would follow to have a single effect (untested):

customerQuery$ = createEffect(() =>
  this.actions$.pipe(
    ofType(CustomerActions.detailPageOpened),
    mergeMap((action) => this.customerDatasource.getCustomerDetail({ id: action.id }).pipe(
      switchMap(() => 
        iif(() => res?.error, 
          of(CustomerActions.customerQueryErrored({ error: res?.error })),
          [
            of(res.customerDetail).pipe(
              filter((detail) => !!detail), 
              map((detail) => CustomerActions.customerDetailLoaded({ detail }),
            of(res.products).pipe(
              filter((products) => !!products), 
              map((products => CustomerActions.productsLoaded({ products })
          ][0]  // <<-- resulting array will have the single action we want
        )
      )
    )
  )
)

Reducer:

export const reducer = createReducer(
  initialState,
  // reset for new request
  on(CustomerActions.detailPageOpened, (state) => ({
    ...state,
    detail: null,
    products: null,
    error: null
  })),
  // set detail only
  on(CustomerActions.customerDetailLoaded, (state, { detail }) => ({
    ...state,
    detail,
  })),
  // set products only
  on(CustomerActions.detailPageOpened, (state, { products }) => ({
    ...state,
    products
  })),
  on(CustomerActions.detailPageOpened, (state, { error}) => ({
    ...state,
   error
  })),

Overall this largely achieves the 'unique event' per action pattern and handles the quirky double response from the API, in a similar manner to when using a normal API, the differences being:

  • returning success/loaded action based on response data
  • returning error action based on response data rather than the more common catchError