We will learn how to use normalizr to convert all API responses to a normalized format so that we can simplify the reducers.
[00:00] In the by ID reducer, I currently have to handle different server actions in a different way, because they have different response shape. For example, the fetch to-do success action has a response, which is an array of to-dos.
[00:14] I have to iterate over them and merge them one by one into the next state. Add to-do success is different. The response for adding a to-do is the to-do itself. I have to merge a single to-do in a different way.
[00:28] Instead of adding new cases for every new API call, I want to normalize the responses so the response shape is always the same.
[00:37] I'm running NPM install, save Normalizer, which is a utility library that helps me normalize API responses to have the same shape. I'm creating a new file in the actions directory called "schema.js." I'm importing a schema constructor, and a function called array of from Normalizer.
[00:58] I will export two schemas from this file. I create a schema for the to-do objects, and specify to-dos as the name of the dictionary in the normalized response. I also create another schema called array of to-dos that corresponds to the responses that contain arrays of to-do objects.
[01:22] Next, I am opening the file where I define action creators, and I am adding a named import for a function called "normalize" that I import from Normalizer. I also add a namespace import for all the schemas I defined in the schema file.
[01:38] I'm scrolling down to the fetch to-do success callback, and adding a normalized response log so that I can see what the normalized response looks like. I'm calling the normalize function with the original response as the first argument, and the corresponding schema -- in this case, array of to-dos -- as the second argument.
[02:02] Next, I'm scrolling down to the add to-do success handler. When the response comes back, I want to log the normalized response by calling the normalize function with the original response as the first argument, and the corresponding schema -- in this case, a schema for a single to-do -- as the second argument.
[02:24] If I run the app now and look at the response in the action, I will see an array of to-do objects, however, a normalized response for fetch to-do success action looks differently. It contains two fields called entities and result.
[02:43] Entities contains a normalized dictionary called "to-dos" that contains every to-do in the response by its ID. Normalizer found these to-do objects in the response by following the array of to-dos schema. Conveniently, they are indexed by IDs, so they will be easy to merge into the lookup table.
[03:07] The second field is the result. It's an array of to-do IDs. They are in the same order as the to-dos in the original response array. However, Normalizer replaced each to-do with its ID, and moved every to-do into the to-dos dictionary.
[03:24] Normalizer can do this for any API response shape. For example, let's add a to-do. The original action response object will be the to-do itself, as returned by the server. The normalized response will contain two fields, just like before, entities and result.
[03:49] Like before, the entities object will contain the to-dos dictionary, this time with a single item. In the result field, we will see just the ID of the to-do, because the original response is just the single to-do, and Normalizer replaced it with its ID in the result field.
[04:10] I will now change the action creator so that they pass the normalized response in the response field, instead of the original response.
[04:19] As a reminder, we have to pass the schema as the second argument, and its schema to-do for the single to-do, and its schema array of to-dos for the array of to-do objects in the response.
[04:33] Now, I can open the by ID reducer, and I can delete these special cases, because the response shape is going to be very similar. Rather than switch by action type, I will check if the action has a response object on it.
[04:49] I will return a new version of the lookup table that contains all existing entries, as well as any entries inside entities to-dos in the normalized response. For other actions, I will return the lookup table as it is.
[05:05] Now, I need to switch to the IDs reducer to amend it to understand the new action response shape. For fetched to-dos, it used to be an array of to-dos. For add to-do, it used to be the to-do itself.
[05:20] Now, the action response has a result filled, which is already an array of IDs, in case of fetch this success, and our single ID of the fetched to-do in case of add to-do success.
[05:33] I can run the app now, and inspect the action response. I can see that, for fetched to-do success, the response contains the entities which contains the to-dos by their IDs, and the result is an array of IDs in the same order as they were in the original response.
[05:51] I can also add a to-do, and the action response will also contain the entities and the result, where the entities contains the to-dos by their IDs, in this case, a single to-do. The result is the ID of the added to-do.
[06:08] Let's recap how to work with normalized responses. Fetch to-do success original response contained an array of to-dos. Normalizer replaces them with an array of their IDs in the result field. The add to-do success original response was a single to-do, so action response result becomes its ID.
[06:30] Inside the by ID reducer, I removed all the special cases for different action types. I just check if the action contains a normalized response. The entities field will contain different dictionaries. In this example, I only have a single to-dos dictionary, which corresponds to the objects with a to-do schema.
[06:54] I use the object spread operator to merge the old lookup table and the newly-fetched to-dos. The name of the dictionary inside entities corresponds to the string argument that I passed to the schema constructor when I created the to-do schema.
[07:12] I have two kinds of API responses in my app, a single to-do, and an array of to-dos. I use the array all function from Normalizer to create a corresponding array schema.
[07:25] Finally, in the action creators, I call the normalize function to get the normalized response from the original response, and the schema that I know corresponds to this API endpoint.
[07:38] I know that the original response from fetch to-dos is an array of to-do objects. I pass the array of to-dos schema. The add to-do response shape is a single to-do item. I pass the schema for a single to-do to the normalize function that I import from Normalizer.
Especially when dealing with an API and success and failure and stuff like that.
For this kind of trivial "apps" is not recommended (in my opinion) to use normalizr because while you simplify the reducers, you'll end up "complexify" the action creators with schemas that you'll need to create.
The byId Reducer goes from
const byId = (state = {}, action) => {
switch (action.type) {
case 'FETCH_TODOS_SUCCESS': // eslint-disable-line no-case-declarations
const nextState = { ...state };
action.response.forEach(todo => {
nextState[todo.id] = todo;
});
return nextState;
case 'ADD_TODO_SUCCESS':
return {
...state,
[action.response.id]: action.response,
};
default:
return state;
}
};
to
const byId = (state = {}, action) => {
if (action.response) {
return {
...state,
...action.response.entities.todos,
};
}
return state;
};
However my understanding is that ALL the actions are taken thru ALL the reducers.
So by removing the check for Action Type of 'FETCH_TODOS_SUCCESS' and 'ADD_TODO_SUCCESS' and simply checking if the action contains a response field, then I'm thinking, that what if our system has some other unrelated action that also returns a promise with a response field, and so in that scenario, our byId Reducer would also attempt to handle this unrelated action, which would be a bug.
Am I correct here, or have I missed something?
For the modern version of normalizr (3.4.0 atm):
import { schema } from 'normalizr';
export const todo = new schema.Entity('todos');
export const arrayOfTodos = new schema.Array(todo);
[...] what if our system has some other unrelated action that also returns a promise with a response field, and so in that scenario, our byId Reducer would also attempt to handle this unrelated action, which would be a bug.
@Mickey, you're right. Another action could easily have a response
property by coincidence or oversight and thus get processed by byId
unintentionally. This implementation is relying on us to remember that any property by that name will be picked up by that reducer. It might make more sense if the property name were specific to "todos," but instead it's the generic response
. It's easy to imagine us expanding this app to fetch additional types of data and dispatching non-todo actions that also have a response
property.
The result would most likely be a bug, as you said. The current code spreads action.response.entities.todos
into state
for todos
, so it's expecting any response
property to be an object with an entities
property, which itself has a todos
property. If we were using normalizr
for this hypothetical other fetched data, it would probably include that same entities
property, but I assume it would have some other dictionary name inside instead of todos
. In that case, the value being spread into state
would be undefined
, which when spread has no effect, so that would probably be OK, albeit by luck. If we weren't using normalizr
for that other fetch though, then most likely entities
would be undefined
, and then when the code tried to read todos
off it, the app would throw an error.
I understand the desire to simplify the reducer code, but this seems like going too far, in a way that is antithetical to Redux itself. We're relying on normalizr
for consistency, not Redux, and while we are still using actions, we're ignoring their types, which I thought was key to the tool's power. We're giving up precision control in favor of trusting that a certain arbitrary structure always means the same thing. I don't buy it.
On the whole, I find this particular lesson to be a bit strange. I'm not sure why it's here, in this otherwise-excellent course. I'm sure normalizr
is a good tool, and I can imagine how it could be incredibly powerful in a very controlled environment. Maybe this lesson just isn't substantial enough to do that justice. Certainly the functionality of normalizr
itself still seems a bit like magic to me; this is no deep dive.
I agree with @Enoh's point that the complexity normalizr
introduces is overkill for this app. At the same time, I understand that this course is walking a fine line, trying to demonstrate sophisticated practices and concepts without taking the substantial additional time needed to build an app that actually needs such sophistication. Most of the time it doesn't bother me, other than occasionally confusing me, but I think we could have skipped this particular topic.
Would be very cool if you could show how to manage relational data with normalisr and redux! :-)