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

Question regarding custom reducers. #56

Closed
mxdmedia opened this issue Aug 21, 2019 · 15 comments
Closed

Question regarding custom reducers. #56

mxdmedia opened this issue Aug 21, 2019 · 15 comments
Labels
useful knowledge base The information in this issue provides useful knowledge

Comments

@mxdmedia
Copy link

mxdmedia commented Aug 21, 2019

I just came across this library, and it looks to meet most (perhaps all?) of my needs for a project i am working on. I've watched a couple of talks about it on youtube, and I am quite excited to give Auto-Entity a go!

One question I have, as it doesn't seem to be in the docs. How does one go about adding a custom reducer (reacting to actions from another entity's actions) for an auto-entity managed entity?

For example, my API includes a fairly deeply nested tree (5 levels deep). Say these 5 levels are from Animal Classifications (Class, Order, Family, Genus Species) where each has an id pointing to its parent. My API also has an endpoint for each with all the proper CRUD operations (/api/classes, /api/orders, /api/families, /api/genera, /api/species). Additionally, to reduce the number of http requests, there is a read-only, GET/List endpoint: /api/animals which returns a flattened version of the entire tree that looks like:

{
  classes: Class[];
  orders: Order[];
  families: Family[];
  genera: Genus[];
  species: Species[];
}

Using vanilla ngrx, I'd create an Animals feature, that had just loading, and error as parameters, and the actions AnimalActions.LoadClassifications, AnimalActions.LoadClassificationsComplete, and AnimalActions.LoadClassificationsError. There would also be an Effect for Animals to fetch the data. Then in my other related features, I'd have my reducer do something like:

// Part of Genus reducer
case AnimalActions.ActionTypes.LoadClassificationsComplete: {
    return adapter.addAll(action.payload.genera, state);
}

Then do the same for the other entities. Is it possible to do something like this if Genus entities were managed using auto-entity? I see where one defines an essentially empty reducer function. But I presume the reducers are injected elsewhere. Any thoughts on the possibility, or how this might be accomplished would be appreciated! And thank you for writing this library, it looks like it will make ngrx use much easier to write and maintain!

@jrista
Copy link
Contributor

jrista commented Aug 21, 2019

Hi @mxdmedia. Apologies for the lax documentation, this is something I am working on.

Regarding custom reducers, yes, you can absolutely write your own! Auto-Entity is just built on top of NgRx, and the way it was designed, it generally does not change how you normally use NgRx. So you can always do the same kinds of things you might normally do, such as write reducers.

The empty stub reducers Auto-Entity requires can easily have custom cases added to them if you need to. Your reducers, stub or otherwise, are included in your state as you would normally include any other reducer, with the ActionReducerMap. So they are there, even though...normally...they are just the stubs. All you would really need to do is add a switch statement and go from there. (It should also be possible to write a reducer using the new NgRx 8 patterns as well, again...Auto-Entity does not change anything here, it goes with the standard flow, as it were, for however NgRx normally works.)

Now, as to the specifics of reducing your data structure into Auto-Entity managed state...that should also be possible. There are probably a few ways to achieve it. You could write your own reducer, but there is also the option to create a custom action to INITIATE the process, then write an effect that handles that action, calls a custom service method to get your "bundle" of entities, then in return dispatch a number of other actions...Auto-Entity generic actions...to integrate your custom initiation with Auto-Entity Success/Failure actions, which will be properly reduced by Auto-Entity's internal meta reducer.

I can give you more details once I get a chance to think about it a bit more tomorrow.

@mxdmedia
Copy link
Author

Thanks for the response @jrista . I am going to use auto-entity on a test project today, and see how far I can get. I look forward to hearing your further thoughts/details.

One thing to clarify. First you say:

The empty stub reducers Auto-Entity requires can easily have custom cases added to them if you need to.

then

Now, as to the specifics of reducing your data structure into Auto-Entity managed state...that should also be possible.

I assume that these are not the same, due to the IEntityState being wrapped around my model. E.g.- I can't just write a simple insert reducer for the incoming Genus data. I'd need to insert the entities in IEntityStates entities dict, and the ids into the ids array. It seems that doing so manually (it wouldn't be terribly difficult) would be the equivalent to using a non-public API (e.g.- it could break at any update if you changed the structure of IEntityState for some reason). Does Auto-Entity provide any sort of adapter like @ngrx/entity for manual management of entities?

The custom actions, and handling it from that direction seems promising.

@jrista
Copy link
Contributor

jrista commented Aug 22, 2019

@mxdmedia So, we have done what we can to make auto-entity state compatible with @ngrx/entity. We have not done a lot of testing, however there is nothing that says you shouldn't be able to create an entity adapter and use that to update state that auto-entity manages.

That said, the easier way to handle it all is to create an effect that handles your initiating action, and then dispatch multiple auto-entity generic actions that auto-entity's meta reducer will handle to update the state for you. Much simpler that way.

So, to give you some actual code... You basically have an AnimalClassifications model. So you'll want that:

import { Class, Order, Family, Genus, Species } from 'models';

export class AnimalClassifications {
  classes: Class[];
  orders: Order[];
  families: Family[];
  genera: Genus[];
  species: Species[];
}

You then need a service to pull that data in from your API. This does NOT need to be an auto-entity service, it can just be a Plain Old Angular Service. ;) Something like:

@Injectable({providedIn: 'root'})
export class AnimalClassificationsService {
  // ... standard stuff ...
  loadAll(): Observable<AnimalClassifications> {
    return this.http.get<AnimalClassifications>('https://...');
  }
}

So now that you have your model and a service, you just need an action & effect that will handle the rest. This effect will use your custom action (standard ngrx action) to initiate the load, and when the data comes back...you split off the individual child entities. Using NgRx 8:

export const loadAllAnimalClassifications = createAction('[AnimalClassifications] Load All');

export class AnimalClassificationsEffects {
    // ... standard stuff ...

    loadAllAnimalClassifications$ = createEffect(() => 
      this.actions$.pipe(
        ofType(loadAllAnimalClassifications),
        exhaustMap(() => 
          this.animalClassificationsService.loadAll().pipe(
            map(classifications => {
                // Now, dispatch LoadAllSuccess for each of the models contained within AnimalClassifications:
                this.store.dispatch(new LoadAllSuccess(Class, classifications.classes);
                this.store.dispatch(new LoadAllSuccess(Order, classifications.orders);
                this.store.dispatch(new LoadAllSuccess(Family, classifications.families);
                this.store.dispatch(new LoadAllSuccess(Genus, classifications.genera);
                this.store.dispatch(new LoadAllSuccess(Species, classifications.species);
            }),
            catchError(err => {
                // Handle error...we don't dispatch with this effect, so you do whatever you need to
           })
          )
    , {dispatch: false});
}

So here, you leverage the existing functionality of Auto-Entity entirely. You use its actions, and let it reduce your state, so you don't have to worry about writing a reducer yourself. You just need to create the initiating action for the whole process, then distribute the result.

@mxdmedia
Copy link
Author

mxdmedia commented Aug 22, 2019

Awesome! That looks like a great way to handle it. I really appreciate you taking the time to respond, and come up with some starter code for me. It is great how Auto-entity is similar to a typical @ngrx/entity. For me it clarifies how it can be used in the context of vanilla ngrx. I am definitely going to give auto-entity a shot in my current project, rather than trying to build out all my own facades over a vanilla store.

A bit off topic (I can definitely open a new issue if this warrants more discussion). Do you have any plans/interest in expanding the built-in selectors to include some functions that help with nested data? I've been working with some pretty complex nested data in ngrx these days, and have a whole bunch of utility selectors I've written to do things like output entities grouped by some parameter. For example, the Genus class might look like:

export class Genus {
  id: string;
  name: string;
  familyId: string;
}

a handy selector I use regularly (and re-create often) is one that returns:

export interface GenusByFamily {
    [key: string]: Genus[];
}

where the key is the parent Family ID. Another selector I re-create is similar, but it returns just a single Family's Genera. I'd be happy to submit PRs if more selectors are of interest.

One other (likely more difficult) selector is one that transforms the raw store data, into a more appropriate "view model". Doing things such as converting datetime strings into actual date objects, or even building out full objects with related models attached. I'd love to hear any thoughts you might have on this, and again, am happy to submit some PRs as I work with auto-entity.

Thanks again for your help!

@jrista
Copy link
Contributor

jrista commented Aug 22, 2019

@mxdmedia Great to hear you'll be giving it a try! Let me know how it goes, and if you have any needs.

Regarding selectors... Selectors is an area where you can create a countless numbers of them. It is a best practice to use selectors as much as possible to retrieve data from the store. That is a best practice we promote with Auto-Entity as well. The only difference is, we tend to wrap selector usage within facades. ;)

There are probably some additional "common" selectors that could be added to Auto-Entity in the future. I would like to make sure that if we add more, that they are well and truly common enough to warrant being included. I did add selections (both single-entity and multi-entity), as well as edits (edit, change, endEdit) features, both the necessary actions and selectors, as those are pretty common use cases. Beyond these, I think we'll need to see how many people request the same selectors or features before we add more.

One of my small concerns now is the size of the library. It is ~75k right now. It is a bit large, and at the moment we do not really have any way to break it down into smaller pieces that may be selectively added to a project. In the future I am thinking we may shift to an @ngrxae/* namespace, and break the library up a bit. But that is down the road a ways, needs more thought and planning. Once we do that, though, concerns about the size of the library (which, BTW, IS tree-shakable, so the final load to your deployment packages should generally be less than 75k unless you are very extensively using all the features) will fade once we have it broken out into multiple libs under a single namespace.

Anyway, my general recommendation with selectors is, use them, use them extensively, they should proliferate, and wrap them inside of your facades (you can wrap them either in getter properties, or getter functions if you need parameterization). For parameterized data retrieval, since NgRx selectors don't really memoize parameterized selectors well, I usually just pipe off of another base selector, then handle the criteria in the pipe. You can also roll your own memoization as necessary (there are some good memo libs on github/npm that can help you there) to maintain those benefits when .pipe()ing off of other selectors.

Hope all that helps!!

@mxdmedia
Copy link
Author

I'll definitely provide you with feedback (and likely some questions) as I use auto-entity.

Re: Selectors, I too am a huge proponent of 'selector all the things.' I find that selectors can get a bit tricky when trying to join data together. If you ever do namespace out features, having a 'join selectors' optional feature might be nice, but I fully see where you are coming from re: size. The way I do it now, is typically embedding a utility function (e.g.- a groupByParameter function that re-arranges the data to group it by.. well a parameter of the model) within the selector itself. I suppose another route would be to embed it in a pipe post-selector, as it really is just a data transformation. It sounds like the auto-entity facades will let me do either, which is awesome! I'll definitely look into using a memo library on a pipe. Cheers!

@mxdmedia
Copy link
Author

Let me know if you'd like me to open a new issue for things like this. I've started using auto-entity, and so far, so good. However, I haven't been able to find in the docs how to do one particular function that I can do with @ngrx/entity: specify a sortComparer. In my current project, I have quite a few models that can be re-sorted by the user, and thus have a delta parameter for sorting. Is there any way to specify a sort order for my models like I can using the vanilla entity adapter?

@jrista
Copy link
Contributor

jrista commented Aug 28, 2019

@mxdmedia Apologies for the late reply. Currently, I don't have anything in the lib for sorting. If you could open an issue specifying what you need, I'll see what I can do to get the functionality added for you.

@jrista
Copy link
Contributor

jrista commented Aug 28, 2019

@mxdmedia Is it safe to assume your questions about how to handle custom use cases with reducers and effects have been answered? Can I close this issue?

@mxdmedia
Copy link
Author

Absolutely! Thanks for your help on this.

@jrista jrista added the good first issue Good for newcomers label Sep 6, 2019
@jrista jrista reopened this Sep 6, 2019
@jrista jrista added good first issue Good for newcomers useful knowledge base The information in this issue provides useful knowledge and removed good first issue Good for newcomers labels Sep 6, 2019
@mxdmedia
Copy link
Author

mxdmedia commented Sep 7, 2019

Since this was re-opened, I have one more similar, though slightly different use case I am trying to get working.

Lets say I have the following models, handled nicely by auto-entity:

// src/app/models/message.model.ts
export class Message {
    id: string;
    title: string;
    body: string;
    author: string; // ID of a user
}

// src/app/models/user.model.ts
export class User {
    id: string;
    firstName: string;
    lastName: string;
    username: string;
}

My api has an endpoint: api-domain.com/api/messages, which pulls a list of messages, and its return format looks something like:

{
  "messages": [
    {"id": "abc", "title": "Message", "body": "Message body.", "author": "user-1"},
    {"id": "def", "title": "Message2", "body": "Message2 body.", "author": "user-2"},
    ...
  ]
}

However, one can also pass an include parameter, to optionally include the user entities for all messages in the return. e.g.: api-domain.com/api/messages?include=users would return:

{
  "messages": [
    ...
  ],
  "users": [
    {"id": "user-1", ...},
    {"id": "user-2", ...}
  ]
}

Does auto-entity have enough flexibility to allow this internally, as both messages and users are managed by auto-entity? I can see how I might do it by injecting the store into my messages entity service, and calling the user generic LoadMany action if a users array is returned, but I am not sure if that would be bad practice (injecting the store into the entity service).

@jrista
Copy link
Contributor

jrista commented Sep 9, 2019

@mxdmedia Yeah, I re-opened it just so it would be visible to future visitors who might have similar questions.

As for your question. I think this would be the same as before. Create a custom effect to split your result and dispatch the necessary success actions so Auto-Entity can handle the rest. You could handle the dynamic potential of the data easily enough in an effect:

export const dynamicLoadMessages = createAction(
    '[Messages] Load Many (Dynamic)',
    props<{include: string[]}>()
);

export class MessageEffects {
    // ... standard stuff ...

    dynamicLoadMessages$ = createEffect(() => 
      this.actions$.pipe(
        ofType(dynamicLoadMessages),
        map(action => action.include),
        exhaustMap(include => 
          this.messageService.loadManyDynamic(include).pipe(
            map(dynamic => {
                this.store.dispatch(new LoadManySuccess(Message, dynamic.messages);
                if (dynamic.users) {
                    this.store.dispatch(new LoadManySuccess(Users, dynamic.users);
                }
                // If you can bring in more, handle it here; Possibly find a way to handle it entirely dynamically, even, if your include can bring in a wide range of possible alternative entities
            }),
            catchError(err => {
                // Handle error...we don't dispatch with this effect, so you do whatever you need to
           })
          )
    , {dispatch: false});
}

Note the actions dispatched here: **Success. This is the key for any similar use case. You don't need to dispatch LoadMany, as that is an initiator. You don't need to initiate another call to the server, you already have the data you need. You just need to stuff the data you have into Auto-Entity-managed state. The **Success and **Failure actions are the result actions, and they are what drives what goes into Auto-Entity state in the end.

You can leverage this, along with custom effects, to handle just about any kind of potential data shape from any server, even if it does not explicitly conform to what Auto-Entity expects. This also allows you to centralize these complexities...these SIDE EFFECTS...into effects, allowing you to keep your data services clean and simple, and solely responsible for making HTTP calls and handling HTTP responses.

Now, in the above example, I have created a new action and implied that a new service method would need to be created. FOR NOW, that is the case. However, with the upcoming @entity decorator for entity model classes, you will have a lot more power and control. Auto-Entity actions already support custom criteria...such as include. With the upcoming decorator, you could leverage the standard Auto-Entity Load/LoadAll/LoadMany actions and service methods, and then just exclude that particular entity from the default load effects. You would then write your own load effect for that entity to do the same as the above:

Model:

@Entity({
    excludeEffects: matching(EntityActionTypes.Load, EntityActionTypes.LoadAll, EntityActionTypes.LoadMany)
})
export class Message {
    @Key id: string;
    title: string;
    body: string;
    author: string;
}

Service:

const getIncludeParams = (criteria: { include: string[] }): { [param: string]: string | string[] } =>  {
    const include = Array.join(criteria.include, ',');
    const params = include.length ? { params: { include } } : undefined;
    return params;
}

export class DynamicMessage {
    message: Message;
    user?: User;
}

export class DynamicMessages {
    messages: Message[];
    users?: User[];
}

export class MessageService implements IAutoEntityService<any> {
    load(entityInfo: IEntityInfo, id: string, criteria: { include: string[] }): Observable<Message | DynamicMessage> {
        const params = getIncludeParams(criteria);
        const opts = params ? { params } : undefined;
        return this.http.get<Message>(`${environment.baseUrl}/api/messages/${id}`, opts);
    }

    loadAll(entityInfo: IEntityInfo, criteria: { include: string[] }): Observable<Message[] | DynamicMessages> {
        const params = getIncludeParams(criteria);
        const opts = params ? { params } : undefined;
        return this.http.get<Message>(`${environment.baseUrl}/api/messages`, opts);
    }

    loadMany(entityInfo: IEntityInfo, criteria: { include: string[] }): Observable<Message[] | DynamicMessages> {
        const params = getIncludeParams(criteria);
        const opts = params ? { params } : undefined;
        return this.http.get<Message>(`${environment.baseUrl}/api/messages`, opts);
    }

    // ...other methods...
}

Custom Effect:

export class MessageEffects {
    // ... standard stuff ...

    load$ = createEffect(() => 
      this.actions$.pipe(
        ofEntityType(Message, EntityActionTypes.Load),
        exhaustMap(action => 
          this.messageService.load(action.info, action.keys, action.criteria).pipe(
            map((dynamic: Message | DynamicMessage) => {
                if (dynamic instanceof Message) {
                   this.store.dispatch(new LoadSuccess(Message, dynamic));
                } else {
                    this.store.dispatch(new LoadSuccess(Message, dynamic.message));
                    if (dynamic.users) {
                        this.store.dispatch(new LoadSuccess(Users, dynamic.user));
                    }
                    // If you can bring in more, handle it here; Possibly find a way to handle it entirely dynamically, even, if your include can bring in a wide range of possible alternative entities
                }
            }),
            catchError(err => {
                // Handle error...we don't dispatch with this effect, so you do whatever you need to
           })
          )
    , {dispatch: false});

    // Implement loadAll$ and loadMany$ much the same as load$, just handle arrays of entities rather than single entities
}

Auto-Entity will do certain things that follow a fairly common pattern. However, when particular use cases deviate from that pattern, that is where you can step back into raw NgRx and do anything you want. Auto-Entity explicitly provides that fallback option, as we cannot automate every possible scenario.

We can still help you though, when you need to fall back and handle custom use cases...because you can always dispatch our Success or Failure actions to integrate your data with Auto-Entity-managed state in the end. As such, even when you do fall back onto raw NgRx as necessary, you should still not have to implement nearly as much as you might have had to in the past with just plain old NgRx (i.e. you do not need to create a full set of actions and effects for every initiation or result for every entity in your app...)

@mxdmedia
Copy link
Author

mxdmedia commented Sep 9, 2019

Thanks again for more help. The action/effect at the top make sense. I will give that a try. On a related note..

With the upcoming decorator, you could leverage the standard Auto-Entity Load/LoadAll/LoadMany actions and service methods, and then just exclude that particular entity from the default load effects. You would then write your own load effect for that entity to do the same as the above...

This is how I was thinking it would be implemented, but couldn't figure out how. I look forward to seeing the new @entity decorator- it sounds like it is going to be quite powerful.

@jrista
Copy link
Contributor

jrista commented Sep 9, 2019

I hope it adds the level of flexibility that will allow users like yourself, who are more on the "power user" end of the spectrum, to do the more complex things they need without having to expend a huge amount of effort creating actions and effects for everything.

@jrista jrista removed the good first issue Good for newcomers label Oct 13, 2019
@jrista
Copy link
Contributor

jrista commented Jan 13, 2020

Closing to reduce issue count.

@jrista jrista closed this as completed Jan 13, 2020
@jrista jrista pinned this issue Feb 24, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
useful knowledge base The information in this issue provides useful knowledge
Projects
None yet
Development

No branches or pull requests

2 participants