angular-architects/ngrx-toolkit

RFC: `withRedux`: global action dispatching / inter-store communication

Opened this issue · 3 comments

Update 1 (21.12 (17.01)): Self-dispatching external actions

Use Case

withRedux integrates the Redux pattern into a signalStore. Actions are currently methods of the store instance which means we don't have a global dispatching mechanism.

Example:

const FlightStore = signalStore({
  withEntities<Flight>(),
  withRedux({
    actions: {
      search: payload<{from: string, to: string}>()
    },
    // ...
  })
})

const BookingStore = signalStore({
  withEntities<Booking>(),
  withRedux({
    actions: {
      bookFlight: payload<{from: string, to: string}>()
    },
    // ...
  })
})

It is not possible for bookingStore to have a reducer on FlightStore::search. It would be possible though to dispatch search via an effect or withMethods:

const BookingStore = signalStore({
  withEntities<Booking>(),
  withRedux({
    actions: {
      bookFlight: payload<{from: string, to: string}>()
    },
    effect(actions, create) {
      const flightStore = inject(FlightStore);
      return {
        bookFlight$: create(actions.bookFlight).pipe(tap(() => flightStore.search({from: 'London', to: 'Vienna'})))
      }
    }
  })
})

That works as long as we have only global stores. As soon as a global store, wants to listen to actions from al local one, the DI will fail.

Proposed Approach:

Here's a design which would introduce global actions for global and local SignalStores.

We require two new features:

  1. Global (self-dispatching) Actions
  2. reducer option to consume "instance-only" dispatched actions or global ones.

To reference actions without a store's instance, we need to be able to externalize them. That is exactly what we have with the Global Store:

export const flightActions = createActions('flights', {search: payload<{from: string, to: string}>()}) 

const FlightStore = signalStore({
  withEntities<Flight>(),
  withRedux({
    actions: flightActions
    // ...
  })
})

// ...

If another Signal Store wants to react to that action, it can do:

withRedux({
  reducer(, on) {
    on(flightActions.search, (state, {from, to}) => patchState(state, {loading: true}));
  },
  // ...
})

It will still be possible to define actions inside withRedux::actions.

External actions are self-dispatching. They do not require a "DispatcherService", like the Store in the Global Store:

withRedux({
  effect(actions, create) {
    return {
      bookFlight$: create(actions.bookFlight).pipe(tap(() => flightActions.search({from: 'London', to: 'Vienna'})))
    }
  }
})

In contrast to the global store, Signal Stores can be provided multiple times. That means we have more than one instance.

There are use cases, where a "local Signal Store" only needs to consume actions dispatched by its instance. The on method will get an optional option to

const FlightStore = signalStore({
  withEntities<Flight>(),
  withRedux({
    actions: {
      searched: payload<{flights: Flight[]}>()
    },
    reducer(actions, on) {
      on(actions.searched, (state, {flights}) => patchState(state, {flights}), {globalActions: false})
    }
    // ...
  })
})

Actions have to be unique per Store class.

Abandoned (simpler) Approach: storeBound actions & inject in reducer

const BookingStore = signalStore({
  withEntities<Booking>(),
  withRedux({
    actions: {
      bookFlight: payload<{from: string, to: string}>()
    },
    reducer(actions, on) {
      const filghtStore = inject(FlightStore);
      on(flightStore.search, (state) => patchState(state, {loading: true}));
    },
    effect(actions, create) {
      const flightStore = inject(FlightStore);
      return {
        bookFlight$: create(actions.bookFlight).pipe(tap(() => flightStore.search({from: 'London', to: 'Vienna'})))
      }
    }
  })
})

I prefer this approach and have a question: if a store has multiple instances, it means you need to add the dispatcher to the instance provider, right? How would you do that?

For compontent provided stores, it would look like this:

const FlightStore = signalStore({
  withEntities<Flight>(),
  withRedux({
    actions: {
      searched: payload<{flights: Flight[]}>()
    },
    reducer(actions, on) {
      on(actions.searched, (state, {flights}) => patchState(state, {flights}), {globalActions: false})
    }
    // ...
  })
})

const flightStore = new FlightStore();
flightStore.searched({flights: []});

So you would have to have access to the store instance in order to dispatch a local-managed action.