Skip to content

Instantly share code, notes, and snippets.

@dingbat
Last active February 22, 2019 05:03
Show Gist options
  • Save dingbat/a0e1f84a549a27fbfa6c0020e51608b2 to your computer and use it in GitHub Desktop.
Save dingbat/a0e1f84a549a27fbfa6c0020e51608b2 to your computer and use it in GitHub Desktop.

indexr.js

createTable

Indexr introduces a higher-order selector which is dubbed a "table", because it acts a bit like a database table which is able to be "queried" by attribute.

A table is created via createTable, which takes a selector that is expected to return a collection (can either be a map (e.g. if keyed by id) or a list), and indices, a mapping of ways to index that collection.

Here's an example:

import {createSelector} from 'reselect';
import {createTable} from '../indexr';

// normalized data in store
const getAllComments = state => state.comments;
// from UI store
const getSelectedPostId = state => state.selectedPostId;

// inefficient

const getCommentsForSelectedPost = createSelector(
  getAllComments,
  getSelectedPostId,
  (allComments, selectedPostId) => {
    return allComments.filter(comment => {
      return comment.get('postId') === selectedPostId;
    });
  },
);

// more efficient

// build comments table "indexed" on postId
const commentsTable = createTable({
  selector: getAllComments,
  indices: {
    byPostId: comment => comment.get('postId'),
  },
});

const getCommentsForSelectedPost = createSelector(
  commentsTable.indexedSelector('byPostId'),
  getSelectedPostId,
  (commentsByPostId, selectedPostId) => {
    return commentsByPostId.get(selectedPostId);
  },
);

In the inefficient example, whenever state.selectedPostId changes, we do an O(N) search, even if state.comments doesn't change at all.

Using a table is more efficient because whenever state.comments changes, the table reconstructs the index, but every time state.selectedPostId changes, it's only an O(1) operation.

The indices object accepts:

  • function (called on the collection object, returns value to be used as index key)
  • string (equivalent to a function that calls .get(<string>) on the object)
  • multibucket (see section below)
  • array (use for multiple levels of indices - accepts functions, strings, or multibuckets as described before.)

Some more examples with output:

const byDayCreated = comment => DateTime.parse(comment.get('postId')).startOf('day');
const commentsTable = createTable({
  selector: getAllComments,
  indices: {
    byDayCreated,
    postId: "postId", // same as example above
    byMultipleKeys: comment => ["postId", byDayCreated],
  },
});

// multiple index usage:
commentsTable.indexedSelector("byMultipleKeys")(state)
=>
{
  postId1: {
    2019-02-20: <collection of comments for post1 written on 2019-02-20>,
    2019-02-19: <collection of comments for post1 written on 2019-02-21>,
  },
  postId2: {
    2019-02-20: <collection of comments for post2 written on 2019-02-20>,
  }
}

multiBucket

Sometimes you want a single object to appear in multiple "groups"/"buckets" at once. This may occur if your object has an array key (even though your stores may be denormalized), or you can always provide your custom function that returns an array of possible indices.

import { createTable, multiBucket } from "../indexr";

const commentsTable = createTable({
  selector: getAllComments,
  indices: {
    byTag: multiBucket("tags"),
    // or
    byTag: multiBucket(c => c.get("tags")),
  },
});

// comments:
[{id: 1, tags: ["a"]}, {id: 2, tags: ["b"]}, {id: 3, tags: ["a", "b"]}]

commentsTable.indexedSelector("byTag")(state)
=>
{
  "a": [{id: 1, tags: ["a"]}, {id: 3, tags: ["a", "b"]}],
  "b": [{id: 2, tags: ["b"]}, {id: 3, tags: ["a", "b"]}],
}

Note multiBuckets can be used in combination with other indices if desired:

const commentsTable = createTable({
  selector: getAllComments,
  indices: {
    byPostIdAndTag: ['postId', multiBucket('tags')],
  },
});

unindexedSelector

If you ever need the original, raw collection that was passed in as selector to createTable, you can access unindexedSelector directly on the table.

createSelector(
  commentsTable.unindexedSelector,
  (comments) => {
    ...
  }
)

reselectSource

If you need to do some transformation to the table's data ("reselect" the original table's source selector), you can use this function to generate a new table:

const getRatingFilter = state => state.ratingFilter;
const ratingFilteredCommentsTable = commentsTable.reselectSource(
  getRatingFilter,
  (comments, ratingFilter) => {
    return comments.filter(c => c.get('rating') >= ratingFilter);
  },
);

Note that the signature of reselectSource is very similar to createSelector - except the very first argument (the original table source collection) is implicit/omitted, and passed directly into the result function.

Note that a more performant solution in this case might be to add rating (or even the conditional expression) as an index, but sometimes the filter/transformation is more complicated. Regardless, this is not recommended for common usage.

import { Map, List } from "immutable";
import { createSelector } from "reselect";
// Exported just for testing
export const index = (data, indexBy) => {
if (indexBy.isMultiBucket) {
return indexBy.group(data);
}
switch(typeof(indexBy)) {
case "function":
return data.groupBy(indexBy);
case "string":
return data.groupBy(value => value.get(indexBy));
default:
// Array, so recursively generate nested groups
const [firstKey, ...rest] = indexBy;
if (!firstKey) {
return data;
}
const indexed = index(data, firstKey);
return indexed.map((group) => {
return index(group, rest);
});
}
};
export const multiBucket = (indexBy) => {
let indexFunc;
const type = typeof(indexBy);
if (type === "string") {
indexFunc = (obj) => obj.get(indexBy);
} else if (type === "function") {
indexFunc = indexBy;
} else {
throw new Error(`Index type \`${type}\` not supported by multiBucket - must be string (key) or function.`);
}
return {
isMultiBucket: true,
group(data) {
const isMap = Map.isMap(data);
return data.reduce((result, obj, objKey) => {
const keys = indexFunc(obj);
return keys.reduce((r, key) => {
if (isMap) {
return r.setIn([key, objKey], obj);
}
return r.update(key, (list) => list ? list.push(obj) : List.of(obj));
}, result);
}, new Map());
}
};
};
export const createTable = ({selector, indices}) => {
const indexedSelectors = {};
Object.keys(indices).forEach(indexKey => {
indexedSelectors[indexKey] = createSelector(
selector,
(data) => {
return index(data, indices[indexKey]);
},
);
});
return {
unindexedSelector: selector,
reselectSource(...args) {
const resultFunc = args.pop();
const newSourceSelector = createSelector(selector, ...args, resultFunc);
return createTable({
selector: newSourceSelector,
indices,
});
},
indexedSelector(indexName) {
const selector = indexedSelectors[indexName];
if (!selector) {
const available = Object.keys(indices).join(", ");
throw(new Error(`Indexed selector \`${indexName}\` not found on table. Available indices are [${available}]`));
}
return selector;
},
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment