Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Add ability to create functional effects #3668

Closed
1 of 2 tasks
markostanimirovic opened this issue Nov 17, 2022 · 20 comments · Fixed by #3669
Closed
1 of 2 tasks

RFC: Add ability to create functional effects #3668

markostanimirovic opened this issue Nov 17, 2022 · 20 comments · Fixed by #3669

Comments

@markostanimirovic
Copy link
Member

markostanimirovic commented Nov 17, 2022

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

effects

Information

Add the ability to create functional effects (without classes). Similar to functional guards, we would be able to use inject within the createEffect function:

// users.effects.ts

export const loadUsers = createEffect(
  (actions$ = inject(Actions), usersService = inject(UsersService)) => {
    return actions$.pipe(
      ofType(UsersPageActions.opened),
      exhaustMap(() => /* ... */)
    );
  },
  { functional: true }
);

export const displayErrorAlert = createEffect(
  () => {
    return inject(Actions).pipe(
      ofType(UsersApiActions.usersLoadedFailure),
      tap(({ error }) => alert(error.message))
    );
  },
  { functional: true, dispatch: false }
);

In order not to break the current behavior of the createEffect function, for functional effects we need to add functional: true in the effect config. They can be registered as follows:

import * as usersEffects from './users.effects.ts';

bootstrapApplication(AppComponent, {
  providers: [
    provideEffects(usersEffects),
  ],
});

Testing

To test functional effects, we don't need TestBed to provide services if we inject all dependencies as effect factory parameters (see loadUsers effect above). Unit test for this effect can look like this:

it('loads users successfully', () => {
  usersEffects.loadUsers(actionsMock, usersServiceMock).subscribe((action) => {
    expect(serviceMock, 'getAll').toHaveBeenCalled();
    expect(action).toEqual(/* ... */);
  });
});

This feature will not remove the ability to create effects within classes

We'll be still able to define effects within classes:

@Injectable()
export class UsersEffects {
  readonly displayErrorAlert = createEffect(() => {
    return this.actions$.pipe(
      ofType(UsersApiActions.usersLoadedFailure),
      tap(({ error }) => alert(error.message))
    );
  }, { dispatch: false });

  constructor(private readonly actions$: Actions) {}
}

and register them via provideEffects (or EffectsModule.forRoot/forFeature):

provideEffects(UsersEffects);

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

  • Yes
  • No
@manfredsteyer
Copy link

I love it for two reasons:

  • It nicely fits to other trends in Angular like functional interceptors, guards, and resolvers
  • For me, it also removes an ambiguity: In the past, we used to call two different things an effect: the service (class), and the property set up with createEffect

@Armenvardanyan95
Copy link
Contributor

Is it possible to make the first argument for the callback function the injected Actions class instance by default? Something like

const myEffect = createEffect(actions$ => actions$.pipe(/* etc) */)

Of course I think the users themselves will be able to do this manually, but would be nice to have.

Other than this. absolutely love the idea, removes boilerplate, removes ambiguity, also easier to explain in general I think

@Felexonyango
Copy link

Felexonyango commented Nov 17, 2022

This is cool ,I can't wait

@phhien203
Copy link

It's such a nice idea. I'm just curious whether it's possible to have two effects in two different files which share the same name, like this

// foo.effects.ts
export const fooBarEffect = createEffect(() => {
  // ...
});

// more effects

// bar.effects.ts
export const fooBarEffect = createEffect(() => {
  // ...
});

// more effects
import * as fooEffects from './foo.effects.ts';
import * as barEffects from './bar.effects.ts';

bootstrapApplication(AppComponent, {
  providers: [
    provideEffects(fooEffects, barEffects),
  ],
});

@stefanoslig
Copy link
Contributor

That's very nice!
What about the effect's lifecycle hooks? I find them very useful in some projects! It will be possible to use them only in class effects?

@brandonroberts
Copy link
Member

@stefanoslig yes that will still be possible. Being able to use effects in a class isn't changing at all.

@timdeschryver
Copy link
Member

@phhien203 yes, that would be possible.

@phhien203
Copy link

Thank @timdeschryver

@markostanimirovic
Copy link
Member Author

RFC description updated ☝️

@markostanimirovic
Copy link
Member Author

Is it possible to make the first argument for the callback function the injected Actions class instance by default? Something like

const myEffect = createEffect(actions$ => actions$.pipe(/* etc) */)

@Armenvardanyan95

I'd rather not provide Actions by default because we'd need to provide them as well when testing effects that don't use actions. In my opinion, there is not much difference between:

(actions$ = inject(Actions)) => actions$.pipe(/* ... */)
// or
() => inject(Actions).pipe(/* ... */)

compared to:

(actions$) => actions$.pipe(/* ... */);

while the first option gives more flexibility.

@Armenvardanyan95
Copy link
Contributor

@markostanimirovic I get you idea. How about a factory function that allows creating predefined effect creator functions? Like this:

const createUserEffects = createEffectFactory([Actions, UserService, SomeOtherService]);

const loadUsers = createUserEffects((actions, userService, someOtherService) => {
  return actions.pipe(
    ofType(someAction);
    // and so on
  );
})

Just feels kinda neater. In testing, we can still provide whatever mock values we want by calling it with mock args, can we? I think we should be able to do that

@liesahead
Copy link

@markostanimirovic I get you idea. How about a factory function that allows creating predefined effect creator functions? Like this:

const createUserEffects = createEffectFactory([Actions, UserService, SomeOtherService]);

const loadUsers = createUserEffects((actions, userService, someOtherService) => {
  return actions.pipe(
    ofType(someAction);
    // and so on
  );
})

Just feels kinda neater. In testing, we can still provide whatever mock values we want by calling it with mock args, can we? I think we should be able to do that

Reminds me of angularjs 🤕

@Armenvardanyan95
Copy link
Contributor

@liesahead how come? We use factories in Angular and also in NgRx all the time. We have selector factory functions, for example. This is not a separate configuration, but a factory function to return a preconfigured effect creator

@liesahead
Copy link

@liesahead how come? We use factories in Angular and also in NgRx all the time. We have selector factory functions, for example. This is not a separate configuration, but a factory function to return a preconfigured effect creator

But in each effect you would need to inject all services as function arguments createUserEffects((actions, userService, someOtherService) at least from what I see, this reminds me of angularjs. If you inject 10 services in your effect then it will be 10 arguments listed. Looks bulky.

@Armenvardanyan95
Copy link
Contributor

Armenvardanyan95 commented Nov 24, 2022

@liesahead
While I think what you mentioned is a bit limited (you usually use 1-2 services in 1 effects class from my experience), this still can be mitigated using an object instead of an array:

const createUserEffects = createEffectFactory({
  actions$: Actions,
  userService: UserService,
  someOtherService: SomeOtherService,
});

const loadUsers = createUserEffects({actions, userService}) => { 
  // use only the services you need for a particular effect
  return actions.pipe(
    ofType(someAction);
    // and so on
  );
})

@liesahead
Copy link

@Armenvardanyan95 , this one I like more 😄 anyway, if this doesn't replace current behavior (with services injection in the constructor) and comes as an additional feature then I think it might be a good idea for some people.

@achraf-elk
Copy link

my code coverage is nagging about uncovered branches regarding the injected dependecies, any clew how to mitigate that?

image
this is my tests:

  describe('loadMecList', () => {
    it('should return loadMecListSuccess action, with the list, on success', () => {
      const mecList: Mec[] = [{} as Mec];
      const action = Actions.initCommandeManagement();
      actions$ = of(action);
      mecListServiceMock.loadMECsNonPagine.mockReturnValue(of(mecList));

      Effects.loadMecList$(actions$, mecListService).subscribe((resultAction) => {
        expect(mecListService.loadMECsNonPagine).toHaveBeenCalled();
        expect(resultAction).toEqual(
          Actions.loadMecListSuccess({
            mecList,
          }),
        );
      });
    });

    it('should return a CommandeCreationActions.loadFailure action, with an error, on failure', () => {
      const error = new HttpErrorResponse({});
      const action = Actions.initCommandeManagement();

      actions$ = of(action);
      mecListServiceMock.loadMECsNonPagine.mockReturnValue(throwError(error));

      Effects.loadMecList$(actions$, mecListService).subscribe((resultAction) => {
        expect(mecListService.loadMECsNonPagine).toHaveBeenCalled();
        expect(resultAction).toEqual(Actions.loadFailure({ error }));
      });
    });
  });

@jahusa02
Copy link

jahusa02 commented Dec 8, 2023

my code coverage is nagging about uncovered branches regarding the injected dependecies, any clew how to mitigate that?

image this is my tests:

  describe('loadMecList', () => {
    it('should return loadMecListSuccess action, with the list, on success', () => {
      const mecList: Mec[] = [{} as Mec];
      const action = Actions.initCommandeManagement();
      actions$ = of(action);
      mecListServiceMock.loadMECsNonPagine.mockReturnValue(of(mecList));

      Effects.loadMecList$(actions$, mecListService).subscribe((resultAction) => {
        expect(mecListService.loadMECsNonPagine).toHaveBeenCalled();
        expect(resultAction).toEqual(
          Actions.loadMecListSuccess({
            mecList,
          }),
        );
      });
    });

    it('should return a CommandeCreationActions.loadFailure action, with an error, on failure', () => {
      const error = new HttpErrorResponse({});
      const action = Actions.initCommandeManagement();

      actions$ = of(action);
      mecListServiceMock.loadMECsNonPagine.mockReturnValue(throwError(error));

      Effects.loadMecList$(actions$, mecListService).subscribe((resultAction) => {
        expect(mecListService.loadMECsNonPagine).toHaveBeenCalled();
        expect(resultAction).toEqual(Actions.loadFailure({ error }));
      });
    });
  });

Did you solve this? I have the exact problem atm

@marcschaller
Copy link

Yes, you can use the runInInjectionContext method of the TestBed:

    it('should return loadMecListSuccess action, with the list, on success', () => {
      const mecList: Mec[] = [{} as Mec];
      const action = Actions.initCommandeManagement();
      actions$ = of(action);
      mecListServiceMock.loadMECsNonPagine.mockReturnValue(of(mecList));

      expect(TestBed.runInInjectionContext(Effects.loadMecList$)).subscribe((resultAction) => {
        expect(mecListService.loadMECsNonPagine).toHaveBeenCalled();
        expect(resultAction).toEqual(
          Actions.loadMecListSuccess({
            mecList,
          }),
        );
      });
    });

Note that you have to use the provideMockActions function provided by ngrx and a whole configured TestBed for this to work (as you don't pass your mocks directly to your effect).

@jahusa02
Copy link

jahusa02 commented May 8, 2024

Yes, you can use the runInInjectionContext method of the TestBed:

    it('should return loadMecListSuccess action, with the list, on success', () => {
      const mecList: Mec[] = [{} as Mec];
      const action = Actions.initCommandeManagement();
      actions$ = of(action);
      mecListServiceMock.loadMECsNonPagine.mockReturnValue(of(mecList));

      expect(TestBed.runInInjectionContext(Effects.loadMecList$)).subscribe((resultAction) => {
        expect(mecListService.loadMECsNonPagine).toHaveBeenCalled();
        expect(resultAction).toEqual(
          Actions.loadMecListSuccess({
            mecList,
          }),
        );
      });
    });

Note that you have to use the provideMockActions function provided by ngrx and a whole configured TestBed for this to work (as you don't pass your mocks directly to your effect).

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.