angular-architects / ngrx-toolkit

Various Extensions for the NgRx Signal Store
MIT License
104 stars 17 forks source link

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

Open rainerhahnekamp opened 6 months ago

rainerhahnekamp commented 6 months ago

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'})))
      }
    }
  })
})
rosostolato commented 6 months ago

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?

rainerhahnekamp commented 6 months ago

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.

markostanimirovic commented 5 months ago

Leaving my suggestion here: https://x.com/MarkoStDev/status/1753556633089704059?s=20