Using Normalizr to organize data in store. Part 2

2,759 reads

@ dashmagazine Dashbouquet Development Collection of posts from those who build Dashbouquet

In this article, I want to continue on the topic of using Normalizr in a React-Redux application and finally answer the question about an all-purpose API which was mentioned briefly in the previous article.

To remind you what that article was about, Normalizr is a utility that normalizes data represented by nested entities (like in server responses), so that it can be stored and used later just as if there was a copy of a database on the front-end (e.g. in the Redux store). In part 1 we had an example with the entity relations described by this diagram:

Fig. 1. Entity-Relationship Diagram

Normalizr has a denormalization API out of the box, but it may be insufficient because it requires data from the server to be fetched in the exact shape that you want to use in the application. For example, if you want to denormalize data from the example in the previous article so that a student entity includes courses, it has to be fetched exactly this way — courses inside students. But you may fetch courses within teacher entities or just courses separately. In this case, there are basically two ways: you may denormalize the entities in the selectors as was described in part I or you may define your own API which we will try to do here.

The main difficulty with the API is that unlike on the back-end, we don’t have all the models with their relations described on the front-end. Schemas we defined for Normalizr aren’t of much help either, because we don’t denote the relations between the entities there. In fact, front-end doesn’t know anything about entities and relations in the database. So if we want to include something in the model we request from the store, we should explicitly denote how to find what we want to include.

I think it would be good to be able to define a request to the store in this manner:

const schema = {

modelName: 'student',

include: [

{

modelName: 'studentCourse',

isRelation: true,

include: [

{

modelName: 'course',

include: [

{

modelName: 'teacher',

through: 'teacherCourse',

}

]

},

],

}

]

}

Here we want to get student from the store with the models denoted in the include property to be nested inside. It looks quite similar to the requests we were composing to get data from the server in the part I, only with a couple of additional properties. Here I use a property isRelation to denote that a relation should be included and a property through to tell the API how to find a required entity. We will get to that a bit later. Now we can try to implement the API. It should only have a couple of functions. The first one is called denormalize and should be called directly when using the API:

function denormalize (entity, models, schema) {

const {include} = schema;



const toInclude = include.map(i => {

const entities = getInclusion(entity, models, schema, i);

if (i.include) {

//if an inclusion has its own inclusion, call the function recursively

return entities.map(e => denormalize(e, models, i));

} else {

return entities;

}

});



const entityWithRelations = {...entity};

include.map((i, index) =>

entityWithRelations[i.modelName] = toInclude[index]);



return entityWithRelations;

}

This function should take as parameters an entity to be denormalized (entity) and all the entities from the store (models) to find the ones that have to be nested. Parameter schema here describes a request to the store as defined above. It should be an object of this kind:

{

modelName, //name of a requested model

through, //name of a relation through which it should be found

include, //an array of schemas to be included in the requested model

isRelation, //true, if the requested model is a relation

}

And the second function should get the entities to be nested from the models object. Here I made use of the fact that in a relation ids of the related models are stored as <modelName>Id:

function getInclusion(entity, models, modelSchema, inclusionSchema) {

const {modelName, isRelation: isModelRelation} = modelSchema;

const {modelName:inclusionName, through, isRelation} = inclusionSchema;



if (isModelRelation) { //include into a relation

const foreignKey = `${inclusionName}Id`;



return values(models[inclusionName])

.filter(m => m.id === entity[foreignKey]);

} else { //include into an entity

if (isInclusionRelation) { //include a relation

const ownKey = `${modelName}Id`;



return values(models[inclusionName])

.filter(m => m[ownKey] === entity.id);

} else { //include an entity

const ownKey = `${modelName}Id`;

const foreignKey = `${inclusionName}Id`;



const relations = values(models[through])

.filter(r => r[ownKey] === entity.id);

return relations.map(r => models[inclusionName][r[foreignKey]]);

}

}

}

We have to think about three cases here:

Case 1: include an entity into a relation. In this case, we can find the required entities taking an id from the relation we want to include into.

Case 2: include a relation into an entity. We have to go the opposite way — we find a relation by the entity id.

Case 3: include an entity into another entity. We have to walk through a bit longer way in this case. First, we find a relation by the entity id (just like in the second case) and then we find the required entities taking their ids from the relations.

This is pretty much it. One more thing to mention: on the top of the API as it is in the suggested implementation there should be a selector. If we use reselect, it may look like this:

export const selectModels = (state) => state.models;



export const find = (schema, ids) => createSelector (

selectModels,

(models) => {

const {modelName, include} = schema;

return !include

? values(pick(models[modelName], ids))

: values(pick(models[modelName], ids))

.map(v => denormalize(v, models, schema));

}

);

So, the actual request from a saga comes down to this shape:

const candidates = yield select(find({schema, ids}))

Methods values, pick and cloneDeep here are from Lodash.

This API was tested by me but never used in a real project so far. Though I think I will apply it as soon as I get an opportunity. It seems to be useful because we don’t need to write the same denormalizations in the selectors every time we want data from the store and it is probably easier to use. Anyway, it is more of a suggestion now than a verified and ready to use solution. If you have other thoughts on the subject, please feel free to comment the article.

Written by Ilya Bohaslauchyk

Share this story @ dashmagazine Dashbouquet Development Read my stories Collection of posts from those who build Dashbouquet

Tags