Optimizing Angular State Selectors with Memoization
Angular applications often manage complex application state, typically using libraries like NgRx or Akita. As applications grow, the selectors used to derive data from this state can become computationally expensive. Repeatedly calculating the same derived data on every change detection cycle can lead to performance issues. This challenge focuses on implementing memoized selectors to efficiently derive state in Angular.
Problem Description
You are tasked with creating a system for managing and selecting data from an Angular application's state. Specifically, you need to implement a mechanism for creating "memoized selectors." A memoized selector is a function that, given the same input (the application state), will always return the same output. Crucially, it will only recompute its result if one of its input arguments has changed. This prevents redundant calculations and improves the performance of your Angular application, especially during change detection.
Key Requirements:
- Create a
createSelectorfunction: This function will take one or more input selectors (functions that extract specific pieces of state) and a result function. The result function will combine the results of the input selectors to produce the final derived state. - Implement Memoization: The
createSelectorfunction must ensure that the result function is only called when any of the input selector's results change. If the inputs remain the same, the previously computed result should be returned. - Handle Multiple Input Selectors: The memoized selector should be able to accept any number of input selectors.
- Integrate with Angular: While the core logic will be pure TypeScript, demonstrate how these memoized selectors would be used within an Angular component or service to retrieve data from a hypothetical state.
Expected Behavior:
When a memoized selector is called with the same state and input selector results, it should return the cached result. If any of the input selector results change from the previous call, the result function should be re-executed, and the new result should be cached and returned.
Edge Cases to Consider:
- Selectors with no input selectors (though less common for memoization benefits).
- Input selectors returning primitive values (numbers, strings, booleans).
- Input selectors returning complex objects or arrays.
- State changes that do not affect the specific data being selected.
Examples
Example 1: Simple Memoized Selector
Let's assume we have a state object like this:
interface AppState {
user: {
id: number;
name: string;
age: number;
};
posts: { id: number; title: string; authorId: number }[];
}
const initialState: AppState = {
user: { id: 1, name: 'Alice', age: 30 },
posts: [
{ id: 101, title: 'First Post', authorId: 1 },
{ id: 102, title: 'Second Post', authorId: 1 },
],
};
And we want to select the user's name.
// Assume these are defined elsewhere and return parts of the state
const selectUser = (state: AppState) => state.user;
const selectUserName = createSelector(selectUser, (user) => user.name);
let currentState = initialState;
// First call
const userName1 = selectUserName(currentState); // Should compute and return 'Alice'
// Second call with same state
const userName2 = selectUserName(currentState); // Should return cached 'Alice' without recomputing
// State changes, but user object reference is the same
const stateAfterUserAgeUpdate = {
...initialState,
user: { ...initialState.user, age: 31 },
};
const userName3 = selectUserName(stateAfterUserAgeUpdate); // Should return cached 'Alice' as user.name hasn't changed
// State changes, and user object reference changes (even if name is the same)
const stateAfterUserRefUpdate = {
...initialState,
user: { ...initialState.user, age: 32 }, // New object reference for user
};
const userName4 = selectUserName(stateAfterUserRefUpdate); // Should compute and return 'Alice'
Output:
userName1: 'Alice'
userName2: 'Alice'
userName3: 'Alice'
userName4: 'Alice'
Explanation:
userName1 is the first call, so selectUser is called, then the result function (user) => user.name is executed. userName2 returns the cached result. In userName3, although the state object is different, the user object reference might be the same, and crucially, user.name hasn't changed, so the memoized selector should return the cached result. In userName4, a new user object reference is created, triggering a re-computation and returning the correct (cached) name.
Example 2: Memoized Selector with Multiple Inputs
Continuing with the AppState from Example 1. Let's create a selector that returns all posts written by a specific user.
interface AppState {
user: { id: number; name: string; age: number };
posts: { id: number; title: string; authorId: number }[];
}
const initialState: AppState = {
user: { id: 1, name: 'Alice', age: 30 },
posts: [
{ id: 101, title: 'First Post', authorId: 1 },
{ id: 102, title: 'Second Post', authorId: 1 },
{ id: 103, title: 'Bob\'s Post', authorId: 2 },
],
};
const selectUser = (state: AppState) => state.user;
const selectPosts = (state: AppState) => state.posts;
const selectUserPosts = createSelector(
selectUser,
selectPosts,
(user, posts) => {
console.log('Calculating user posts...'); // For demonstration of re-computation
return posts.filter(post => post.authorId === user.id);
}
);
let currentState = initialState;
// First call
const userPosts1 = selectUserPosts(currentState);
// Console: Calculating user posts...
// userPosts1: [{ id: 101, title: 'First Post', authorId: 1 }, { id: 102, title: 'Second Post', authorId: 1 }]
// Second call with same state
const userPosts2 = selectUserPosts(currentState);
// No console log, returns cached result
// userPosts2: [{ id: 101, title: 'First Post', authorId: 1 }, { id: 102, title: 'Second Post', authorId: 1 }]
// State changes, but user and posts arrays are not affected in a way that changes the result
const stateAfterUserAgeUpdate = {
...initialState,
user: { ...initialState.user, age: 31 },
};
const userPosts3 = selectUserPosts(stateAfterUserAgeUpdate);
// No console log, returns cached result
// userPosts3: [{ id: 101, title: 'First Post', authorId: 1 }, { id: 102, title: 'Second Post', authorId: 1 }]
// State changes, user object reference changes, but authorId is the same
const stateAfterUserRefUpdate = {
...initialState,
user: { ...initialState.user, age: 32 }, // New user object reference
};
const userPosts4 = selectUserPosts(stateAfterUserRefUpdate);
// No console log, returns cached result (as user.id is still 1)
// userPosts4: [{ id: 101, title: 'First Post', authorId: 1 }, { id: 102, title: 'Second Post', authorId: 1 }]
// State changes, a new post is added
const stateAfterNewPost = {
...initialState,
posts: [...initialState.posts, { id: 104, title: 'New Post', authorId: 1 }], // New post array reference
};
const userPosts5 = selectUserPosts(stateAfterNewPost);
// Console: Calculating user posts...
// userPosts5: [{ id: 101, title: 'First Post', authorId: 1 }, { id: 102, title: 'Second Post', authorId: 1 }, { id: 104, title: 'New Post', authorId: 1 }]
Output:
As described in the console logs and variable assignments above.
Explanation:
The selectUserPosts selector depends on both selectUser and selectPosts. The "Calculating user posts..." log only appears when either user or posts (or the derived values from them used in the result function) change. In userPosts2, userPosts3, and userPosts4, the relevant inputs did not change (from the perspective of the filter condition), so the cached result was used. In userPosts5, a new post was added, changing the posts array and therefore triggering a re-computation.
Example 3: Handling Object/Array Comparisons
Consider a scenario where an input selector returns an object or array, and you want to ensure that a change in the object's reference (even if content is the same) triggers re-computation, or vice-versa. The createSelector implementation should handle this by comparing the results of the input selectors.
interface ComplexState {
settings: { theme: string; fontSize: number };
notifications: string[];
}
const initialComplexState: ComplexState = {
settings: { theme: 'dark', fontSize: 16 },
notifications: ['New message', 'Update available'],
};
const selectSettings = (state: ComplexState) => state.settings;
const selectNotifications = (state: ComplexState) => state.notifications;
// Selector that just returns the settings object
const selectSettingsObject = createSelector(selectSettings, (settings) => settings);
// Selector that filters notifications based on a condition
const selectImportantNotifications = createSelector(selectNotifications, (notifications) =>
notifications.filter(n => n.includes('Update'))
);
let currentComplexState = initialComplexState;
// Test 1: SelectSettingsObject
const settings1 = selectSettingsObject(currentComplexState);
const settings2 = selectSettingsObject(currentComplexState); // Should be cached if reference is same
// Test 2: SelectImportantNotifications
const important1 = selectImportantNotifications(currentComplexState);
const complexStateAfterNotificationAddition = {
...initialComplexState,
notifications: [...initialComplexState.notifications, 'System alert'], // New array reference, but no 'Update'
};
const important2 = selectImportantNotifications(complexStateAfterNotificationAddition); // Should not recompute
const complexStateAfterUpdateNotification = {
...initialComplexState,
notifications: [...initialComplexState.notifications, 'Important update'], // New array reference, and 'Update'
};
const important3 = selectImportantNotifications(complexStateAfterUpdateNotification); // Should recompute
Output:
settings1andsettings2should be the same object ifselectSettingsreturns the same reference.important1:['Update available']important2:['Update available'](no re-computation expected)important3:['Update available', 'Important update'](re-computation expected)
Explanation:
The selectSettingsObject relies on reference equality of the settings object returned by selectSettings. selectImportantNotifications filters the notifications array. Even if a new array reference is created (as in complexStateAfterNotificationAddition), if the filtered result is the same, memoization should hold. Only when the filtered result actually changes (as in complexStateAfterUpdateNotification) should re-computation occur.
Constraints
- The
createSelectorfunction should be implemented in pure TypeScript. - The solution should not rely on external memoization libraries (e.g.,
reselectfor NgRx). You are building the core logic yourself. - The
createSelectorfunction should correctly handle at least 2 input selectors. - Performance: While precise benchmarks are not required, the memoization logic should be efficient for typical use cases. Avoid deeply nested loops or complex algorithms within the memoization checking mechanism itself.
Notes
- Think about how to compare the results of the input selectors between calls. What JavaScript features can help you detect changes?
- Consider the order of arguments passed to
createSelector. - The provided examples use plain JavaScript objects and arrays for simplicity. In a real Angular application, state might be managed by NgRx
Store, Redux, or Akita. The memoized selectors you build should be compatible with these patterns. - The
console.logstatements in the examples are for illustrative purposes to demonstrate when re-computation happens. Your finalcreateSelectorimplementation does not need to include them.