Reputation: 1597
My problem can be best described by analogy with the selectors example of the ngrx documentation to keep things simple (https://github.com/ngrx/platform/blob/master/docs/store/selectors.md#using-selectors-for-multiple-pieces-of-state).
I use the async pipe to subscribe to certain slices of state which I select using selectors, for instance
this.visibleBooks$ = this.store$.select(selectVisibleBooks)
The thing is that, if the allBooks
array is "small", <100 items, my view gets updated instantly. But when it is large, >100, my view gets only updated next time change detection is triggered, for instance by scrolling. This is quite a bad user experience, to only see books once you scroll the list.
I looked at the source for async pipe (https://github.com/angular/angular/blob/master/packages/common/src/pipes/async_pipe.ts), and indeed the _updateLatestValue
method calls ChangeDetectorRef.markForCheck()
, which as far as I understand marks the component to be checked for changes the next time change detection is triggered.
My current way around this is by subscribing manually within the top-level component
this.store$.select(selectVisibleBooks).subscribe(cb)
and calling ChangeDetectorRef.detectChanges()
manually within the callback.
I find this however unsatisfactory and would simply like async pipe to always work, no matter how large the Book[]
array. Does anybody have some suggestions or a correction with which I could make things work?
edit as per request
The "books store" case above, as said, was just an analogy for the app I'm writing to keep things simple. In reality, my app renders nodes and edges of a graph, where nodes and edges also have a version attached, denoted "vnode", which together with "vedge"s span a version tree. So any graph element has its own version tree.
What I am developing currently is a search form, where we send a certain request to the backend, asking it for any nodes which match a certain set of search key/value pairs.
So, those nodes would then be rendered in a component <nodes-list>
, which we pass nodes by input binding
<nodes-list [nodes]="nodes$ | async"></nodes-list>
nodes-list
has change detection "on push", while the top-level <search>
component has default strategy.
nodes$
is set within ngOnInit()
as
this.nodes$ = this.store$.select(selectFullNodesList)
selectFullNodesList
looks like this:
export const fullNodesSelector = getFullNodesSelector(createSelector(selectSearchState, s => {
if (s.currentId) {
const nodes = s.queries.get(s.currentId).nodes;
if (nodes) {
return [...nodes];
}
}
return null;
}))
export const selectFullNodesList = createSelector(
fullNodesSelector,
(global: GlobalState) => global.data.counts,
createSelector(selectSearchState, s => s.sort),
(nodes, counts, sorting) => {
if (!nodes || !counts || !sorting) return null;
return [...nodes.sort(sorting.sortCbFactory(counts))];
}
)
Let me explain:
getFullNodesSelector(...)
I will show below, it sits in a top-level library because we may reuse it in many features. But what it does is, it takes as an argument another selector which points to an array of node & vnode key pairs {key: number, vKey: number}[]
, and turns that array into an array of nodes with their vnodes attached (see below how). search
feature, if there is a currentId
, which is the id of the current request to the backend, then we select the nodes which were the result of our current request.s.queries
is a light wrapper around a Javascript object, which allows me easily get/set values, clone, or add new items to a clone. This I find helpful when working with key/value stores in NGRX. Hence the s.queries.get(s.currentId).nodes
. global.data.counts
is simply a list of how many neighbors each node has. This I want to know because I'd like to sort the nodes list by "count". s.sort
is which sorting of the list is currently selected. sortCbFactory
, this factory simply returns the correct callback to pass to Array.sort
, but I need counts
to be present in the local scope of the callback because otherwise I wouldn't be able to sort by counts. selectSearchState
is simply a feature selector
export const selectSearchState = createFeatureSelector<SearchState>('search');
getFullNodesSelector(...)
looks like this:
function getFullNodesSelector(keyPairsSelector: MemoizedSelector<object, GraphElementKeyPair[]>): MemoizedSelector<object, INodeJSON<IVNodeJSON>[]> {
return createSelector(
keyPairsSelector,
(s: GlobalState) => s.data.nodes,
(s: GlobalState) => s.data.vnodes,
(pairs, nodes, vnodes) => {
if (!pairs || !nodes || !vnodes) return null;
return pairs.map(pair => ({
...nodes.get(pair.key),
_SUB: {
...vnodes.get(pair.vKey)
}
}));
})
}
Some comments again:
GraphElementKeyPair
({key: number, vKey: number}
)get
method. Thus, as we've subscribed to this.nodes$
with the async pipe, each time there is a new event on the stream <nodes-list>
should be updated. However, in practice it appears that this depends on the size of INodeJSON<IVNodeJSON>[]
, and that if the array has length > ~80, we've got to trigger change detection manually by clicking somewhere or scrolling. nodes-list is refreshed automatically, as should be the case, for smaller arrays.
Upvotes: 2
Views: 4342
Reputation: 23803
You'll not have any problem with large dataset. You selectors are supposed to be synchronous. Which means that when the selector is running, nothing else is happening in the background. Not matter how much time it takes to compute everything in your selector, you'll be fine. If it's taking too long, you might have a freeze in the browser but that's it.
When you say
I use ngrx-store-freeze which should guard me against that
It is not true.
It is true from the store point of view. But let's picture the following:
Your store has an array of IDs (let say users IDs).
You have a first selector called getAllUsers
.
This one is just mapping over the users IDs and retrieving the correct users, right? Signature would be (usersIds: string[]): User[]
.
Of course here, you create a new array reference and you are not (supposed) to mutate the usersIds
array.
But then, you've got an other selector. getUsersResolved
which basically "resolve" foreign properties. Let say that a user has a list of animals. From the first selector you'll get a list of users and for each of them, an animalsIds
properties. But what you want is to have an animals
array instead. If from this selector you mutate the original array (the one coming from the first selector), ngrx-store-freeze
will not throw any error, which make sense: You're mutating an array, but not the one from the store.
So how could that be a problem?
getUsersResolved
, assign that to a variable which is then subscribed to from the view using the async
pipe (let's say it's the first time in the whole app that you're subscribing to it!)getAllUsers
is then called (for the first time) by getUsersResolved
(also called for the first time)getAllUsers
creates a new array as intended and passes it to getUsersResolved
. As it's the first time, even if you modify that array into the getUsersResolved
, you wont have any problem: Change detection will be done as it's the first time receiving the arraygetUsersResolved
will be triggered but in the case where you're not respecting immutability and modify the first array coming from getAllUsers
, the array reference does not change and the change detection won't happen. And it's totally fine as that array is not part of the store, it was an array created from a selectorSo I'm not sure whether your problem comes from there or not, but you might want to double check that you respect immutability within your selectors.
Eventually if you're not sure, please share the code of selectVisibleBooks
, and every selectors used by it.
Upvotes: 1