Chain & Empty Selectors
Chain Selector
If you design your state like a data base, you should be familiar with foreign keys. Imagine that you have this normalized state:
const state = {persons: {1: {id: 1,firstName: 'Marry',secondName: 'Poppins',},2: {id: 2,firstName: 'Harry',secondName: 'Potter',},},messages: {100: {id: 100,personId: 1,text: 'Hello',},200: {id: 200,personId: 2,text: 'Buy',},},documents: {111: {id: 111,messageId: 100,},222: {id: 222,messageId: 200,},},};
And you need get a person by messageId
. You can use next code to solve this problem:
const messagesSelector = (state: State) => state.messages;const personsSelector = (state: State) => state.persons;const messagePersonSelector = (state: State, props: { messageId: number }) => {const message = messagesSelector(state)[props.messageId];return messagesSelector(state)[message.personId];};
It is working solution, but you can't use this selector as input for Cached Selector, because it fully uncached. Let's try to re-write this example via re-reselect:
import { createCachedSelector } from 're-reselect';import { prop } from 'reselect-utils';const messagePersonSelector = createCachedSelector([(state: State) => state, prop<{ messageId: number }>().messageId()],(state, messageId) => {const message = messagesSelector(state)[messageId];return messagesSelector(state)[message.personId];},)({keySelector: prop<{ messageId: number }>().messageId(),});
Prop Selector
is described here. Now our solution has become only worth. This selector is still fully uncached, because it depends on whole state. Additionally, written selector has an ugly combiner, which calls other selectors right in body. Reselect Utils propose next way:
import { createCachedSelector } from 're-reselect';import { prop, createChainSelector, createBoundSelector } from 'reselect-utils';const personsSelector = (state: State) => state.persons;const personSelector = createCachedSelector([personsSelector, prop<{ personId: number }>().personId()],(persons, personId) => persons[personId],)({keySelector: prop<{ personId: number }>().personId(),});const messagesSelector = (state: State) => state.messages;const messageSelector = createCachedSelector([messagesSelector, prop<{ messageId: number }>().messageId()],(messages, messageId) => messages[messageId],)({keySelector: prop<{ messageId: number }>().messageId(),});const messagePersonSelector = createChainSelector(messageSelector).chain((message) =>createBoundSelector(personSelector, { personId: message.id }),).build();
Bound Selector
is described here. You can build longer chains. For example, if you want find person by documentId
you can add next code:
import { prop, chain, bound } from 'reselect-utils';const documentsSelector = (state: State) => state.documents;const documentSelector = createCachedSelector([documentsSelector, prop<{ documentId: number }>().documentId()],(documents, documentId) => documents[documentId],)({keySelector: prop<{ documentId: number }>().documentId(),});const documentPersonSelector = chain(documentSelector).chain((document) => bound(messageSelector, { messageId: document.id })).chain((message) => bound(personSelector, { personId: message.id })).build();
Here we have used compact aliases chain
and bound
to reduce code. Now if we call documentPersonSelector
with state declared above, we can receive next results:
documentPersonSelector(state, { documentId: 111 }); // => { firstName: 'Marry', ... }documentPersonSelector(state, { documentId: 222 }); // => { firstName: 'Harry', ... }
Chain Selector
uses monad pattern like the Promises. In chain callback you receive result of previous selector, and you should return a new derived selector. Chain Selector
is flexible enough, you can make decisions and use conditions in chain callback:
import { createCachedSelector } from 're-reselect';import { prop, chain } from 'reselect-utils';const personsSelector = (state: State) => state.persons;const personSelector = createCachedSelector([personsSelector, prop<{ personId: number }>().personId()],(persons, personId) => persons[personId],)({keySelector: prop<{ personId: number }>().personId(),});const fullNameSelector = createCachedSelector([personSelector],({ firstName, secondName }) => `${firstName} ${secondName}`,)({keySelector: prop<{ personId: number }>().personId(),});const shortNameSelector = createCachedSelector([personSelector],({ firstName, secondName }) => `${firstName[0]}. ${secondName}`,);const nameSelector = chain(prop<{ isShort: boolean }>().isShort()).chain((isShort) => (isShort ? shortNameSelector : fullNameSelector)).build();nameSelector(state, { personId: 1, isShort: false }); // => 'Marry Poppins'nameSelector(state, { personId: 1, isShort: true }); // => 'M. Poppins'
Here we change business logic implementation dynamically by isShort
property.
Empty Selector
Another place where we can use conditional chaining is optional foreign keys. For example, we have next structure:
type Parent = {parentId: number;};type Child = {childId: number;parentId?: number;};type State = {firstGeneration: Record<number, Parent>;secondGeneration: Record<number, Child>;};const exampleState: State = {firstGeneration: {1: { parentId: 1 },2: { parentId: 2 },},secondGeneration: {101: { childId: 101, parentId: 1 },102: { childId: 102 },},};
As you can see, child with id 102
haven't a parent, so this relation is optional. We can write next selector for this case:
import { createCachedSelector } from 're-reselect';import { prop, chain, bound, empty } from 'reselect-utils';const parentsSelector = (state: State) => state.firstGeneration;const parentSelector = createCachedSelector([parentsSelector, prop<{ parentId: number }>().parentId()],(parents, personId) => persons[personId],)({keySelector: prop<{ parentId: number }>().parentId(),});const childrenSelector = (state: State) => state.secondGeneration;const childSelector = createCachedSelector([childrenSelector, prop<{ childId: number }>().childId()],(children, childId) => children[childId],)({keySelector: prop<{ childId: number }>().childId(),});const parentByChildSelector = chain(childSelector).chain(({ parentId }) => {return parentId !== undefined? bound(parentSelector, {parentId,}): empty(parentSelector); // <- Pay attention here}).build();
We have used Empty Selector
here. Empty Selector
is a selector, which always returns undefined
. You can just write () => undefined
instead, but empty
will help you to infer types correctly. Also you can use more verbose helper alias: createEmptySelector
.
Aggregation
Another task, that can be solved via Chain Selector
, is aggregation. For example, you have these selectors:
import { createCachedSelector } from 're-reselect';import { prop } from 'reselect-utils';const personsSelector = (state: State) => state.persons;const personSelector = createCachedSelector([personsSelector, prop<{ personId: number }>().personId()],(persons, personId) => persons[personId],)({keySelector: prop<{ personId: number }>().personId(),});const fullNameSelector = createCachedSelector([personSelector],({ firstName, secondName }) => `${firstName} ${secondName}`,)({keySelector: prop<{ personId: number }>().personId(),});
And you need find out the longest full name. You can use Chain Selector
next way:
import { chain, bound, seq } from 'reselect-utils';const longestFullNameSelector = chain(personsSelector) // (1).chain((persons) =>seq(// (2)Object.values(persons).map((person) => bound(fullNameSelector, { personId: person.id }), // (3)),),).map((fullNames, // (4)) =>fullNames.reduce((longest, current) =>current.length > longest.length ? current : longest,),).build();
What happens here? At first, we select persons normalized structure in point (1)
. Next we use Sequence Selector
to create the aggregated selector from selector array in point (2)
. Each selector in selector array is Bound Selector
, see point (3)
. Finally, we use map
method in point (4)
to transform array of full names to the longest full method. The map
method is like the chain
method, but you can return calculated value from it instead of derived selector.
Both of map
and chain
methods are cached. It means, that passed to them callback will not be called while result from a previous selector in a chain is the same.
Unit Tests
You can test logic in your Chain Selectors
. Created Chain Selector
exposes special static field chainHierarchy
. You can use this field in your unit tests next way:
const documentPersonSelector = chain(documentSelector).chain((document) => bound(messageSelector, { messageId: document.id })).chain((message) => bound(personSelector, { personId: message.id })).build();const samplePerson = { id: 1 };const sampleMessage = { id: 100 };const sampleDocument = { id: 111 };const state = {persons: {1: samplePerson,},messages: {100: sampleMessage,},documents: {111: sampleDocument,},};const boundPersonSelector = documentPersonSelector.chainHierarchy(sampleMessage,);expect(boundPersonSelector(state)).toBe(samplePerson);const boundMessageSelector = documentPersonSelector.chainHierarchy.parentChain(sampleDocument,);expect(boundMessageSelector(state)).toBe(sampleMessage);