DEV Community

David
David

Posted on

Here's how NgRx selectors actually work internally

Everyone wants to know "how do selectors really work?"

I ran into this question question on the NgRx GitHub discussion threads which asked:

I was wondering how selectors determine if their input changed - or, put differently: when do they recalculate?

I think every developer that uses NgRx should know the answer to this from the angle of how it really works under-the-hood. Knowing builds your confidence to build performant apps without mystery.

The fastest answer

When you subscribe to a selector with store.select, that selector, and every dependent selector it has, is called on every single state change.

That may sound terrible for performance! But, what you may not know is that selectors are actually composed of two parts, and the part that is expensive to run on every state change, the "projector," is actually not run on every state change.

Selectors only re-calculate, or call their projector again, when their "arguments" change. Let's see what that means.

What selectors really are

Selectors are composed of a projector function and a list of selectors that create "arguments" for the projector when given a state object.

Selectors are declared with createSelector like:

const selectSomething = createSelector(
   ...aListOfSelectors,
   projector
);
Enter fullscreen mode Exit fullscreen mode

The projector function is not called on every state change.

Instead, when a selector is called:

  • each selector in the list of dependent selectors is applied on the state, producing arguments with which to call the projector
  • the arguments are compared to a cached, or memoized, list of values with which the selector's projector was last called
  • When the arguments are the same, compared by ===, the projector is not called; the last memoized value is returned instead
  • When any of the arguments are different, the projector is called, and the new value is returned; the arguments are memoized along with the new value

What does this mean?

Selectors don't have to have to be re-calculated, or apply their projector function, on every state change. This is enabled by writing small, composable selectors.

Break it down, how do selectors achieve memoization?

I'll show a simplified version of how createSelector and store.select work.

By looking at simplified implementations, hopefully you'll see how the awesome performance benefits of selectors are done.

How createSelector really works

createSelector is a factory that creates a function which takes in state and returns a value. The steps it takes are:

  • Declare variables for the last arguments with which the projector was called, and the last result of the projector
  • Create a function which
    • Applies dependent selectors to the state
    • Checks whether the last arguments with which the projector was called match the results from the dependent selectors
    • Returns either memoized results when true, or applies the projector to the results from the dependent selectors

You could implement createSelector like:

function createSelector(...otherSelectors, projector) {
  // Declare variables for the last arguments which the projector was called with, and the last result of the projector
  let lastMemoizedArguments: Array<unknown> = undefined;
  let lastMemoizedResult: unknown = undefined;

  // Create a function which
  return (state) => {
    // Applies dependent selectors to the state
    const otherSelectorResults = otherSelectors.map(selector => selector(state));

    // Checks whether the last arguments with which the projector was called match the results from the dependent selectors
    const result = lastMemoizedArguments?.every((arg, i) => arg === otherSelectorResults[i])
        // Returns memoized results when true
        ? lastMemoizedResult
        // or applies the projector to the results from the dependent selectors
        : projector(...arguments);

    lastMemoizedArguments = otherSelectorResults;
    lastMemoizedResult = result;

    return result;
  };
}
Enter fullscreen mode Exit fullscreen mode

How store.select really works

store.select creates an Observable based on the state and a selector.

For every single state change, the provided selector is applied to the value. Duplicate results are simply ignored.

You could implement select within a global store like:

class Store<State> {
  select(selector: (state: State) => unknown) {
    return this.state$.pipe(
      map(state => selector(state)),
      // don't emit a new value unless the value changes
      distinctUntilChanged() 
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

So those implementations mean...

createSelector handles setting up functions that process state and memoize their results based on child selectors. store.select applies a selector function to an Observable of every state change, and only emits changed values.

Applying what we learned

Let's look at how some real selectors use memoization to avoid unnecessary re-calculations.

Say we have the following selectors for finding students who have a passing grade on a test:

const selectStudents = createSelector(
  selectState,
  state => state.students
);

const selectTestGradesByStudentId = createSelector(
  selectState,
  state => state.testGradesToStudentId
);

const selectPassedTestsByStudentId = createSelector(
  selectTestGradesToStudentId,
  testGradesToStudentId => Object.fromEntries(
    Object.entries(testGradesToStudentId).filter(([_, { grade }]) => grade > 60)
  )
);

const selectStudentsWhoPassedTest = createSelector(
  selectPassedTestsByStudentId,
  selectStudents,
  (passedTestsByStudentId, students) => students.filter(student => !!passedTestsByStudentId[student.id])
);
Enter fullscreen mode Exit fullscreen mode

Then when we are subscribed to this selector:

const studentsWhoPassedTest$ = this.store.select(selectStudentsWhoPassedTest);
studentsWhoPassedTest$.subscribe();
Enter fullscreen mode Exit fullscreen mode

Say the current state was:

{
  students: [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}],
  testGradesToStudentId: {'1': {grade: 65}, '2': {grade: 55}}
}
Enter fullscreen mode Exit fullscreen mode

When we subscribe, selectStudentsWhoPassedTest would be called immediately, and all the dependent selectors would also be called, and every projector would be run.

If we update the state by adding another student without adding another test grade, we should expect only the selectors relevant to the students' list to call their projectors:

  • When selectStudentsWhoPassedTest is called,
    • First, it evaluates its dependent selectors against the current state.
    • selectPassedTestsByStudentId calls its dependent selector
      • selectTestGradesToStudentId calls selectState, which returns the current state object.
      • The projector function is then applied, returning the testGradesToStudentId array.
      • Next, the projector function for selectPassedTestsByStudentId is applied, using the argument from selectTestGradesToStudentId. Since the result is identical to the previous state (same reference), the memoized projector result is returned.
    • Separately, selectStudents calls selectState, fetching the new state object.
      • Its projector function returns the state.students array, now with a new reference due to the updated list of students.
    • Finally, selectStudentsWhoPassedTest compares its arguments: the unchanged passedTestsByStudentId and the updated array of students. Given the change in one argument, the projector is re-applied.

So when we changed the list of students:

  • selectPassedTestsByStudentId's projector didn't have to get called again
  • selectStudentsWhoPassedTest's projector got called again using the new list of students

Wrapping up

In this article you learned:

  • Selectors that are part of a subscription are called on every state change
  • The projectors of selectors are only called when the results of their dependent selectors change from the previous state value to the current one
  • Selector memoization is set up in createSelector
  • Change notifications of selector values are set up in store.select
  • Selectors have to be written intentionally to take advantage of their memoization

Without the mystery of how selectors work, you should be able to write performant, composable selectors.

Top comments (0)