Scaling data selection on the client

profile picture

Spencer Miskoviak

February 26, 2018

Photo by Ander Burdain

What is a Selector?

A selector is a helper function that accepts the state (and maybe a few parameters), and returns a specific slice or combination of the overall state. For example, say we had a list of animals and the type of each animal. Our state might look something like this:

const state = {
    animals: [
        { name: "Alligator", typeId: 1 },
        { name: "Shark", typeId: 3 },
        { name: "Platypus", typeId: 2 },
        { name: "Anglerfish", typeId: 3 },
        { name: "Crocodile", typeId: 1 }
    ],
    types: [
        { id: 1, name: "Reptiles" },
        { id: 2, name: "Mammals" },
        { id: 3, name: "Fish" }
    ]
}

Now, we want to display this list, but filtered to a single type of animal. Let’s say we want only the reptiles. A simple selector for this would look like the following:

function getReptiles(state) {
    // Find the Reptile type object (assuming we don't know the id)
    const reptiles = state.types.find(({ name }) => name === "Reptiles"); 

    // Find all animals that are the Reptile type
    const animals = state.animals.filter(({ typeId }) => typeId === reptiles.id);

    // Return an array of only Reptiles 🦎
    return animals;
}

That’s it, a selector is just a function. However, by moving this logic into a separate function it has several advantages over putting it directly in the component or UI that needs this data. For example, today a popular solution is putting this logic into the mapStateToProps function in a traditional React Redux application.

Why Selectors?

Separation of concerns

Putting all of the logic directly in the mapStateToProps makes it difficult to visually parse and skim a file. The data selection logic and the presentational logic are typically crammed in the same file. This is great for small, simple components. However, as the complexity of both increases this can become unwieldy to understand at a glance.

When I visit this component in the future — to refactor, fix a bug or copy a portion of it — I usually care about only one of two things. What is the data this component needs? Or, how is the data being presented and used? When we want to modify how the reptiles are selected from the store, we can focus on the specific selector. When we want to modify how the reptiles are displayed we can modify the component. Additionally, by separating selectors it makes them very easy to test. The selector can be passed any state as input and assert the expected output without worrying about how it is presented.

Reuse

What happens when we want to display reptiles, but on a different page or in a different component? Or, what happens when we want to create another component that displays the number of reptiles? We presumably want the same logic, but calling length on the results?

Without selectors, we would probably copy the same logic components. If the shape of the state changes down the road we have to update it in each place we use this logic. Or if a bug crops up, it has to be fixed multiple time. We also have to write additional tests to cover the logic in each place.

With selectors, the same selector can be reused over and over and even composed within another selector. For example, to get the count of reptiles we may create a selector that looks something like this:

function getReptileCount(state) {
    return getReptiles(state).length
}

Memoization

If the state gets extremely large or the data selection logic is computationally expensive, it’s possible there could be performance implications. This might be on a highly interactive page or page with a lot of data. In either case, each time the state changes the selector needs to be recomputed. Moreover, if the selector is used in multiple places, it needs to be recomputed multiple times each time the state changes. In the majority of these cases, since the selector is only working on a small slice of the overall state, it’s unlikely that tiny slice of state has changed. In this case, can we avoid the recompute?

Yes! We could implement this, but this is where an external package, reselect comes into play. With reselect, out two examples would look like the following:

import { createSelector } from "reselect";

const getReptiles = createSelector(
  state => state.types.find(({ name }) => name === "Reptiles").id,
  state => state.animals,
  (id, animals) => animals.filter(({ typeId }) => typeId === id)
);

const getReptileCount = createSelector(
  getReptiles,
  reptiles => reptiles.length
);

By default, reselect will not recompute unless the arguments to the result function (the last argument to createSelector) have changed since the previous computation. For example, for the first selector, if the id for the reptile type or the animals array changes, then the selector will recompute the list of filtered animals. One thing to note with this, the default equality check is a shallow instance equality. This can be an issue if the state is being directly mutated.

General selectors on a normalized state

You may have noticed in this example, we were only selecting reptiles. Presumably, we’d also want a selector for only “Mammals” and only “Fish.” One approach is to copy getReptiles and do a find and replace “Reptiles” with “Mammal” or “Fish.” But this is fragile if the logic for selecting a type of animal changes, it needs to be changed three times. And many of the same duplication problems highlighted earlier.

Since we have a normalized state, basically meaning we flattened nested associations, we can create a generic selector for this. It would look something like this:

import { createSelector } from "reselect";

const getAnimalsByType = createSelector(
  (state, props) => state.types.find(({ name }) => name === props.name).id,
  state => state.animals,
  (id, animals) => animals.filter(({ typeId }) => typeId === id)
);

And would be used like the following:

getAnimalsByType(state, { name: "Reptiles" });
getAnimalsByType(state, { name: "Mammals" });
getAnimalsByType(state, { name: "Fish" });

Note: this specific example would “break” memoization. The default cache size is one, so it would recompute every time the animal type name changes. In this scenario, we would likely want to use a makeMapStateToProps function with a makeGetAnimalsByType to retain the memoization.

Bonus: Types

Types are the last thing to highlight, but not specific to selectors. It’s always nice to have type safety, especially on larger applications where selectors are predominantly useful. Reselect supports TypeScript, but the ordering of the generics aren’t immediately obvious at a first glance. For our getReptiles selector, the types would be the following (assuming the types have already been defined):

import { createSelector } from "reselect";

const getReptiles = createSelector<State, Reptile[], number, Animal[]>(
  state => state.types.find(({ name }) => name === "Reptiles").id,
  state => state.animals,
  (id, animals) => animals.filter(({ typeId }) => typeId === id)
);

const getReptileCount = createSelector<State, number, Reptile[]>(
  getReptiles,
  reptiles => reptiles.length
);

The createSelector function accepts generics in the following order: input, output, first input selector output, second input selector output, etc. For the first selector, the input is of type State which is a type that matches our state originally defined above. The output is an array of reptiles, Reptile[]. The first input selector output is the reptile type id, or a number. The second input selector output is an array of animals, Animal[]. We don’t have to define any additional types for the result function, since the arguments match the output of the previous selector functions and the return value is the output.

Final Thoughts

The first time I saw a selector, I was skeptical. It looked like an unnecessary abstraction that was only there to add complexity. After using them for some time, I now realize how beneficial they are when working on a large Redux application. When used correctly, they add a layer of comfort and efficiency. It always feels great when I can reuse a selector, knowing it’s already well tested and avoiding expensive duplicate computations.

Tags:

course

Practical Abstract Syntax Trees

Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.

Check out the course