DEV Community

Cover image for A Beginner Friendly Guide to Memoization in NgRx Selectors
hassantayyab
hassantayyab

Posted on

A Beginner Friendly Guide to Memoization in NgRx Selectors

Memoization is a way to speed up functions by caching their results.

If you call a function with the same inputs, instead of recomputing everything again, it just returns the previous result from memory.

Very simplified:

function memoize<T extends (...args: any[]) => any>(fn: T): T {
  let lastArgs: any[] | null = null;
  let lastResult: any;

  return function (...args: any[]) {
    if (
      lastArgs &&
      args.length === lastArgs.length &&
      args.every((arg, i) => arg === lastArgs[i])
    ) {
      return lastResult; // same inputs -> reuse result
    }

    lastArgs = args;
    lastResult = fn(...args);
    return lastResult;
  } as T;
}
Enter fullscreen mode Exit fullscreen mode

So:
Same args → skip work → return cached result.


Why memoization matters in NgRx

In NgRx you often have:

  • A big state tree
  • Selectors that derive data from that state
  • Components that subscribe to those selectors

Some derived data can be expensive to compute:

  • Filtering large lists
  • Sorting
  • Computing aggregates
  • Mapping to complex view models

If your selector recalculates this on every change detection, your app feels heavier than it should.

This is where NgRx comes in with a gift:

Selectors created with createSelector are memoized by default.

They only recompute when their inputs change (by reference).


Basic NgRx selector example (with memoization)

Imagine a simple state:

export interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

export interface TodosState {
  todos: Todo[];
  filter: 'all' | 'completed' | 'active';
}
Enter fullscreen mode Exit fullscreen mode

Feature selector:

export const selectTodosState = createFeatureSelector<TodosState>('todos');
Enter fullscreen mode Exit fullscreen mode

Now a few selectors:

export const selectTodos = createSelector(
  selectTodosState,
  (state) => state.todos
);

export const selectFilter = createSelector(
  selectTodosState,
  (state) => state.filter
);

export const selectFilteredTodos = createSelector(
  selectTodos,
  selectFilter,
  (todos, filter) => {
    console.log('Recomputing filtered todos...');

    if (filter === 'completed') {
      return todos.filter((t) => t.completed);
    }

    if (filter === 'active') {
      return todos.filter((t) => !t.completed);
    }

    return todos;
  }
);
Enter fullscreen mode Exit fullscreen mode

What NgRx does for you:

  • selectFilteredTodos is memoized
  • It only recalculates when:

    • selectTodos returns a new array reference, or
    • selectFilter returns a new filter value

If unrelated parts of the store change, and the todos array and filter are the same references, NgRx just returns the cached filtered list.

Less CPU, fewer unnecessary component updates.


How this helps your Angular components

Component:

@Component({
  selector: 'app-todos',
  template: `
    <div *ngFor="let todo of todos$ | async">
      {{ todo.title }}
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodosComponent {
  todos$ = this.store.select(selectFilteredTodos);

  constructor(private store: Store) {}
}
Enter fullscreen mode Exit fullscreen mode

Because selectFilteredTodos is memoized:

  • If state changes somewhere else (e.g. user profile), but todos slice is unchanged:

    • Selector returns the same array reference
    • With OnPush, Angular sees no new value → no re-render of this list

This is exactly the combo you want:

  • NgRx memoization
  • OnPush change detection
  • Less work, smoother app

Memoization and selectors with props

You can also use memoization when your selector depends on an argument, for example selecting a single todo by id.

export const selectTodoById = (id: string) =>
  createSelector(
    selectTodos,
    (todos) => {
      console.log('Recomputing todo by id...', id);
      return todos.find((t) => t.id === id) ?? null;
    }
  );
Enter fullscreen mode Exit fullscreen mode

Then in your component:

todo$ = this.store.select(selectTodoById(this.todoId));
Enter fullscreen mode Exit fullscreen mode

NgRx memoizes per selector instance here:

  • For this particular id, as long as selectTodos returns the same array reference, the selector returns the cached todo and doesn’t run find again.

A subtle gotcha: new objects break memoization

Memoization in NgRx works based on referential equality of the selector inputs.

If you do this:

export const selectTodoViewModels = createSelector(
  selectTodos,
  (todos) =>
    todos.map((t) => ({
      ...t,
      label: `${t.completed ? '' : ''} ${t.title}`,
    }))
);
Enter fullscreen mode Exit fullscreen mode

Each time selectTodoViewModels recomputes, it returns a new array with new objects.

The selector itself is still memoized, but if todos changes reference often, you’re rebuilding everything.

Better pattern in many cases:
Keep heavy transformations inside selectors, but don’t unnecessarily create new objects if inputs haven’t changed. Memoization helps at the selector level, but your code still needs to be reasonable.


Custom memoization (if you ever need more control)

NgRx also lets you define selectors with custom memoization via createSelectorFactory, but honestly in most apps:

  • createSelector’s built-in memoization is enough
  • You rarely need to touch lower-level memoization

If you ever have a crazy use case with complex arguments, that’s when you look into selector factories.


Quick recap

  • Memoization = caching function results based on inputs
  • NgRx selectors created with createSelector are memoized by default
  • They recompute only when their input selectors’ outputs change by reference
  • This works beautifully with OnPush and large apps where you can’t afford to recompute everything on every change detection
  • Use selectors to:

    • Filter, sort, and derive data
    • Keep components lean
    • Let memoization + change detection handle performance

Top comments (0)