angular-redux / store

Angular 2+ bindings for Redux
MIT License
1.34k stars 202 forks source link

Help: Code splitting multiple Epics (if I am using them right) #305

Closed gregkopp closed 7 years ago

gregkopp commented 7 years ago

I have been working on an Angular2 application as a learning project. It has many small components, each with it's own service, reducer and actions. I had originally written it using @ngrx, but have been spending the last few days learning and refactoring it for ng2-redux, as that is what my employer is using for a new project to which I am hoping to get assigned.

However, I am confused on how to use Epics properly. My previous application relied on Side Effects and was working quite well, and I am having difficulty understanding how to accomplish the same thing in ng2-redux.

For example, my main app is a zoo animal management application, and each kind of animal has it's own code. The animals are retrieved via individual calls to an API. Once the application starts, an action is dispatched from the application level to load data (i.e.: '[App] Load Animal Data'). Each animal subsection would "listen" for this action and call the appropriate service. With @ngrx, I simply added an Injectable class and decorated a function as an Effect:

@Injectable()
export class ElephantEffects {

  constructor(
    private actions$: Actions,
    private actions: ElephantActions,
    private service: ElephantDataService
  ) { }

  @Effect() loadData = this.actions$
    .ofType(AppActions.LOAD_DATA)
    .switchMap(x => this.service.getAll())
    .map(g => this.actions.loadSucceeded(g));

I then exported an array of effects:

export const EFFECTS = [
  EffectsModule.run(ElephantEffects),
  EffectsModule.run(LionEffects),
];

This final collection was declared as an import in the top level Zoo module.

@NgModule({
  imports: [
    ...EFFECTS
  ]
})
export class AppModule { }

Simple enough in @ngrx. Seems to be a different level of difficulty with ng2-redux.

I've looked at the docs for combineEpics and even adding Epics asynchronously, but it's not clicking for me for some reason. What I don't want to have to do is import each of these in the top level, and declare each Epic in the configureStore call in the top level component.

I've tried creating an Epic like this:

@Injectable()
export class ElephantEpics {
  constructor(
    private appActions: AppActions,
    private service: ElephantService) { }

  loadData = (action$: ActionsObservable<AppActions>) => {
    return action$.ofType(AppActions.LOAD_DATA)
      .flatMap(() => this.service.getAll());
  }
}

But have't had any luck actually getting this into the middleware. Plus, I would have to define every class and function in the App Module, from what I am reading. I'd much rather look at combining these from level to level. Should I need to add a new side effect, I would only need to add it at it's lowest level.

Looking for some guidance and suggestions. What am I missing here?

gregkopp commented 7 years ago

I believe I have a solution to this. However, I am still running into some issues returning a new action after an asynchronous call to a service, but that is just me not fully understanding RxJs. I will post up a solution here when I get this figured out.

SethDavenport commented 7 years ago

Hey sorry for the delay - I'll try and look into this more tonight.

SethDavenport commented 7 years ago

Here's a redux-observable recipe for async epic loading: https://github.com/redux-observable/redux-observable/blob/bc26db5d47d65f57a9d52736a6b2dde102f8733e/docs/recipes/AddingNewEpicsAsynchronously.md

Effectively they're creating a plain root epic, and keeping a handle to it so they can merge other epics into it at runtime.

We should cook up an ng2-redux version of this recipe in our docs; I'd be interested in taking a look at your solution if you're willing to share.

We also have some plans cooking to make this easier but they're not quite ready for prime-time yet.

As for your second question: the actual epic implementations themselves should be almost completely the same as for ngrx/Effects; my understanding is that Effects was inspired by redux-observable to begin with.

Did you try with the same operators you were using before in ngrx? e.g.:

//...
import { Action } from 'redux';

@Injectable()
export class ElephantEpics {
  constructor(
    private actions: AppActions,
    private service: ElephantService) { }

  loadData = (action$: Observable<Action>) => $action
    .ofType(AppActions.LOAD_DATA)
    .switchMap(x => this.service.getAll())
    .map(g => this.actions.loadSucceeded(g)); // Assumes loadSucceeded(g) returns an Action
}
gregkopp commented 7 years ago

Here is what I have come up with so far. This is incomplete code (only snippets), and may not even be fully functional. But the idea is to build up Epics at each level of the application, so the root level component doesn't need to know about all the details. I looked at how to use combineEpics and this is what is working for the most part. My biggest issue is my lack of experience with RxJs. Knowing where to use map, vs. switchMap, etc. is where I need to work on. My current issue is it seems my API service is not being called. Ugh.

These are the Epics for the Elephant module. I combine them all and expose them as a public property:

@Injectable()
export class ElephantEpics {

    public epics: Epic<any>;

    constructor(
        private actions: ElephantActions,
        private service: ElephantService) {

        this.epics = combineEpics(
            this.loadAll,
            this.loadElephants,
            this.addElephant,
            this.editElephant,
            this.deleteElephant
        );

    }

    loadAll = (action$: ActionsObservable<any>): any => {
        return action$.ofType(AppActions.LOAD_DATA)
            .mapTo(this.actions.load());
    };

    loadElephants = (action$: ActionsObservable<any>): any => {
        return action$.ofType(ElephantActions.LOAD_DATA)
            .mergeMap(action =>
                this.load() // returns all elephants
                    .map((response: any) => this.actions.loadSucceeded(response))
                    .catch((err: any) => Observable.of(this.actions.loadFailed(err)))
            );
    };

    addItem = (action$: ActionsObservable<any>): any => {
        return action$.ofType(ElephantActions.ADD)
            .mergeMap(action =>
                this.add(action.payload) // returns the added elephant (updated Id, etc.)
                    .map((response: any) => this.actions.addSucceeded(response))
                    .catch((err: any) => Observable.of(this.actions.addFailed(err)))
            );
    };

    // Edited for brevity

The Zoo Epics just compile each of the Epics from each animal module:

@Injectable()
export class ZooEpics {

    public epics: Epic<any>;

    constructor(
        private elephantEpics: ElephantEpics,
        private lionEpics: LionEpics {

        this.epics = combineEpics(
            this.elephantEpics.epics,
            this.lionEpics.epics
        );
    }
}

We could continue this up the chain until we get to the root component:

@Component({
    selector: 'my-app',
    templateUrl: './app.component.html',
})
export class AppComponent implements OnInit, OnDestroy {

    constructor(
        private ngRedux: NgRedux<IAppState>,
        private devTool: DevToolsExtension,
        private zooEpics: ZooEpics
        ) {

        const epics = [
            createEpicMiddleware<any>(this.zooEpics.epics)
        ];

        this.ngRedux.configureStore(
            rootReducer,
            {},
            [
                ...epics, 
                createLogger(), 
                thunk
            ],
            [
                devTool.isEnabled() ? devTool.enhancer() : f => f
            ]);

    // Edited for brevity

My actual test app has a couple of extra levels in here as well.

I think the biggest difference between NgRx Effects and Epics, is the Epic must return another action, where Effects did not. At least that;s what I seem to be finding out.

gregkopp commented 7 years ago

These Epics are still driving me nuts. Because they require me to return a new Action, as an Observable, everything ends up being an Observable and nothing actually subscribes to it. It gets to a point, and then sits there, waiting for something to ask for the data.

Again, this is more because of my ignorance of Typescript and Angular (still new to this framework). In the above code:

    loadElephants = (action$: ActionsObservable<any>): any => {
        return action$.ofType(ElephantActions.LOAD_DATA)
            .mergeMap(action =>
                this.load() // returns all elephants
                    .map((response: any) => this.actions.loadSucceeded(response))
                    .catch((err: any) => Observable.of(this.actions.loadFailed(err)))
            );
    };

There is a call to a private function to load the Elephants from a service:

    private load(): any {
        this.service.getAll(this.tokenServ.getToken())
            .map((items: { ids: number[], entities: { [id: number]: Elephant } }) => items)
            .catch((err: any) => Observable.of(err));

The service looks something like this:

    getAll(token: string): Observable<{ ids: number[], entities: { [id: number]: Elephant } }> {
        return this.http.get(this.url, this.headers(token))
            .map((resp: Response) => resp.json())
            .map((items: Elephant[]) => this.getEntity(items))
            .catch((err: any) => Observable.throw(JSON.parse(err._body)))
    }

    private getEntity(items: Elephant[]): { ids: number[], entities: { [id: number]: Elephant } } {
        let ids = items.map(r => r.Id);
        let entities: { [id: number]: Elephant } = {};

        items.forEach(g => {
            entities[g.Id] = g;
        });

        return {
            ids,
            entities
        };
    }

The idea here, behind this code, is to load all of the Elephants, and return the elephants so the reducer can update the state:

        case ElephantActions.LOAD_SUCCEEDED: {
            return tassign(state, {
                entities: action.payload.entities,
                ids: action.payload.ids
            });     
        }

But nothing ever really subscribes to Observable, so none of it actually fires (no HTTP get or anything). Maybe someone can help me out here with that. I can't just call ".subscribe" on the Observable inside the Epic, because I don't know how to return the new Action from inside the Epic.

This stuff is fun, but I make my living as a C# developer, so this is a bit of a departure.

SethDavenport commented 7 years ago

I'll look closer later tonight.

But at a high level, regarding subscription, it sort of works conceptually like this:

  1. createEpicMiddleware creates a thing that subscribes to the redux action stream internally. So this is where the subscription is. After that point, any action dispatched by redux will first trigger appropriate reducers, and then once the state is updated, it will trigger the epic middleware's internals.

  2. at this point the action should be should be pushed through all of your epics; each epic uses the .ofType operator to only listen to the actions it cares about.

  3. epics are supposed to map those incoming actions to outgoing actions, which will then be dispatched by the epic middleware and flow through redux all over again.

So the subscription is handled for you as a result of createEpicMiddleware.

To debug, maybe try doing something simple like:

@Component({
    selector: 'my-app',
    templateUrl: './app.component.html',
})
export class AppComponent implements OnInit, OnDestroy {
  constructor(
    private ngRedux: NgRedux<IAppState>,
    private devTool: DevToolsExtension,
    private zooEpics: ZooEpics) {

    /*
    const epics = [
      createEpicMiddleware<any>(this.zooEpics.epics)
    ];
    */

    const debugEpic = action => action
      .filter(a => action.type != 'DUMMY_ACTION') // prevent infinite loop
      .do(a => console.log('Epics are listening:', a))
      .map(a => { type: 'DUMMY_ACTION', meta: a });

    this.ngRedux.configureStore(
      rootReducer,
      {},
      [
        createEpicMiddleware(debugEpic),
        createLogger(), 
        // Not sure if thunk is interfering somehow.
        // thunk
      ],
      [
        devTool.isEnabled() ? devTool.enhancer() : f => f
      ]);

You should see in your console a log entry 'Epics are listening' any time any action is fired. You should also see via redux-logger that each action results in an additional 'DUMMY_ACTION' being dispatched.

SethDavenport commented 7 years ago

I guess my other question is where are you dispatching the action that starts it all? E.g. there should be an ngRedux.dispatch(AppActions.LOAD_DATA) in your code somewhere, right?

SethDavenport commented 7 years ago

If you have this on GH I can try to run it locally

gregkopp commented 7 years ago

I'm using Redux Devtools, so I can see all of the actions that are currently being dispatched. The initial AppActions.LOAD_DATA action gets dispatched after the user successfully logs into the application via an auth0-lock successful authentication. I see all of them, right up to ElephantActions.LOAD_DATA. I am convinced it has everything to do with me not using Observables properly.

I can add the dummy Epic, but I don't think it's going to tell me anything new.

It's not on GH, although that's an idea, I would have to mock out the data services. TBH, I am not really writing a zoo animal management app. :) I've changed the names of the objects to be more generic than the real ones. There is a real set of REST APIs behind all of this, but all of my examples are spot on to what I am seeing.

You can see a section of the dispatched actions. I've redacted the real name of the object for security reasons. Just pretend the red block says "Elephant."

image

SethDavenport commented 7 years ago

ok nvm ;) let me try and hack an example together

gregkopp commented 7 years ago

I'm such a dope. It helps if I actually return the Observable:

    private load(): any {
        RETURN this.service.getAll(this.tokenServ.getToken())
            .map((items: { ids: number[], entities: { [id: number]: Elephant } }) => items)
            .catch((err: any) => Observable.of(err));

I might have this working. Once I get it to where it should be, I will put together a working prototype and put it on GH.

SethDavenport commented 7 years ago

Ha!

Well for what it's worth, I threw an example of this up on GH: https://github.com/SethDavenport/zoo

TBH for me the hardest part was remembering the vagaries of NgModule :)

CC: @jayphelps if you're still looking for Angular 2 examples of redux-observable.

SethDavenport commented 7 years ago

Note that my example shows how to split up the epics into different feature folders without undue verbosity. However it's not quite code-splitting per se. Code splitting usually refers to using webpack to split your app into different bundles (usually according to the app's router layout).

Redux-observable presents some additional challenges in this area, which is what this article is all about.

gregkopp commented 7 years ago

This is exactly the kind of help I needed. I implemented your pattern for the Epics and it's working the way I expected. This is perfect. My understanding of the RxJs operators is still fuzzy. It's starting to make more sense now. And I appreciate the clarification on terminology as well.

SethDavenport commented 7 years ago

Good to hear it. Yeah rxjs has a bit of a learning curve - I've only really scratched the surface myself.

I found this to be a helpful resource when learning RX: http://rxmarbles.com/