The Redux documentation (https://redux.js.org/usage/structuring-reducers/normalizing-state-shape) recommends to avoid nested data and to normalize and flatten it instead. But I wonder if it really is a good idea to flatten the state in the case of nested one-to-many relationships.
Let's say I want to model a collection of books, where each books has a certain number of pages, and each page has a certain number of sentences. Every sentence is in only one page and every page is in only one book. From what I understand, the Redux docs suggest the following normalized flat structure. Note that pages and sentences need ids that are globally unique.
state = {
books: {
allIds: ["book1", "book2"],
byId: {
"book1": {
/*name: "First book",*/
pages: ["page1", "page2"],
},
"book2": {/*...*/},
},
},
pages: {
byId: {
"page1": {
/*pageNumber: "1",*/
sentences: ["sentence1", "sentence2"]
},
"page2": {/*...*/},
}
},
sentences: {
byId: {
"sentence1": {
/*contents: "First sentence"*/
},
"sentence2": {/*...*/},
}
},
};
But in practice I found that this representation makes it very difficult to do certain operations. For instance (deep) cloning a book requires coming up with a whole lot of new unique ids (for the cloned book, all the cloned pages, and all the cloned sentences), but I have no idea where I can generate them: the reducer is not allowed to generate random ids, and the action creator only has access to the book id so it doesn’t know how many random ids need to be generated.
On the other hand, in the representation below where we nest the {allIds, byId}
objects, cloning books is very easy: you just need to come up with one new id for the new book and then do a regular deep clone. Note that the ids are now only required to be locally unique.
state.books = {
allIds: ["book1", "book2"],
byId: {
"book1": {
/*name: "First book",*/
pages: {
allIds: ["page1", "page2"],
byId: {
"page1": {
/*pageNumber: "1",*/
sentences: {
allIds: ["sentence1", "sentence2"],
byId: {
"sentence1": {
/*contents: "First sentence"*/
},
"sentence2": {/*...*/},
}
}
},
"page2": {/*...*/},
}
}
},
"book2": {/*...*/}
}
};
This nested representation also seems to avoid every single pitfall mentioned in the Redux docs:
When a piece of data is duplicated in several places, it becomes harder to make sure that it is updated appropriately.
- Not relevant for one-to-many relationships as data is not duplicated to begin with.
Nested data means that the corresponding reducer logic has to be more nested and therefore more complex. In particular, trying to update a deeply nested field can become very ugly very fast.
- Well, no, using Immer (included by default in Redux Toolkit) updating a deeply nested field is super easy. Even without Immer, it’s not that complicated. On the other hand, the flat representation makes a lot of other things way more complex (in particular cloning a book which seems incredibly complicated to me, but also adding or removing pages). Yes, in order to access a sentence the sentenceId is not enough, you now also need to give the pageId and the bookId, but that’s just two more arguments to a bunch of function calls.
Since immutable data updates require all ancestors in the state tree to be copied and updated as well, and new object references will cause connected UI components to re-render, an update to a deeply nested data object could force totally unrelated UI components to re-render even if the data they're displaying hasn't actually changed.
- Doesn’t seem to be an issue either, as long as you don’t select the whole
state.books.byId[bookId]
object. If you only selectstate.books.byId[bookId].name
andstate.books.byId[bookId].pages.allIds
, then you have all the information you need, and changing a page or a sentence won’t make the book component rerender. It seems just as efficient as the flat version. I guess there is slightly more boilerplate, especially if you need to select many other fields, but it shouldn’t be too hard to manage.
In summary, I think that turning an array of books/pages/sentences into an {allIds, byId}
object and having every book/page/sentence component select its own data is definitely a good idea, but I really don’t see the point of going one step further and flattening out everything. Keeping the data nested seems significantly easier to work with and doesn’t seem to have any real drawbacks.
So I guess my question is:
Am I missing something? Is there something else I would gain by flattening my state, which would make it worth figuring out how to clone a book?