Normalizing State Shape in Redux
At its core, Redux is a predictable state container for JavaScript apps. It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.
In this article, we'll explore why you should normalize your data, how to normalize your data, and how to work with normalized data in Redux.
Why Normalize Your Data?
When you're building a Redux application, you're likely to encounter situations where you need to manage complex data structures. For example, imagine you're building an e-commerce website, and you have to manage products, orders, customers, and payments. Each of these entities has a set of properties, and they're all related to each other in different ways.
If you store all of this data in a single, nested object in Redux, you'll quickly run into problems. Your code will become difficult to read and maintain, and you'll end up writing lots of boilerplate code to manipulate your data. You'll also end up with lots of duplicate data, which can lead to inconsistent state and hard-to-find bugs.
Normalizing your data solves these problems by breaking your data down into smaller, independent pieces, and storing each piece in its own object. You can then use references to relate these objects to each other, instead of embedding one object inside another.
Advantages of state normalization in Redux
There are several benefits of state normalization, including:
Improved performance: By breaking down the state tree into smaller pieces, you reduce the amount of data that needs to be processed and updated. This can result in faster rendering times and a smoother user experience.
Better organization: Normalized state allows for a more structured and organized codebase, making it easier to understand, update, and maintain.
Easier data retrieval: Normalized state allows for easy and efficient data retrieval, as there is no need to search through large, unstructured data sets to find the required data.
How to Normalize Your Data
The first step in normalizing your data is to identify the entities in your application. An entity is a discrete object that has a unique ID
and a set of properties. In our e-commerce example, we have four entities: products
, orders
, customers
, and payments
.
Once you've identified your entities, you need to define the shape of your normalized data. The shape of your data describes the relationships between your entities and the properties of each entity. You can define the shape of your data using a schema definition language like JSON Schema, or by writing a custom function that defines the shape of your data.
Here's an example of a normalized data shape for our e-commerce application:
{
"products": {
"byId": {
"1": {
"id": 1,
"name": "Product 1",
"price": 10.99,
"description": "A great product",
"category": "Electronics",
"inventory": 5
},
"2": {
"id": 2,
"name": "Product 2",
"price": 20.99,
"description": "Another great product",
"category": "Clothing",
"inventory": 10
}
},
"allIds": ["1", "2"]
},
"orders": {
"byId": {
"1": {
"id": 1,
"customerId": 1,
"productId": 1,
"quantity": 2,
"status": "Pending"
},
"2": {
"id": 2,
"customerId": 2,
"productId": 2,
"quantity": 1,
"status": "Complete"
}
},
"allIds": ["1", "2"]
},
"customers": {
"byId":
{
"1": {
"id": 1,
"name": "John Doe",
"email": "[email protected]"
},
"2": {
"id": 2,
"name": "Jane Doe",
"email": "[email protected]"
}
},
"allIds": ["1", "2"]
},
"payments": {
"byId": {
"1": {
"id": 1,
"orderId": 1,
"amount": 21.98,
"status": "Complete"
},
"2": {
"id": 2,
"orderId": 2,
"amount": 20.99,
"status": "Pending"
}
},
"allIds": ["1", "2"]
}
}
In this example, we have four entities: products
, orders
, customers
, and payments
. Each entity is represented by an object with two properties: byId
and allIds
. The byId
property is an object that maps the entity's unique IDs
to the entity's properties. The allIds
property is an array of the entity's IDs
, in the order in which they were created.
We also have relationships between our entities. Orders are related to both customers and products, and payments are related to orders. We represent these relationships using references between entities. For example, the customerId
property in the orders entity refers to the id
property in the customers
entity.
Once you've defined the shape of your normalized data, you can write reducers that handle each entity independently. Each reducer should handle CRUD operations for its entity, and update the normalized data accordingly.
Benefits of using byId
and allIds
structures
Splitting the store into byId
and allIds
structures is a key part of normalizing the state in Redux. This approach has several benefits:
Efficient Updates: By using an object instead of an array to store entities, it's easier to add, update or delete items without having to mutate the entire array. When using an array to store entities, removing an item requires finding the index of the item, which can be slow when the array is large. By using an object with unique identifiers as keys, we can easily find and update specific items without having to search through an entire array.
Simplified Access: By using an
allIds
array to store theIDs
of all entities, we can easily access a list of all entities in a consistent and predictable manner. This can simplify the code required to iterate over all entities or filter entities based on certain criteria.Reduced Duplication: By using a normalized state structure, we can avoid data duplication. When using an array of entities, it's common to store the same entity in multiple places to make it easier to access the entity from different parts of the state. This can lead to inconsistent data and bugs when the same entity is updated in one place but not in another. By using a normalized state structure, we can reference the same entity from different parts of the state without duplicating the data.
Simplified Data Retrieval: By using a normalized state structure, we can simplify data retrieval by creating selector functions that extract data from the state. Selectors are functions that take the state as input and return a subset of the state based on specific criteria. By creating selectors that extract data from the
byId
andallIds
structures, we can simplify the code required to access specific data from the state.
Overall, splitting the store into byId
and allIds
structures is a powerful technique that can simplify state management, reduce data duplication, and improve performance in Redux. By adopting this approach, we can create more efficient and maintainable code, and build applications that can scale to handle large amounts of data.
Working with Normalized Data in Redux
Working with normalized data in Redux is straightforward once you've defined the shape of your data and written reducers to handle each entity. You can use selectors to retrieve data from your normalized state, and you can use the createSelector function from the reselect library to create memoized selectors that compute derived data from your normalized state.
Here's an example of a selector that retrieves all orders for a given customer:
import { createSelector } from 'reselect';
const getCustomerId = (state, props) => props.customerId;
const getOrdersById = state => state.orders.byId;
const getOrdersAllIds = state => state.orders.allIds;
const getOrdersByCustomerId = createSelector(
[getOrdersById, getOrdersAllIds, getCustomerId],
(ordersById, ordersAllIds, customerId) => {
return ordersAllIds
.map(id => ordersById[id])
.filter(order => order.customerId === customerId);
}
);
In this example, we define a selector called getOrdersByCustomerId
that takes two selectors and a customerId
prop as arguments. The getOrdersById
selector returns the orders entity's byId
object, and the getOrdersAllIds
selector returns the orders entity's allIds
array.
We then use createSelector
to create a memoized selector that maps over the allIds
array and returns an array of orders
that match the given customerId
.
By normalizing your data and working with normalized data in Redux, you can simplify your code, avoid inconsistencies, and make your application more performant. By following the best practices outlined in this article, you can build Redux applications that are easy to maintain, easy to scale, and easy to work with.