In the chapter on Designing the State Shape, the docs suggest to keep your state in an object keyed by ID:
Keep every entity in an object stored with an ID as a key, and use IDs to reference it from other entities, or lists.
They go on to state
Think of the app’s state as a database.
I'm working on the state shape for a list of filters, some of which will be open (they're displayed in a popup), or have selected options. When I read "Think of the app’s state as a database," I thought about thinking of them as a JSON response as it would be returned from an API (itself backed by a database).
So I was thinking of it as
[{
id: '1',
name: 'View',
open: false,
options: ['10', '11', '12', '13'],
selectedOption: ['10'],
parent: null,
},
{
id: '10',
name: 'Time & Fees',
open: false,
options: ['20', '21', '22', '23', '24'],
selectedOption: null,
parent: '1',
}]
However, the docs suggest a format more like
{
1: {
name: 'View',
open: false,
options: ['10', '11', '12', '13'],
selectedOption: ['10'],
parent: null,
},
10: {
name: 'Time & Fees',
open: false,
options: ['20', '21', '22', '23', '24'],
selectedOption: null,
parent: '1',
}
}
In theory, it shouldn't matter as long as the data is serializable (under the heading "State").
So I went with the array-of-objects approach happily, until I was writing my reducer.
With the object-keyed-by-id approach (and liberal use of the spread syntax), the OPEN_FILTER
part of the reducer becomes
switch (action.type) {
case OPEN_FILTER: {
return { ...state, { ...state[action.id], open: true } }
}
Whereas with the array-of-objects approach, it's the more verbose (and helper function reliant)
switch (action.type) {
case OPEN_FILTER: {
// relies on getFilterById helper function
const filter = getFilterById(state, action.id);
const index = state.indexOf(filter);
return state
.slice(0, index)
.concat([{ ...filter, open: true }])
.concat(state.slice(index + 1));
}
...
So my questions are threefold:
1) Is the simplicity of the reducer the motivation for going with the object-keyed-by-id approach? Are there other advantages to that state shape?
and
2) It seems like the object-keyed-by-id approach makes it harder to deal with standard JSON in/out for an API. (That's why I went with the array of objects in the first place.) So if you go with that approach, do you just use a function to transform it back and forth between JSON format and state shape format? That seems clunky. (Though if you advocate that approach, is part of your reasoning that that's less clunky than the array-of-objects reducer above?)
and
3) I know Dan Abramov designed redux to theoretically be state-data-structure agnostic (as suggested by "By convention, the top-level state is an object or some other key-value collection like a Map, but technically it can be any type," emphasis mine). But given the above, is it just "recommended" to keep it an object keyed by ID, or are there other unforeseen pain points I'm going to run into by using an array of objects that make it such that I should just abort that plan and try to stick with an object keyed by ID?
Q1: The simplicity of the reducer is a result of not having to search through the array to find the right entry. Not having to search through the array is the advantage. Selectors and other data accessors may and often do access these items by id
. Having to search through the array for each access becomes a performance issue. When your arrays get larger, the performance issue worsens steeply. Also, as your app becomes more complex, showing and filtering data in more places, the issue worsens as well. The combination can be detrimental. By accessing the items by id
, the access time changes from O(n)
to O(1)
, which for large n
(here array items) makes a huge difference.
Q2: You can use normalizr
to help you with the conversion from API to store. As of normalizr V3.1.0 you can use denormalize to go the other way. That said, Apps are often more consumers than producers of data and as such the conversion to store is usually done more frequently.
Q3: The issues you'll run into by using an array are not so much issues with the storage convention and/or incompatibilities, but more performance issues.
Think of the app’s state as a database.
That's the key idea.
1) Having objects with unique IDs allows you to always use that id when referencing the object, so you have to pass the minimum ammount of data between actions and reducers. It is more efficient than using array.find(...). If you use the array approach you have to pass the entire object and that can get messy very soon, you might end up recreating the object on different reducers, actions, or even in the container (you dont want that). Views will always be able to get the full object even if their associated reducer only contains the ID, because when mapping the state you'll get the collection somewhere (the view gets the whole state to map it to the properties). Because of all of what i've said, actions end up having the minimal ammount of parameters, and reducers the minimal ammount of information, give it a try, try both methods and you'll see the architecture ends up more scalable and clean using IDs if collections do have ID.
2) The connection to the API should not affect the architecture of your storage and reducers, that's why you have actions, to keep the separation of concerns. Just put your conversion logic in and out of the API in a reusable module, import that module in the actions that use the API, and that should be it.
3) I used arrays for structures with IDs, and this are the unforseen consequences i've suffered:
- Recreating objects constantly toughout the code
- Passing innecesary information to reducers and actions
- As consquence of that, bad, not clean and not scalable code.
I ended up changing my data structure and rewriting a lot of code. You have been warned, please don't get yourself in trouble.
Also:
4) Most collections with IDs are meant to use the ID as a reference to the whole object, you should take advantage of that. The API calls will get the ID and then the rest of the parameters, so will your actions and reducers.
1) Is the simplicity of the reducer the motivation for going with the object-keyed-by-id approach? Are there other advantages to that state shape?
The main reason you want to keep keep entities in objects stored with IDs as keys (also called normalized), is that it's really cumbersome to work with deeply nested objects (which is what you typically get from REST APIs in a more complex app) — both for your components and your reducers.
It's a bit hard to illustrate the benefits of a normalized state with your current example (as you don't have a deeply nested structure). But let's say that the options (in your example) also had a title, and were created by users in your system. That would make the response look something like this instead:
[{
id: 1,
name: 'View',
open: false,
options: [
{
id: 10,
title: 'Option 10',
created_by: {
id: 1,
username: 'thierry'
}
},
{
id: 11,
title: 'Option 11',
created_by: {
id: 2,
username: 'dennis'
}
},
...
],
selectedOption: ['10'],
parent: null,
},
...
]
Now let's say you wanted to create a component that shows a list of all users that have created options. To do that, you'd first have to request all the items, then iterate over each of their options, and lastly get the created_by.username.
A better solution would be to normalize the response into:
results: [1],
entities: {
filterItems: {
1: {
id: 1,
name: 'View',
open: false,
options: [10, 11],
selectedOption: [10],
parent: null
}
},
options: {
10: {
id: 10,
title: 'Option 10',
created_by: 1
},
11: {
id: 11,
title: 'Option 11',
created_by: 2
}
},
optionCreators: {
1: {
id: 1,
username: 'thierry',
},
2: {
id: 2,
username: 'dennis'
}
}
}
With this structure, it's much easier, and more efficient, to list all users that have created options (we have them isolated in entities.optionCreators, so we just have to loop through that list).
It's also quite simple to show e.g. the usernames of those that have created options for the filter item with ID 1:
entities
.filterItems[1].options
.map(id => entities.options[id])
.map(option => entities.optionCreators[option.created_by].username)
2) It seems like the object-keyed-by-id approach makes it harder to
deal with standard JSON in/out for an API. (That's why I went with the
array of objects in the first place.) So if you go with that approach,
do you just use a function to transform it back and forth between JSON
format and state shape format? That seems clunky. (Though if you
advocate that approach, is part of your reasoning that that's less
clunky than the array-of-objects reducer above?)
A JSON-response can be normalized using e.g. normalizr.
3) I know Dan Abramov designed redux to theoretically be
state-data-structure agnostic (as suggested by "By convention, the
top-level state is an object or some other key-value collection like a
Map, but technically it can be any type," emphasis mine). But given
the above, is it just "recommended" to keep it an object keyed by ID,
or are there other unforeseen pain points I'm going to run into by
using an array of objects that make it such that I should just abort
that plan and try to stick with an object keyed by ID?
It's probably a recommendation for more complex apps with lots of deeply nested API responses. In your particular example though, it doesn't really matter that much.