Managing the state with Redux
As explained in the previous part, we chose to keep track of application state using Flux, and especially one of its most common implementations, Redux.
This article describes design patterns we found useful while working with Redux. Note that we also discuss about Redux patterns in the part dedicated to Flow patterns, because we tried to statically type our actions and our interactions with the back-end as often as possible.
Designing reducers
This part is deeply linked with what was discussed earlier about application structure, because we tended to
choose the same kind of structure for our state store and for our folders. For example, we tried to put the reducer
related to a root directory of src/pages
at the root of our state store, while sometimes using
combineReducers
to nest several reducers under a single store key when needed. However, we tried to
limit this to only one level of depth, because understanding a complex state store tree structure is
quite difficult.
Preserving state immutability
As a lot of teams, we did not made the things more complex than they need to be (and reduced runtime overhead), and simply used the ES7 object spread operator to recreate the state without mutating it. To read more about why we made this choice, and get an insight on other alternatives, read our article on immutability.
Switches or maps ?
Another tradeoff came before us : write reducers as a big function using switch
, or write them as
maps whose keys are the types of actions that may be dispatched. We know both approaches have their upsides
and their downsides, but we made a definite choice for a simple reason : Flow does not looks on the inside of maps,
except if you force it to do so by creating an object type with the right keys. We did not want to have to
rewrite a list of all actions handled by our reducer outside of it, so we stuck with the switch
option.
Allowing ourselves to change our minds
To ease refactoring, we define a getLocalState
method inside of each reducer's file. To this method,
we can pass the global application state as a parameter, and it gives us back the part of state that
is managed by this specific reducer. This allows us to keep the information about where this reducer is
mounted in the state tree in a single place, so we are safer when moving it and refactoring the app.
// fooReducer.js
export const getLocalState = (globalState: Object): State => {
return globalState.someSpecificPart.foo;
}
Side note : as you may have noticed, this code snippet has Flow type annotations. This is the second benefit of this pattern. Read more about this in the article about Flow.
I don't want to store carrots in my socks drawer
As you may have already noticed if you have worked with Redux, when having quite a big number of reducers to work with, you often find yourself wondering how you went up to having so much data, often of different kind, sometimes duplicated, stored in your store. This sometimes involves information about the user interface mixed with pure business data, or business data that's needed in two pages of your app, hence duplicated in two places of your state store.
Several options exist to avoid this. As we said earlier, we wish we had those reflexions earlier in the development of our app, because it made reasoning about our store quite hard.
Splitting pure data and interface state
This involves dedicating a part of your state store to pure data, and another part to interface state. Here's an example store structure :
{
data: {
products: {
fruits: {
oranges: [ /* ... */ ],
apples: [ /* ... */ ],
},
vegetables: {
potatoes: [ /* ... */ ],
carrots: [ /* ... */ ],
},
},
},
interface: {
menu: { collapsed: true }},
activeFilters: {
searchBar: 'appl',
},
}
}
Upside : this approach is very close to the way a local database would work. This enables to understand easily the underlying data model, and to decouple actions and reducers from the internals of your presentational components.
Downside : You need to interact with many actions and many parts of your state store when you need to dispatch changes. It might be harder to track down what happens in your reducers, because some actions may tend to have strange side effects when you want them to interact with a remote part of the state store.
- Splitting your state store by business concerns :
{ category: { name: 'fruits', description: 'Yummy fruits !' products: { oranges: [ /* ... */ ], apples: [ /* ... */ ], } activeFilters: { searchBar: 'appl', ... }, ... }, menu: { collapsed: true }}, }
Upsides :
- easier to reason about when the split between pages is clear in your app (say we have a "category" page which displays the list of products in the given category)
- nesting reducers allows flushing a whole part of the state (when we change category, we discard this whole store key and don't keep any unwanted data)
Downside : when a piece of data is transversal (such as the menu here), you have to create a smaller top-level store part just to keep track of it. This can lead to a state store growing exponentially, and being difficult to reason about.
Don't put everything in the store
One of our refactoring processes that helped us have a clearer view of our state store
was to remove a lot of data from them, because we realised they didn't need to be there in
the first place. For example, we had a page that was used to edit data, with a popup displayed
when leaving it asking you to either save or discard your changes. This means the data related to the edits
that had been made needed to be available from only one component, and only existed
between its mounting and its unmounting. For this purpose,
no need to use the Redux store ! So we modified our component to make use of its internal state instead of the
global store, and saved lines of codes, dispatched less actions and made our state store easier to reason about
(no need for fooData
and fooDataBeingEdited
anymore).
Don't make your actions too smart
When dispatching actions, most of the time, you'll want them to have side effects. However, we found useful to avoid
having too much things performed in background by a single action. To serve this purpose and help make the background
computations easier to track down, we used Redux Thunk middleware. Its behaviour is quite simple :
instead of creating static actions, you write "action creator" functions. When Redux Thunk
sees that you've passed dispatch
a function, it calls it, passing it dispatch
and getState
as
arguments. One common implementation pattern we found useful, for actions
that abstract fetching data from the back-end, was the following :
export function fetchCarrotsAction() {
return async (dispatch, getState) => {
dispatch({ type: FETCH_CARROTS });
try {
const carrots = await getCarrotsService();
dispatch({
type: RECEIVE_CARROTS,
carrots
});
} catch(err) {
// handle the error
}
}
}
This pattern allows us to have actions that have side effects, but still expose a clear reporting about the actions, and keep them simple (one side-effect per dispatched action).
When should a component be connected ?
Once again, you may often have a hard time deciding whether a component should be connected to the state store, or receive its input data and event handlers from its parent. This depends on several things, but here are the upsides and downsides of each method, to help you make the trade-off :
* Connecting everything down the components tree makes refactorings easier, because you don't have to
wonder whether a sub-sub-sub-subcomponent is being given the right data. However, it may make your app
harder to reason about, and make a mess of your state store by needing a lot of keys in your store, and dispatching
actions everytime something happens ;
* Connecting only top components and passing the data and handlers to theirs children in the JSX may seem easier or
cleaner at the beginning, but as your component become more complex, you'll often find yourself writing
a lot of props and handlers in your JSX, and not even needing them in the component you're passing them to,
as it's simply passing them transparently to one of its children.
We often found a sweet spot betwwen those two extreme options, which we also sometimes decided to change (read more about this above in this article). This was quite a hassle, and, on this point specifically, we advise you to weigh your choices wisely before implementing them.
What about the "container" pattern ? Some of us used it heavily (probably overusing it a bit, sometimes), some others didn't felt the need to use it. Maybe we should have explored it more in-depth. Do you have advices or ideas on this part ? Feel free to tell us in the comments section below !