Deeply Nested Objects and Redux
Aug 30, 2019 • 13 Minute Read
Introduction
Managing the application state is one of the most discussed and debated topics in React. First, came the component state and soon it was not enough to keep up with the increasing complexities of SPA (Single Page Applications). Then, libraries such as React, Mobx, and Unstated emerged to simplify the process. While these libraries essentially ease the process of keeping the application state, they still required the developer to make sure that the changes to the state are done in a manner that respects the rule of immutable state.
In very simple terms, the React rendering process is sensitive only to the immutable updates of the state. This means that any change you make to object references is not propagated unless you explicitly create a new reference to the object, i.e. create a new object. With deeply nested objects, the workload (of both human and machine) significantly increases. This puts additional overhead for the developer to be cautious in the handling of state changes. But with a good initial state architecture, most of these problems can be avoided. We’ll explore most unavoidable circumstances and how to tackle them efficiently, in this guide.
Note: Although the guide topic is specific to the Redux state, this applies in general to React’s immutable state and most of the libraries helping to manage the application state.
Redux State Best Practices
Why not avoid a problem, if we can see it coming miles away? That's what the best practices are about. Redux documentation on Normalization State Shape gives a wide perspective on the matter. It suggests that we treat the application state as a relational database. Thus, we should apply the practices we apply in designing scalable database architecture. Let's look at the following scenario to get a better understanding of these best practices..
Scenario: We have to build the front-end for a blogging platform. It will have authors, who need to create posts. Each post will have comments. Each author will also have followers who are users of the platform. So, essentially, authors are users as well but with a flag to indicate that they are authors.
First, we'll try to store this data in the Unnormalized Form. One possible design would be as follows:
{
[
id: 1,
name: "author 01",
posts: [
{
title: "Bad Redux State",
body: ".....",
comments: [
{
userId: 10,
comment: "...."
}
],
tags: ["react", "redux"]
createdDate: ...,
}
],
followers: [
]
createdDate: ...
],
[
id: 2,
name: "author 02",
posts: [
{
title: "Post",
body: ".....",
comments: [
...
],
tags: ["react", "redux"]
createdDate: ...,
}
],
createdDate: ...
],
}
While this might seem like a good architecture when seen in the object form, let's convert it to a table and see if it still makes sense.
+----+------+-------------+---------------+--------------+--------------+-------------------------+-------------------------+------+
| id | name | createdDate | post_01_title | post_01_body | post_01_tags | post_01_comment_01_body | post_01_comment_01_user | .... |
+----+------+-------------+---------------+--------------+--------------+-------------------------+-------------------------+------+
| | | | | | | | | |
+----+------+-------------+---------------+--------------+--------------+-------------------------+-------------------------+------+
Now it looks absurd! It will look like this unless we normalize the table and apply the same to the application state. If you are familiar with the Database Normalization rules (1NF, 2NF, etc.) you can apply that knowledge. If not, we can use some basic rules to get the job done. Below is the set of guidelines from Redux's tutorial on this.
- Each type of data gets its own "table" in the state.
The first thing you need to do is to create a separate table for each entity type in the scenario. This means that we store users, posts, and comments in separate tables. In Redux, this would mean using separate reducers (or different sections on the same reducer). Note that authors and users are in fact in the same table. Also, tags are not separated, which is a design decision I chose. But it could be different according to the needs of the application.
users : [...]
comments: [...]
posts: [...]
- Each "data table" should store the individual items in an object, with the IDs of the items as keys and the items themselves as the values.
users: [
{
"user01": {
id: "user01",
name: "John Smith",
...
},
...
}
]
posts: [
{
"post01": {
id: "post01",
author: "user01",
title: "Good Redux State",
body: "...."
},
...
}
]
-
Any references to individual items should be done by storing the item's ID.
-
Arrays of IDs should be used to indicate ordering.
The last guideline applies when you need a specific ordering to the set of objects. For example, comments should appear in an order that makes sense in the thread.
comments: {
byIds: {
{
"comment01": {
id: "comment01",
comment: "...
},
"comment03": { ... },
"comment02": { ... },
}
},
allIds: ["comment01", "comment02", "comment03", ...]
}
In the above example, allIds essentially gives us the list of keys in order so that we know in which order they should be displayed. Note that we don't (and can't in objects) need to maintain order in the actual objects.
By following the above guidelines, you essentially get a normalized application state which will:
- Simplify and flatten the state (less chance of nested state).
- Ease the access to different object properties.
- Increase UI performance because individual object properties can be updated without having to update intervening objects.
But in a real-world scenario, there will still be occurrences where the state contains nested objects and normalizing further doesn't make sense. In the next few sections, we'll explore how these instances can be tackled.
Using Deep Copy
For this guide, let's use the following hypothetical data structure that is supposed to be stored in the application state.
const initState = {
firstLevel: {
secondLevel: {
thirdLevel: {
property1: ...,
property2: [...]
},
property3: ...
},
property4: ...
}
}
Also assume that this is already in the most normalized form. Now let's say we now need to update the value of property1 through an API call. Naturally, we would be inclined to directly update the states, as follows:
// reducer.js
const initState = {
...
}
export function rootLevelReducer(state, action){
const nestedState = state.firstLevel.secondLevel.thirdLevel;
nestedState.property1 = action.data;
// this is similar to
// state.firstLevel.secondLevel.thirdLevel.property1 = action.data
return state;
}
As I have commented, this change is not directly reflected in the state. Thus, Redux does not consider a rerender for the change. Note that an update to property4 will be rendered since it directly makes changes to the state object. For a property update of a deeply nested object to be rendered, the high-level reference needs to be changed. That is, a deep copy of the state object needs to be created with the desired changes to the nested properties done.
Although Object.assign() has been used for the purpose, it made the code largely unreadable. Thus came the Spread operator.
Note: It is important to know the benefits and pitfalls of using the spread operator. I suggest having a better understanding of the operator before overloading your application with it.
With the spread operator, we can simply use the following notation to correctly run the property update.
const initState = {
...
}
export function rootLevelReducer(state, action){
return {
...state,
firstLevel: {
...state.firstLevel,
secondLevel: {
...state.firstLevel.secondLevel,
thirdLevel: {
...state.firstLevel.secondLevel.thirdLevel,
property1: action.data
}
}
}
}
}
Although this solves our problem of updating deeply nested objects, it's apparent that the readability of the code falls significantly. So, we’ll try to improve readability by using nested reducers.
Using Nested Reducers
As the topic itself suggests, a nesting reducer calls can help with simplifying the above process. In the following example, we have created another reducer at the secondLevel to delegate the state changes of secondLevel and thirdLevel. And it decreases the overall complexity of using multiple levels of spread operators and increases readability.
const initState = {
...
}
export secondLevelReducer(state, action){
return {
...state,
thirdLevel: {
...state.thirdLevel,
property1: action.data
}
}
}
export function rootLevelReducer(state, action){
return {
...state,
firstLevel: {
...state.firstLevel,
secondLevel: secondLevelReducer(state.firstLevel.secondLevel, action)
}
}
}
We can, of course, split this into three different reducer functions. It is a design decision that should be taken according to the situation. In this scenario, it felt like an overhead to create three separate reducer functions.
While these seem to make our lives easier, keeping track of the state structure becomes challenging in a sufficiently large codebase. So often we use the power of third-party libraries to further simplify these processes.
Using Immer
Immer.js is a "helper" library that simply creates a new state object by mutating the current "immutable state". Essentially, this abstracts the above deep copy operations (possibly with better optimizations) and lets us mutate the state without hassle. The theory behind the inner working of immer is beyond the scope of this guide. So, we will focus on its usage. First, we add immer to our project with:
$ npm install --save immer
We simply use the produce method which provides us with a concept called a 'draft state'. In comparison to the redux state, the draft state is mutable. Once the mutations are done, immer compares the draft state to the original state and makes the changes accordingly. Let's fix immer into our deeply nested state.
import produce from 'immer';
const initState = {
...
}
export function rootLevelReducer(state, action){
return produce(state, draft => {
draft.firstLevel.secondLevel.thirdLevel.property1 = action.data;
// bonus, you can do array updated as well!
// draft.firstLevel.secondLevel.thirdLevel.property2[index] = someData;
});
}
Success! The state updates are now as easy as a simple assignment. Using immer, you can simply assign the new value by directly accessing the required nested property. Moreover, you can change the value of individual array elements without having to copy all other array elements. I strongly suggest using immer (or any other library that provides similar functionality) at the very beginning of a project, if it has the potential to grow into a large codebase. The Redux Starter Kit published by the Redux team itself utilizes immer to provide easier inbuilt state mutation.
Conclusion
In today's guide, we explored the idea of structuring the application state of a React SPA. First, we briefly looked over the best practices in designing the state. While Redux suggests flattening the state as much as possible, we observed that there are unavoidable circumstances. For tackling these instances, this guide presented three different approaches.