Optimizing Selectors

Selectors are responsible for providing state data to your application. As your application code grows, naturally the number of selectors you create also increases. Ensuring your selectors are optimized can be instrumental in building a faster performing application.

Memoization

Selectors are memoized functions. Memoized functions are calculated when their arguments change and the results are cached. Regardless of how many components or services consume a selector, a selector will calculate only once when state changes and the cached result will be returned to all consumers. Taking advantage of this feature can result in performance increases.

For example, there exists this state model:

interface SomeStateModel {
  data: Data[];
  name: string;
}

And in this example there is an input component where a user can type a name. On key down, an action is dispatched updating the name property of state. On the same page, another component renders data. In order to render state data we create a selector in our state class:

@Selector()
static getViewData(state: SomeStateModel) {
   return state.data.map(d => expensiveFunction(d));
}

Selectors defined in state classes implicitly have state injected as their first argument. The above selector will be recalculated every time the user types into the input component. Since state could update rapidly when a user types, the expensive selector will needlessly recalculate even though it does not care about the name property of state changing. This selector does not take advantage of memoization.

One way to solve this problem is to turn off the injectContainerState selector option at root, state, or selector level. By default (in NGXS v3), the state is implicitly injected as the first argument for composite selectors defined within state classes. Turning off this setting prevents the container state from being injected as the first argument. This requires you to explicitly specify all arguments when you use the @Selector([...]) decorator. Any parameterless @Selector() decorators will still inject the state as an implicit argument. Note that this option does not apply to selectors declared outside of state classes (because there is no container state to inject). For example, we create two selectors in our state class:

@Selector([SomeState])
static getData(state: SomeStateModel) {
   return state.data;
}

@Selector([SomeState.getData])
static getViewData(data: Data[]) {
  return data.map(d => expensiveFunction(d));
}

This getViewData selector will not be recalculated when a user types into the input component. This selector targets the specific property of state it cares about as its argument by leveraging an additional selector. When the name property of state changes, the getViewData arguments do not change. Memoization is taken advantage of.

An alternative solution to turning off the selector option is to create a meta selector. For example, we declare one selector in our state class and declare another selector outside of our state class:

@State({...})
@Injectable()
export class SomeState {
  @Selector()
  static getData(state: SomeStateModel) {
    return state.data;
  }
}

export class SomeStateQueries {
  @Selector([SomeState.getData])
  static getViewData(data: Data[]) {
    return data.map(d => expensiveFunction(d));
  }
}

Implementation

Selectors are calculated when state changes. As your application grows, the number of state changes increases. Finding optimizations in your selector implementations can have significant benefits.

For example, say you have this state model:

interface SelectedDataStateModel {
  selectedIds: number[];
}

And you have this selector:

@Selector([SelectedDataState])
isDataSelected(state: SelectedDataStateModel) {
  return (id: number) => state.selectedIds.includes(id);
}

The above selector is an example of a lazy selector. This selector returns a function, which accepts an id as an argument and returns a boolean indicating whether or not this id is selected. The lazy selector returned by isDataSelected uses Array.includes and has O(n) time complexity. In this example, we want to render a list of checkboxes:

<ng-container *ngIf="isDataSelected() as isDataSelected">
  @for (data of data(); track data) {
  <data-check-box [checked]="isDataSelected(d.id)" />
  }
</ng-container>

When a user checks or unchecks an item, state.selectedIds is updated, therefore the isDataSelected selector is recalculated and the list must re-render. Every time the list re-renders, the lazy selector isDataSelected is invoked data.length number of times. Because the lazy selector implementation has O(n) time complexity, this template renders with O(n^2) time complexity - Ugh!. One magnitude of n for the length of data , another for state.selectedIds.length.

Here's one way to improve performance in that example:

@Selector([SelectedDataState])
isDataSelected(state: SelectedDataStateModel) {
  const selectedIds = new Set(state.selectedIds);
  return (id: number) => selectedIds.has(id);
}

The above selector implementation creates a Set. The lazy selector returned by isDataSelected is a closure with access to the selectedIds variable created in the parent function. The lazy selector uses Set.has which has O(1) time complexity.

Now when the list re-renders, because the lazy selector has O(1) time complexity, this template renders with O(n) time complexity. This optimizes performance by a magnitude of n.

Last updated