Selector recomputation in createSelector with OnPush change detection
0 reputation · 26 May 2021, 15:00 UTC
0 reputation · 26 May 2021, 15:00 UTC
In an Angular application using @ngrx/store, a selector created via createSelector is intended to memoize results to prevent unnecessary downstream computations and component re-renders. However, the default memoization mechanism relies on reference equality (===) for its input arguments.
When reducers return new object references—even if the underlying data remains identical—the selector fails to recognize the state as unchanged. In components utilizing ChangeDetectionStrategy.OnPush, this behavior triggers a re-render because the selector emits a new value, bypassing the performance benefits of memoization.
While memoizeOptions was introduced in v12 to allow custom equality checks, there is a trade-off between the overhead of deep comparison and the cost of selector re-execution.
memoizeOptions parameter be implemented to balance deep equality checks against the performance cost of re-rendering large component trees?createSelector memoization remains effective without custom equality functions?Use memoizeOptions with a shallow equality function that compares only the specific state slice your selector consumes, not the entire state tree. For stable references, structure normalized state so entity objects are reused across updates—only replace the entity map key when that entity's data actually changes.
memoizeOptions for Balanced PerformanceThe default createSelector memoization uses strict === on input arguments. When reducers return new object references (even with identical data), selectors recompute and OnPush components re-render. memoizeOptions (NgRx v12+) accepts a custom equalityCheck function.
import { createSelector, MemoizedSelector } from '@ngrx/store';
const selectUserSlice = (state: AppState) => state.users;
const selectActiveUser = createSelector(
selectUserSlice,
(users) => users.entities[users.activeId],
{
memoizeOptions: {
equalityCheck: (a, b) => a === b || (
a?.id === b?.id &&
a?.version === b?.version
)
}
}
);
This compares only the fields that matter (id and version) rather than deep-cloning or full object traversal. The overhead is O(1) per selector invocation versus O(n) for deep equality.
OnPush compare by referenceconsole.count or Chrome DevTools to measure invocation frequency before adding overheadThe most effective strategy avoids custom equality entirely by keeping entity references stable across updates.
interface UsersState {
ids: string[];
entities: { [id: string]: User };
activeId: string | null;
}
on(UsersActions.updateUser, (state, { user }) => {
const existing = state.entities[user.id];
// Only create new reference if data actually changed
if (existing && shallowEqual(existing, user)) {
return state; // Return SAME state reference
}
return {
...state,
entities: {
...state.entities,
[user.id]: user // New reference only for changed entity
}
};
});
Use a lightweight shallowEqual helper (comparing own enumerable properties) in the reducer itself. This keeps the entities map reference stable when unchanged, and only the modified entity gets a new reference. Selectors reading entities[id] then hit memoization naturally.
console.log or a counter inside your selector's projector functionWhat does your current reducer return when an entity update payload matches existing data? If it returns { ...state, entities: { ...state.entities, [id]: payload } } unconditionally, that's the root cause—fix the reducer first before adding memoizeOptions.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.