DEV Community

loading...
Cover image for NgRx: Fun With `createSelectorFactory()`

NgRx: Fun With `createSelectorFactory()`

zackderose profile image Zack DeRose Updated on ・9 min read

This article is an investigation of some of the more complex features of the @ngrx/store library. For some of the basics of NgRx and the redux pattern, be sure to check out the NgRx docs as they're excellent!!

*** Also a quick disclaimer regarding the examples given in this article! Be sure to watch Mike Ryan's talk about action hygiene and creating an event-driven store, as opposed to command-driven store. As this article is a dive into the mechanics of @ngrx/store, we'll be using command-driven actions in our examples, but in no way should this be taken as me advocating these types of actions in your real-world apps! ***

What Happens When You Call createSelector()

The NgRx Diagram

The job of a selector is to 'query' or 'derive' data from the object held in our Store. Assuming you had set up a Typescript Interface or Type to represent the shape of your Store, you'd probably expect this selector code to look something like this:

export interface FeatureState {
  array: string[];
}

export interface State {
  featureNameplace: FeatureState;
}

export function selectArray(state: State) {
  return state.featureNameplace.array;
}
Enter fullscreen mode Exit fullscreen mode

... and you'd be entirely correct! As a matter of fact, you can absolutely 100% pass exactly this function into Store#select and the select() operator:

mind blown

[And if you REALLY want your mind blown, you can actually just swap out that select operator for map while you're at it]:

If you're like me - you probably have gone for awhile in your NgRx journey before realizing this could work. All beginner material I've encountered on ngrx/store (including the NgRx docs) will tell you to write your selectors like this:

import { createFeatureSelector, createSelector } from '@ngrx/store';

export interface FeatureState {
  array: string[];
}
export interface State {
  featureNameplace: FeatureState;
}

export selectFeatureNameplace = createFeatureSelector<FeatureState>('featureNameplace');
export selectArray = createSelector(
  selectFeatureNameplace,
  featureState => featureState.array
);
Enter fullscreen mode Exit fullscreen mode

This gave me (and I suspect many folks) the impression that there's some deep magical-ness to selectors that when mixed with the equally magical Store object will magically create an Observable.

it's magic baby

As it turns out, for the most part, these createSelector functions from the @ngrx/store API are just returning functions that return the same exact values as our original simple function.

[As a bonus! In case you weren't aware, selectors are SUPER easy to test because of this]:

import { selectArray, AppState } from './state.ts';

describe('selectArray', () => {
  test('returns the featureNameplace.array of a state object', () => {
    const state: AppState = {
      featureNameplace: {
        array: ['test'];
      }
    };
    const result = selectArray(state);
    expect(result).toEqual(['test']);
  });
});
Enter fullscreen mode Exit fullscreen mode

There is still some magic to the createSelector() function though. Here's the actual implementation of createSelector() straight from the @ngrx/store source code:

export function createSelector(
  ...input: any[]
): MemoizedSelector<any, any> | MemoizedSelectorWithProps<any, any, any> {
  return createSelectorFactory(defaultMemoize)(...input);
}
Enter fullscreen mode Exit fullscreen mode

As we can see, createSelector is actually just a wrapper for calling createSelectorFactory() with defaultMemoize, and then currying over the arguments originally passed into createSelector().

There is no fridge!!!

Note that this createSelectorFactory() function being called here is actually exported from @ngrx/store - meaning that it is actually meant for us to use! Let's take a look at defaultMemoize:

export function defaultMemoize(
  projectionFn: AnyFn,
  isArgumentsEqual = isEqualCheck,
  isResultEqual = isEqualCheck
): MemoizedProjection {
  let lastArguments: null | IArguments = null;
  // tslint:disable-next-line:no-any anything could be the result.
  let lastResult: any = null;
  let overrideResult: any;

  function reset() {
    lastArguments = null;
    lastResult = null;
  }

  function setResult(result: any = undefined) {
    overrideResult = { result };
  }

  function clearResult() {
    overrideResult = undefined;
  }

  // tslint:disable-next-line:no-any anything could be the result.
  function memoized(): any {
    if (overrideResult !== undefined) {
      return overrideResult.result;
    }

    if (!lastArguments) {
      lastResult = projectionFn.apply(null, arguments as any);
      lastArguments = arguments;
      return lastResult;
    }

    if (!isArgumentsChanged(arguments, lastArguments, isArgumentsEqual)) {
      return lastResult;
    }

    const newResult = projectionFn.apply(null, arguments as any);
    lastArguments = arguments;

    if (isResultEqual(lastResult, newResult)) {
      return lastResult;
    }

    lastResult = newResult;

    return newResult;
  }

  return { memoized, reset, setResult, clearResult };
}
Enter fullscreen mode Exit fullscreen mode

Right off the bat, we can see that defaultMemoize() function is exported from @ngrx/store as well - we'll use this to our advantage later on!

When taking a deeper look into this function, we see that this is a closure that is exposing memoize(), reset(), setResult(), and clearResult() methods, with most of the action happening in memoize(). Essentially, this function will look at the closure's state and

  • if an overrideResult exists, it will return that result
  • if lastArguments don't exist, it will call the projection function (btw - a projection function is the function we pass as the last argument we pass to createSelector() when creating a selector the standard way), set the lastResult of the closure with the result of the call, and return that result.
  • if lastArguments exist and they match the current arguments (according to the isArgumentsEqual logic!) then return the lastResult
  • calculate a new result by calling the projection function, set lastArguments and lastResult (assuming it is different than your new result) and return either the newResult if new, or lastResult if they are equal.

that's alot

TL;DR: if the relevant state is the same as last time the selector ran, the result is pulled from memory instead of called.

Quick Aside on Projectors

It may not be entirely clear what a projector or projectionFn is, so to clear things up:

Anatomy of a selector

A projection function is the final argument of the selector factory.

Also note that mocking projection functions can be helpful for testing selectors:

describe('barAndFooSelector()', () => {
  test('mocking parent selectors', () => {
     const initialState = { bar: 'bar' };
     const initialFoo = 'foo';
     const result = barAndFooSelector.projector(
       initialState,
       initialFoo
     );
     expect(result).toEqual({ bar: 'bar', foo: 'foo' });
  });
});
Enter fullscreen mode Exit fullscreen mode

(There may be some philosophical discussions regarding if this is testing an implementation detail [and I would tend to agree!!] but that's a story for another time!)

Creating Our Own createOrderDoesNotMatterSelector()!!

Let's say that we'd like to make a selector for an array we're keeping in our global state, but the order of the array doesn't matter to us:

function removeMatch(
  arr: string[],
  target: string
): string[] {
  const matchIndex = arr.indexOf(target);
  return [
    ...arr.slice(0, matchIndex),
    ...arr.slice(matchIndex + 1)
  ];
}

function orderDoesNotMatterComparer(a: any, b: any): boolean {
  if (!Array.isArray(a) || !Array.isArray(b)) {
    return a === b;
  }
  if (a.length !== b.length) {
    return false;
  }
  let tempB = [...b];
  function reduceToDetermineIfArraysContainSameContents(
    previousCallResult: boolean,
    arrayMember: any
  ): boolean {
    if (previousCallResult === false) {
      return false;
    }
    if (tempB.includes(arrayMember)) {
      tempB = removeMatch(tempB, arrayMember);
      return true;
    }
    return false;
  }
  return a.reduce(
    reduceToDetermineIfArraysContainSameContents,
    true
  );
}
Enter fullscreen mode Exit fullscreen mode

^ This function will tell us if two arrays are equal given that order doesn't count!

Once we have this function, we can pick up the createSelectorFactory() and the defaultMemoize() puzzle pieces that the @ngrx/store exposes and build our createOrderDoesNotMatterSelector():

export const createOrderDoesNotMatterSelector =
  createSelectorFactory(
    (projectionFn) =>
      defaultMemoize(
        projectionFn,
        orderDoesNotMatterComparer,
        orderDoesNotMatterComparer
      )
  );
Enter fullscreen mode Exit fullscreen mode

If using our new selector factory, we can optimize some array-like things! Let's make a new selector to select our array out of the Store, and also a selector from that to select the sum of all the items in that array. Here's an example app showing how our custom selector factory compares to the standard createSelector().

We can see when we click sort, we'll observe more emissions from our regular selectors than our 'order does not matter' selectors. Also, if we open the console, we'll see from the logs that even though the regularSelectSum is not emitting as much (there's a distinctUntilChanged() blocking the extra emissions), it is still calling the projector much more often than it's 'order does not matter' counterpart.

Creating a Full History Cache Memoization Selector

Putting aside the defaultMemoize() function provided by @ngrx/store, we can actually write a memoization strategy that records all previous runs of a selector (recall that the defaultMemoize() will only remember the 1 previous result and arguments).

Before we get started, note that the defaultMemoize() strategy is there for a reason! Creating a full history cache will absolutely take up more memory, and for most selectors, it's probably not too often that duplicate arguments will be called except for back-to-back (&& the defaultMemoize() will cover back-to-back scenarios). So before you start recreating this across your codebases, make sure that the benefits of speed are worth the cost of extra memory (lulz, j/k, the only performance that matters is bundle size.... fml). A use-case where cache hits are likely and computation of the projector function is expensive would be the ideal for this kind of selector.

Also for more on memoization, be sure to check out this article I wrote on the topic!

Alright, so essentially, we want to make a closure, the same way that the defaultMemoize function created a closure, but instead of tracking things like lastResult and lastArguments, we'll make a single cache object, that will serve as an indexed map of some representation of projector arguments to project results. Installing 'object-hash' from npm will get us standard SHA-1 hashing on the arguments, for indexing our cache, and from there, we'll check on the cache to see if a match exists. If it does, will return the match. If not, we'll call the projector, stash it in the cache, and then return it. All the other methods we can assign to no-ops too, since they're not needed in our solution.

import * as hash from 'object-hash';

const createFullHistorySelector = createSelectorFactory(
  (projectionFunction) => {
    const cache = {};

    function memoized() {
      const hashedArguments = hash(...arguments);
      if (cache[hashedArguments] != null) {
        cache[hashedArguments] = projectionFunction.apply(null, arguments);
        console.log('calculationMade');
      }
      return cache[hashedArguments];
    }
    return {
      memoized,
      reset: () => {},
      setResult: () => {},
      clearResult: () => {},
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

Now, we can proceed to recreate a similar example app comparing how our new selector factory fairs vs. the default one:

Be sure to open the console on this one, and we'll see if we push on 1, then 5, then 3 - each of these three with result in a cache miss, causing the projection function to be run.

Then if we pop all three off, we'll see the cache hits for these, AND our full history projectors will not get called! (Meanwhile the regular selectors are having to re-call their projectors!).

Going even further though! If we push back on 1, then 5, then 3, we'll continue to see cache hits and no calls to the projectors!

Very cool! What's more - this selector factory could absolutely be exported from a utility library and used widely across just about any selector!!

Creating a createImmutableSelector() function!

In my earlier days of working with NgRx, I had assumed that the observables created by selectors were immutable - that a deep clone of them were being emitted from the observable, and that they were not being passed by reference.

I WAS WRONG.

what a fool I was

My impression is that many people make similar assumptions about select! But with our new-found knowledge of createSelectorFactory(), we can fix this for everyone!

Note that I'm still using full history strategy (as well as immutable returns) in this example. It's essentially a copy & paste of the previous example, but with the cloneDeep() (from Lodash) called just before returning!

Looking at the component, we can see for the immutable selector, we are making a local copy that we can freely change around - as if it's in its own scope (because it is 🤯) - without altering the global state! This can be very useful in some situations/use-cases!

On the other hand, trying to pop off of the regular selector will produce an error. This is actually quite beneficial as the alternative would have been changing the value of the Store OUTSIDE of the reducers!!

Like the createFullHistorySelector() factory we made in the previous section, this one too is very generic in terms of being able to be used in virtually any selector that you would have made with createSelector()!

CONCLUSION

Hopefully there's been lots of interesting learning about selectors for you in this article!! I hope that it's given you a better understanding of how NgRx is put together, as well as maybe some ideas on how to create your own selector factories - or even just use some of the factories provided in this article!

Huge props to the NgRx team - the craftsmanship of this lib really holds up when taking a deeper look into it, and I think that speaks very highly of everyone on the core team!!

More Content By Zack

Blogs
YouTube
Twitch
Twitter
All Video Content Combined

Discussion (0)

pic
Editor guide