We want to create a package that will allow us to get rid of the constant creation of the same type of reducer'ov and action creator'ov for each model, obtained by the API.
The first part is this article. In it, we created a config for our future package and found out that it should contain an action creator, middleware and reducer. Let's start the development!
We begin with the simplest - action creator. Here our contribution will be minimal - we just need to write the traditional action creator for redux-api-middleware
, taking into account our config.
To get users, it should look like this:
import {CALL_API} from 'redux-api-middleware'; const get = () => ({ [CALL_API]: { endpoint: 'mysite.com/api/users', method: 'GET', types: ['USERS_GET', 'USERS_SUCCESS', 'USERS_FAILURE'] } });
In action, you can add more headers, credentials. If the request is successful, then we get USERS_SUCCESS, and in it in action.payload are the data obtained by the API. If an error occurs, we get USERS_FAILURE, which has errors in action.errors. All this is described in detail in the documentation .
In the future, for simplicity of reasoning, we will assume that the data in payload have already been normalized. We are interested in how we can modernize our creator to get all entities. Everything is quite simple: in order to return the necessary entities, we transfer the name of this entity to the creator:
import {CALL_API} from 'redux-api-middleware'; const initGet = (api) => (entity) => ({ [CALL_API]: { endpoint: api[entity].endpoint, // endpoint method: 'GET', types: api[entity].types // actions } });
It is also necessary to add filtering of the server response by GET-parameters, so that we can go only for the necessary data and not drag anything extra. I prefer to pass GET parameters as a dictionary and serialize them with a separate objectToQuery method:
import {CALL_API} from 'redux-api-middleware'; const initGet = (api) => (entity, params) => ({ [CALL_API]: { endpoint: `${api[entity].endpoint}${objectToQuery(params)}`, method: 'GET', types: api[entity].types } });
Initialize the creator itself:
const get = initGet(config.api);
Now, calling the get method with the necessary arguments, we will send a request for the necessary data. Now we need to take care of how to store the received data - write a reducer.
More precisely, two. One will be placed in the store entity, and the other - the time of their arrival. To keep them in one place is a bad idea, because then we will mix clean data with the local state of the application on the client (after all, each client has their own data arrival time).
Here we will need the same successActionTypes and react-addons-update
, which will ensure the stability of the store. Here we will have to go through each entity from entities and make a separate $ merge, that is, combine the keys from defaultStore and receivedData.
const entitiesReducer = (entities = defaultStore, action) => { if (action.type in successActionTypes) { const processedData = {}; const receivedData = action.payload.entities || {}; for (let entity in receivedData) { processedData[entity] = { $merge: receivedData[entity] }; } return update(entities, processedData); } else { return entities; } };
Similarly for the timestampReducer, but there we will set the current time of arrival of the data in the store:
const now = Date.now(); for (let id in receivedData[entity]) { entityData[id] = now; } processedData[entity] = { $merge: entityData };
schema or lifetime, successActionTypes will be needed by us at initialization - we wrote the same code in the action creator.
To get the defaultState, do this:
const defaultStore = {}; for (let key in schema) { // lifetime, reducer' defaultStore[key] = {}; }
successActionTypes can be obtained from the api config:
const getSuccessActionTypes = (api) => { let actionTypes = {}; for (let key in api) { actionTypes[api[key].types[1]] = key; } return actionTypes; };
This, of course, is a simple task, but one such simple reducer will save us a lot of time writing our own reducer for each data type.
Routine work is finished - let's move on to the main component of our package, which will take care of going only for the data that is really needed, and at the same time not forcing us to think about it.
Let me remind you that we believe that normalized data comes to us immediately. Then in the middleware, we have to go through all the data obtained in entities, and collect a list of id missing related entities, and make a request to the API for this data.
const middleware = store => next => action => { if (action.type in successActionTypes) { // action, const entity = successActionTypes[action.type]; // const receivedEntities = action.payload.entities || {};; // const absentEntities = resolve(entity, store.getState(), receivedEntities); // for (let key in absentEntities) { const ids = absentEntities[key]; // id if (ids instanceof Array && ids.length > 0) { // store.dispatch(get(key, {id: ids})); // action, } } } return next(action); }
successActionTypes, resolve and get need to be passed to middleware during initialization.
It remains only to implement the resolve method, which will determine what data is missing. This is perhaps the most interesting and important part.
For simplicity, we will assume that our data is stored in store.entities. It is possible and to take it out as a separate config item, and attach a reducer there, but at this stage it does not matter.
We need to return abcentEntities - a dictionary of this type:
const absentEntities = {users: [1, 2, 3], posts: [107, 6, 54]};
Where lists are stored missing data id. To determine what data is missing, we will need our schema and lifetime from the config.
In general, the foreign key can have a list of id, and not one id - nobody canceled many-to-many and one-to-many relations. We will have to take this into account by checking the data type of the foreign key, and, if anything, go for everything from the list.
const resolve = (type, state, receivedEntities) => { let absentEntities = {}; for (let key in schema[type]) { // foreign key const keyType = schema[typeName][key]; // foreign key absentEntities[keyType] = []; // for (let id in receivedEntities[type]) { // // let keyIdList = receivedEntities[type][id][key]; if (!(keyIdList instanceof Array)) { keyIdList = [keyIdList]; } for (let keyId of keyIdList) { // , id store const present = state.entities.hasOwnProperty(keyType) && state.entities[keyType].hasOwnProperty(keyId); // , receivedEntities const received = receivedEntities.hasOwnProperty(keyType) && receivedEntities[keyType].hasOwnProperty(keyId); // , ? const relevant = present && !!lifetime ? state.timestamp[keyType][keyId] + lifetime[keyType] > Date.now() : true; // action, store , absent if (!(received || (present && relevant))) { absentEntities[keyType].push(keyId); } } } };
That's all - a bit of puzzling logic and consideration of all cases, and our function is ready. When initializing, you need to remember to transfer the schema and the lifetime from the config to it.
In general, everything is already working, if we make the following assumptions:
All these points (especially the first!) Must be carefully worked out, and we will do this in the third part. But this is not so interesting, because almost all the code that fulfills our goal has already been written, so for those who want to just see and test themselves, I quote the links:
Source: https://habr.com/ru/post/330634/
All Articles