ngrx / platform

Reactive State for Angular
https://ngrx.io
Other
8.03k stars 1.97k forks source link

@ngrx/signals: Add support for the Redux Pattern #4031

Closed rainerhahnekamp closed 1 year ago

rainerhahnekamp commented 1 year ago

Which @ngrx/* package(s) are relevant/related to the feature request?

store

Information

The $update function allows to run asynchronous code, multiple state updates, and provides access to dependency injection. On the other hand, the global store comes with reducers, which are very limited in their possibilities. Those limitations are highly appreciated in a lot of scenarios because they enforce a certain way of “how to do things”.

Signal and global store can be used together. The following arguments speak against it:

I would propose a redux extension to the SignalStore which provides the same elements (actions, reducer, effects, no $update) as the global store but adds the extensibility of the SignalStore.

SignalStore, would be one library, offering three different flavours for state management, where it is very easy to upgrade/switch:

One also has to provide a possibility to integrate an existing NgRx Store codebase into the SignalStore. That could be done via an own compat library. Automatic migration scripts should also be discussed.

Example:

signalStore(
  withFeature('users', { users: [], loading: false }),
  withActions({
    load: props<{ page: 1 }>(),
    loaded: props<{ holidays: User[] }>,
  }),
  withReducers({
    loaded: (action: any, state: UsersState) => ({
      ...action,
      users: action.users,
    }),
  }),
  withEffects((actions) => {
    const userService = inject(UserService);

    return {
      load: async (action: { page: number }) =>
        actions.loaded(await userService.load(action.page)),
    };
  })
);

To avoid a mixup, withFeature should disable options like withMethods

If accepted, I would be happy to provide an initial PR.

Describe any alternatives/workarounds you're currently using

No response

I would be willing to submit a PR to fix this issue

markostanimirovic commented 1 year ago

We don't plan to support Redux-based APIs as a part of the core signals package. I'm going to close this issue as we discussed.

rosostolato commented 9 months ago

@rainerhahnekamp I'm creating this library to implement redux pattern features for signal store. I will let you know when I publish it.

rainerhahnekamp commented 9 months ago

@rosostolato thanks for the info.

A library of that kind does already exist: https://github.com/angular-architects/ngrx-toolkit

Don't you want to join forces? There is still some work to do.

rosostolato commented 9 months ago

@rainerhahnekamp yeah, I saw the project later but the way the library is going right now is a bit away from the main redux pattern that NgRx created. My version would reuse some NgRx functions like the createReducer and implement actions similarly. For example, I can separate the actions, reducers and effects from the main signal store object and file. I'm going to implement the example app on my github project today and will share it with you to understand better what I'm talking about.

I need to write this library because it helps me migrating my ngrx stores very quickly. I have already implemented that in my project, now I'm just creating a separate npm package for it. If you find value in the current work I'm doing, I would be very happy to merge the project into the ngrx-toolkit library, that's totally fine to me.

Thank you!

rainerhahnekamp commented 9 months ago

Got you. It sounds very similar to this issue here: https://github.com/angular-architects/ngrx-toolkit/issues/8

Please keep me updated.

rosostolato commented 9 months ago

@rainerhahnekamp I added a simple entity example with the redux pattern I'm developing. You can check it here: https://github.com/rosostolato/ngrx-signals-redux/blob/master/src/app/state/posts/posts.store.ts

The effects are the most complicated part, I can't create a withEffects feature because I won't be able to instantiate the Effects class if it depends back on the signal store, it will end up in circular injection. The solution was to create a Directive that can be set to the hostDirectives of the component to inject the effects after it inits.

rainerhahnekamp commented 9 months ago

Hi, I haven't looked into it in detail, but that sounds slightly complicated and hard to do.

rosostolato commented 9 months ago

Really? Do you mean to use it on your app?

The idea is to follow the same pattern as @ngrx/store. Basically, you create the reducer and apply it to the store with withReducer.

// counter.actions.ts
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');

// counter.reducer.ts
export const counterReducer = createReducer(
  { value: 0 },
  on(increment, ({ value }) => ({ value: value + 1 })),
  on(decrement, (state) => ({ value: state.value - 1 })),
  on(reset, () => initialState)
);

// counter.store.ts
export const CounterStore = signalStore(
  withReducers(counterReducer),
}

To dispatch the actions, you can inject the Actions dispatcher in your component or create store methods with the withDispatchers feature.

// counter.store.ts
export const CounterStore = signalStore(
  withReducers(counterReducer),
  withDispatchers((dispatch) => ({
    increment: () => dispatch(increment()),
    decrement: () => dispatch(decrement()),
    reset: () => dispatch(reset()),
  }))}

You will need to provide Actions to be injected in the same context as your store. To help you with that we have the provideStore function that is only a shortcut to provide the Store and Actions together.

// counter.component.ts
@Component({
  standalone: true,
  selector: 'app-counter',
  providers: [provideStore(CounterStore)],
  imports: [],
  template: `
    <h1>Counter</h1>
    <p>Current Count: {{ counter.value() }}</p>
    <p>Is Even: {{ counter.isEven() }}</p>

    <button (click)="counter.increment()">Increment</button>
    <button (click)="counter.decrement()">Decrement</button>
    <button (click)="counter.reset()">Reset Counter</button>
  `,
})
export class CounterComponent {
  readonly counter = inject(CounterStore);
}

It's very similar to NgRx store, it even uses the same functions.

rosostolato commented 9 months ago

For selectors, you can create computed signals using withComputed, or to follow a more ngrx selectors approach you can use our own createSelector which behaves similarly to ComponentStore.select, allowing you to select signals or observables.

export const CounterStore = signalStore(
  withReducers(counterReducer),
  withSelectors(({ value }) => ({
    isEven: createSelector(value, (value) => value % 2 === 0),

    // for example, you could select any external signal or observable here too
    fooBar: createSelector(inject(otherStore).foo, inject(otherService).bar$, (foo, bar) => foo + bar),
    // foo is a signal and bar is an observable.
  })),
);

For effects, I wished we could inject the Effects in the store but it would end up in a circular dependency injection if you try to inject the store in the Effects.

So, the workaround was to create a normal Effects class with some effects created with createEffect and provide them on the same provideStore function and add the EffectsDirective to the host to inject the Effects for you.

// counter.component.ts
@Component({
  standalone: true,
  selector: 'app-counter',
  providers: [provideStore(CounterStore, CounterEffects)], // add effects here
  hostDirectives: [EffectsDirective], // add `EffectsDirective` here to auto inject the provided Effects.
  imports: [],
  template: `
    // ...
  `,
})
export class CounterComponent {
  readonly counter = inject(CounterStore);
}