I am currently working with a somewhat complicated (deep) structure within an ngrx project. It can be thought of as an array of parent objects, with multiple levels of child objects. It is normalized/flattened on the server side, and my the feature within my store looks something like this:
rootObjs: {
level1: {
byId: {
'lvl1_1': {id: 'lvl1_1', label: '[Lvl 1]: 1', ui_open: true, children: ['lvl2_1', 'lvl2_3']},
'lvl1_2': {id: 'lvl1_2', label: '[Lvl 1]: 2', ui_open: false, children: ['lvl2_2']}
},
allIds: [
'lvl1_1', 'lvl1_2'
]
},
level2: {
byId: {
'lvl2_1': {id: 'lvl2_1', label: '[Lvl 2]: 1', ui_open: false, children: ['lvl3_1', 'lvl3_2']},
'lvl2_2': {id: 'lvl2_1', label: '[Lvl 2]: 2', ui_open: true, children: ['lvl3_3']},
'lvl2_3': {id: 'lvl2_1', label: '[Lvl 2]: 3', ui_open: false, children: []}
},
allIds: [
'lvl2_1', 'lvl2_2', 'lvl2_3'
]
},
level3: {
byId: {
'lvl3_1': {id: 'lvl3_1', label: '[Lvl 3]: 1', ui_open: false,},
'lvl3_2': {id: 'lvl3_2', label: '[Lvl 3]: 2', ui_open: false,},
'lvl3_3': {id: 'lvl3_3', label: '[Lvl 3]: 3', ui_open: false,},
}
allIds: [
'lvl3_1', 'lvl3_2', 'lvl3_3'
]
}
}
Now I am trying to write my selectors. My issue is that all objects need to be displayed on the screen at once, however they must all be editable separately. Thus, I am trying to create a selector that allows me to select each component individually- something like:
export const rootObjFeature = createFeatureSelector<RootObj>('rootObjs');
export const selectLevel1 = (id: string) => createSelector(
rootObjFeature, (state: JobPlanner) => {
// Grab only the level2 children associated with selected level1
const lvl2 = state.level1.byId[id].children.map(key => state.level2.byId[key]);
// Grab only the level3 children of level2 associated with selected level1
const lvl3 = [].concat(
...state.lvl2.map( l2 => l2.children.map(key => state.level3.byId[key]));
);
return {
...state.level1.byId[id],
level2: lvl2,
level3: lvl3
};
}
);
Then in my Level1Component init, I do something like this:
export class Level1Component implements OnInit, OnDestroy {
@Input() id: string;
lvl1Sub: Subscription;
lvl1: Level1Model;
constructor(private store: Store<AppState>) { }
ngOnInit() {
this.lvl1Sub = this.store.select(selectLevel1(this.id)).subscribe(l1 => {
console.log('loading level 1: '+this.id);
this.lvl1 = l1;
});
}
ngOnDestroy() {
this.lvl1Sub.unsubscribe();
}
}
With this setup I can pass the proper level2
and level3
objects on to their own components (where those children can be opened, closed, edited, etc..). HOWEVER, due to how I have my selector, any time ANY level1
, level2
, or level3
item is edited (e.g.- ui_open
is toggled for lvl1_1
), EVERY level1
component's lvl1Sub
method is called. This is an issue as my view might have hundreds of level1 components, but only one will be edited at a time. Is there a way to set up a selector that will only call its subscription when just those store elements associated with a single ID is changed?
I have wondered the same thing. I think the issue is that you want to observe a filtered subset (children of specific Level1) of an array (Level2s) without observing the entire array. However, in my understanding, the entire array (all Level2s) is what ngrx exposes for observing and what memoization is applied to.
Three solutions come to mind.
The first is to change your datamodel so that the children of a given level are held in their own array. This would essentially mean nesting your levels in your state. If you truly have a tree structure (child only has one parent) rather than a graph structure (child has multiple parents) then this could work. However keeping your state flate is the best practice (https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape).
The second solution is to subscribe at a more granular level. Instead of creating a top level object with nested objects under it you could just pass the id of each entity to the component below it and that component would subscribe to its own slice of state. Then only the component associated with that slice of state and its ancestors will be notified.
The third option is to do your own form of memoization (def: return the last result when receiving the same arguments). The problem with using createSelector
is that it just looks at the reference of the array (list of Level2s for instance) and sees that it changes. You need a deeper form of memoization that compares the references of the elements inside of the slice that you care about to see if they changed.
The poor man's version is to setup your own distinct filter before materializing your model at the end of your projection. The basic gist is that you filter the list of children to only what you want, apply a pairwise operator so that you can know what you got last time, and then filter the stream to ignore values where the references of the objects inside of the current and previous emit are the same.
Here are some running examples:
- My understanding of your current scenario: https://stackblitz.com/edit/ngrx-projections-v1
- Solution #2 (granular subscriptions): https://stackblitz.com/edit/ngrx-projections-v3
- Solution #3 (custom memoization...ish): https://stackblitz.com/edit/ngrx-projections-v2
Open up the console to see what is happening. It prints state changes and changes to each component from state.
For #2 I went full reactive which adds a good bit of bloat. In practice I usually don't do that. Rather I would pass the model from the view into the functions that need it.
For #3 I wrote a custom operator called distinctElements()
which is like the distinctUntilChanged()
operator but it compares the references of the elements within an array rather than the array itself. Here is the code for it.
import { Observable } from 'rxjs/Observable';
import { startWith, pairwise, filter, map } from 'rxjs/operators';
export const distinctElements = () => <T>(source: Observable<T[]>) => {
return source.pipe(
startWith(<T[]>null),
pairwise(),
filter(([a, b]) => a == null || a.length !== b.length || a.some(x => !b.includes(x))),
map(([a, b]) => b)
)
};
Rerendering the entire UI might not be as costly as you think as long as you adhere to angular best practices (make sure you specify the trackBy key).
If you are still concerned about it, then you could split out the ids from the details and only use the ids for rendering the list.
arr = [1,2,3,4.....]
<div *ngFor="let id of arr"><sub-component [itemId]="id"></subcomponent></div>
Then inside of your sub component you could use the store to select the details for each component utilizing the provided input.
_itemId = null
@input()
set itemId(value) {
if (value !== this._itemId) {
this._itemId = value
this.details = this.store.select(selectDetails(value))
}
}
来源:https://stackoverflow.com/questions/49246694/denormalizing-ngrx-store-setting-up-selectors