ngxtension / ngxtension-platform

Utilities for Angular
https://ngxtension.netlify.app/
MIT License
592 stars 87 forks source link

feat(signal-slice): pass initial state streams to source functions #486

Open joshuamorony opened 1 month ago

joshuamorony commented 1 month ago

Implementation of proposal discussed here: https://github.com/ngxtension/ngxtension-platform/issues/485

This PR is non-breaking, it adds onto the state object passed to sources but it does not change existing functionality

This adds the ability to use streams of the signal slices own state within sources (e.g. if there is an initial state property of myVal then a myVal$ stream will be available on the state object passed to a source), which allows a signalSlice to react to its own state changing without needing to use external subjects, or without using an actionSource which will lead to a more imperative style of code.

Here is an example of plain RxJS handling a pagination scenario in a way that is cleanly declarative (but perhaps not the most practical):

  pageNumber$ = new BehaviorSubject(1);
  itemFilter$ = new BehaviorSubject('');

  request$ = combineLatest([this.pageNumber$, this.itemFilter$]).pipe(
    switchMap(([pageNumber, itemFilter]) =>
      this.getPage(pageNumber, itemFilter).pipe(
        materialize(),
        filter((notification) => notification.kind !== 'C'),
      ),
    ),
    share(),
  );

  data$ = this.request$.pipe(
    filter((notification) => notification.kind === 'N'),
    dematerialize(),
  );

  error$ = this.request$.pipe(
    filter((notification) => notification.kind === 'E'),
    switchMap((notification) => of(notification.error)),
  );

My goal was to allow this style of code to be followed reasonably faithfully using only features of signalSlice. With the change in this PR, we are able to write code like this:

  private initialState = {
    pageNumber: 1,
    itemFilter: '',
    loading: true,
    error: null as string | null,
    data: null as string | null,
  };

  public state = signalSlice({
    initialState: this.initialState,
    sources: [
      (state) =>
        combineLatest([state.pageNumber$, state.itemFilter$]).pipe(
          switchMap(([pageNumber, itemFilter]) =>
            this.getPage(pageNumber, itemFilter).pipe(
              map((data) => ({ data, loading: false })),
              startWith({ loading: true }),
              catchError((error) => of({ error })),
            ),
          ),
        ),
    ],
    actionSources: {
      offsetPage: (state, action$: Observable<number>) =>
        action$.pipe(
          map((offset) => ({ pageNumber: state().pageNumber + offset })),
        ),
      setFilter: (_, action$: Observable<string>) =>
        action$.pipe(map((itemFilter) => ({ itemFilter }))),
    },
  });

Note specifically that no external subjects are required here. Our action sources update the state values, and our pre-defined sources can react to those changes via the pageNumber$ and itemFilter$ streams that have been added.

The discussion linked also discussed potentially creating automatic setter action sources for the initial state, so that you could update them without needing to manually define an actionSource as I have done above. This PR does not include that change, if we end up deciding on doing that I will open a separate PR for that.