State tree, the single source of truth
This post is part of the Scalable Frontend series, you can see the other parts here: “#1 — Architecture” and “#2 — Common Patterns”.
When dealing with user interfaces, it is mandatory to manage the state to be displayed to or changed by the user, no matter the scale of the application we happen to be working with. The source can be a list fetched from an API, input obtained from the user, data from LocalStorage, and so on. Independently of where this data comes from, we’ll have to deal with it and keep it in sync with a persistence method, be it a remote server or browser storage.
To be precise, this is what we call the local state, a specific portion of data that our app consumes and relies on. There are many reasons for why, when, and where the state is updated and consumed that it might get out of hand pretty quickly if we don’t manage it properly. Even a simple sign-up form might require juggling with a lot of state:
- Check if the fields are filled with valid data while the user interacts with it;
- Skip validation for untouched fields, until the form is submitted;
- When selecting a country from a dropdown, trigger a request to fetch the states for that country and then cache the response;
- Change the available options for the languages dropdown based on the selected country.
Whew! Sounds tricky, right?
In this post, we’ll talk about how to manage the local state in a sane way, always keeping in mind the scalability of our codebase and principles of architecture design to avoid coupling between the state layer and the other layers. The rest of our application shouldn’t know about the state layer or the library being used, if any. We just need to tell the view layer how to fetch data from the state and how to dispatch actions that will call our use cases to compose the behavior of our app.
In the past few years, a lot of libraries to manage the local state emerged in the JavaScript community, previously dominated by two-way data-binding contenders like Backbone, Ember, and Angular. Only with the arrival of Flux and React that one-way data flow became popular, and people realized MVC wouldn’t work well for frontend applications. Along with the large adoption of functional programming techniques in frontend development, we can see why libraries like Redux became so popular and influenced an entire generation of state management libraries.
There is an excellent presentation about the Flux mindset in case you want to learn more about the subject.
Nowadays, there are a couple of popular state management libraries, some of them specific to certain ecosystems like NgRx is for Angular. We’ll use Redux for familiarity’s sake, but all concepts mentioned in this post are transferrable across libraries or even to no library. With that in mind, you should use what’s best for you and your team. Don’t feel forced to use a library just because it’s the hype. If it works for you, go for it.
The citizens: actions, action creators, reducers and the store
These four are the most common types of objects we’re going to find in state management of modern frontend applications. The idea of using actions to isolate events from their implications and side effects is not new. In fact, these citizens are based on ideas from well-established approaches like event sourcing, CQRS, and the mediator design pattern.
They work together by centralizing the ways of storing and changing the state, by confining it to a single place and dispatching actions (a.k.a. events) to trigger state changes. Once the changes get applied to the state, we notify the parts interested in them, and they update themselves to reflect the new state. This is the one-way data flow cycle.
One-way data flow cycle.
Actions and action creators
Actions are usually implemented as objects with two attributes: a type
property and the data
to perform the implications of that action by the store. For example, an action to trigger the creation of a user could have the following format:
{
type: 'CREATE_USER',
userData: { name: 'Aragorn', birthday: '03/01/2931' }
}
It’s important to note that the implementation of the type
attribute varies depending on the state management library being used, but most of the time it will be a string. Also, keep in mind that the example action doesn’t create the user by itself; it is just a message that tells the store to create the user with userData
.
Action creators are functions that abstract the creation of an action object as a reusable unit.
But what if we need to trigger the same action from more than one place within our code, like a test suite or another file? How can we make it reusable and hide the action type from the unit that is dispatching it? We use action creators! Action creators are functions that abstract the creation of an action object as a reusable unit. Our previous example can be encapsulated by the following action creator:
const createUser = (userData) => ({
type: 'CREATE_USER',
userData
});
Now, whenever we need to dispatch the CREATE_USER
action, we import this function and use it to create the action object that’ll be dispatched to our store.
The store
The store is the single source of truth of our state, the only place where we store and modify it. Every time we change our state, we dispatch an action to the store describing what change we want to perform along with additional information if needed (the action type and the user data of our example, respectively.) It means we should never mutate our state in the same place we consume it, but instead leave it up to the store to update it. In most implementations of this pattern, we subscribe to be notified when changes are performed by the store so we can react to them.
The store is the single source of truth of our state.
OK, so now we know the store can be used for two main purposes: dispatch actions and trigger events to subscribers. In React applications, it’s common to use Redux to create the store and react-redux’ connect to dispatch actions (mapDispatchToProps
) and listen to changes (mapStateToProps
). But we can also have a root component that uses the Context API to store the state, where we would use Context.Consumer to both dispatch actions and listen to changes. Or we can do it in an even simpler way by lifting state up. For Vue, there is a library very similar to Redux called Vuex, where we use dispatch to trigger actions and mapState to listen to the store. Likewise, we can do the same in Angular applications with @ngrx/store.
Even with their differences, all these libraries share the same idea: a unidirectional cycle. Every time we need to update the state, we dispatch actions to the store, which executes them and notifies the listeners. Never the other way around or skipping steps.
Reducers
But how does the store update the state and handle each action type? That’s where reducers come in handy. To be honest, not always they’re called “reducers” because in Vuex, for example, they’re called “mutations”. But the central idea is the same: a function that takes the current state of the application and the action being handled, returning a brand new state or mutating the current one with setters. The store delegates updates to this function and then notifies listeners about the new state. And this closes the cycle!
Every reducer should be able to handle any action of our application.
Before finishing this part, there is a very important rule to mention: every reducer should be able to handle any action of our application. In other words, it is OK to have a single action that is handled by more than one reducer at the same time. This rule, therefore, allows a single action to trigger multiple changes in different parts of the state. Here’s a good example: after an AJAX request is finished, we can update the local state with the response in reducer X, hide the spinner in reducer Y, and even show a success message in reducer Z, where each of these reducers has the single responsibility of updating a distinct portion of the state.
Designing the State
Some questions that always come to mind when we start writing our application are:
- What should my state look like?
- What should I put in it?
- What shape should it have?
I’m afraid there is no right answer to these questions. The only thing we can take for granted are a few library-specific rules which dictate how the state gets updated. In Redux, for instance, reducer functions should be pure, deterministic, and have a signature of (state, action) => state
.
That being said, there are a few practices that we can follow to get away from complexity and improve UI performance, some of them being generic and suitable to any state management technique of our choice. Others are applicable specifically for tools like Redux, which counts with helper functions with a strong functional accent for splitting up reducer logic.
Before digging into that, I recommend checking out the docs of the library you’re using to manage the state. In most cases, you will find advanced techniques and helpers that you didn’t know about, and even concepts not covered in this post that are more idiomatic to the state management approach you’re using. Otherwise, you can look into a third-party library, or build functions to achieve that yourself.
State shape
The state refers to the data we need to manage, and the shape refers to how we structure and organize that data. The shape is independent of the source of data but is totally related to how we structure reducer logic.
Usually, the shape is represented with a plain JavaScript object that forms the initial state tree, but it’s also possible to use any other value like plain numbers, arrays, or strings. The advantage of objects is that they allow organizing and dividing the state into meaningful pieces, where each key of the root object is a sub-tree representing a common domain or slice of data. In a basic blog app with articles and authors, the shape of the state could look like this:
{
articles: [
{
id: 1,
title: 'Managing all state in one reducer',
author: {
id: 1,
name: 'Iago Dahlem Lorensini',
email: 'iagodahlemlorensini@gmail.com'
},
},
{
id: 2,
title: 'Using combineReducers to manage reducer logic',
author: {
id: 2,
name: 'Talysson de Oliveira Cassiano',
email: 'talyssonoc@gmail.com'
},
},
{
id: 3,
title: 'Normalizing the state shape',
author: {
id: 1,
name: 'Iago Dahlem Lorensini',
email: 'iagodahlemlorensini@gmail.com'
},
},
],
}
Notice that articles
is a top-level key to the state, forming a sub-tree to represent a common concept of data. We also have a nested subtree inside each article to represent authors
. As a general rule, we should avoid nested data because it increases the complexity of reducers.
This page from Redux docs goes over how to structure the types of data onto your state shape based on your domain and app state. Go read it even if you are not using Redux! Data management is commonplace for any type of application, and that’s a really good article to learn how to categorize data and organize it to form your state shape.
Combine Reducers
The previous example showed only one key in our state shape, but real-world applications usually have more than one domain to represent — which means more update logic going on into one reducer function. However, that goes against one important rule: reducer functions should be small and focused (the Single Responsibility Principle), as to be easier to read, understand, and maintain.
We can achieve that in Redux with the built-in combineReducers
function. This function takes an object where each key represents a sub-tree of the state and returns one combined reducer function as the name implies. Let’s combine the reducers for authors
and articles
into a single rootReducer
:
import { combineReducers } from 'redux'
const authorsReducer = (state, action) => newState
const articlesReducer = (state, action) => newState
const rootReducer = combineReducers({
authors: authorsReducer,
articles: articlesReducer,
})
The keys passed to combineReducers
will be used to form the resulting shape of the state, whose data will be transformed by the reducer functions associated with their respective keys. So if we’d pass an authors
key and an authorsReducer
function, the shape returned by rootReducer
would be state.authors
, managed by the authorsReducer
function.
Combining reducers is also great because we can go even deeper when splitting up our reducer functions. Let’s suppose articlesReducer
needs to handle the case where articles are being fetched and keep track of errors that occur during the request. So now the articles
key in our state will no longer be an array of articles, it will be an object like this:
{
isLoading: false,
error: null,
list: [] // <- this is the array of articles itself
}
We could handle this new situation inside articlesReducer
but we’d have even more statements to deal with in a single place. Fortunately, that can be resolved by splitting up articlesReducer
into smaller pieces:
const isLoadingReducer = (state, action) => newState
const errorReducer = (state, action) => newState
const listReducer = (state, action) => newState
const articlesReducer = combineReducers({
isLoading: isLoadingReducer,
error: errorReducer,
list: listReducer,
})
Besides combinerReducers
, there are other approaches for splitting up reducer logic but we will skip them in favor of Redux docs, which does a great job of describing techniques such as high-order reducers, slicing reducers to make them reusable, and ways to reduce boilerplate code. Note that these approaches can also apply to both VueX modules (which will be mentioned again in this post) and NgRx.
Normalization
Have you noticed that in our blog example each article has an author nested into it? Well, unfortunately, that leads to duplicated data when an author is associated with more than one article, thereby making the act of updating an author a nightmare because we need to make sure duplicated authors get updated as well. And to make matters worse, performance is degraded due to unnecessary re-renders.
But there’s a solution: we can treat relational data as if it were a database by resorting to normalization. The technique consists in having one “table” for each data type or domain where we reference relational entities by their IDs. Redux recommends the following:
- Holding each entity in an object called
byId
, with entity IDs as keys and entities as values, - An array of IDs called
allIds
to denote the order of entities.
In our example, we would have something like this after normalizing our data:
{
articles: {
byId: {
'1': {
id: '1',
title: 'Managing all state in one reducer',
author: '1',
},
'2': {
id: '2',
title: 'Using combineReducers to manage reducer logic',
author: '2',
},
'3': {
id: '3',
title: 'Normalizing the state shape',
author: '1',
},
},
allIds: ['1', '2', '3'],
},
authors: {
byId: {
'1': {
id: '1',
name: 'Iago Dahlem Lorensini',
email: 'iagodahlemlorensini@gmail.com',
},
'2': {
id: '2',
name: 'Talysson de Oliveira Cassiano',
email: 'talyssonoc@gmail.com',
}
},
allIds: ['1', '2'],
},
}
This structure is much more lightweight. Since there are no more duplicated items, authors get updated in a single place and fewer UI updates get triggered as a result. Our reducer is simpler and item lookup is easy and consistent.
A common question when starting to normalize our data is:
How to shape those relational portions of data into our state?
While there is no hard rule for this, it’s common to put “tables” of domains inside a top-level object called entities. In our articles example, it would look like this:
{
currentUser: {},
entities: {
articles: {},
authors: {},
},
ui: {},
}
And what about data sent by APIs? Because that data is usually sent back in a nested format, it needs to be normalized prior to being stored in the state tree. We can use the Normalizr library to do that, which allows defining schema types and relations to return normalized data in accordance with thereof setup. Go check their docs for more details on its usage.
For people working with smaller applications or not wanting to use a library, it’s easy to implement normalization by hand with a few functions:
replaceRelationById
to replace nested objects by their IDs,extractRelation
to extract nested objects out of the main entity,byId
to group entities by ID,allIds
to collect all the IDs.
So let’s create those functions:
const replaceRelationById = (entities, relation, idKey = 'id') => entities.map(item => ({
...item,
[relation]: item[relation][idKey],
}))
const extractRelation = (entities, relation) => entities.map(entity => entity[relation])
const byId = (entities, idKey = 'id') => entities
.reduce((obj, entity) => ({
...obj,
[entity[idKey]]: entity,
}), {})
const allIds = (entities, idKey = 'id') => [...new Set(entities.map(entity => entity[idKey]))]
Pretty straightforward, right? Now we need to call those functions from within the corresponding reducer. Let’s get our first article structure as an example:
const articlesReducer = (state = initialState, action) => {
switch (action.type) {
case 'RECEIVE_DATA':
const articles = replaceRelationById(action.data, 'author')
return {
...state,
byId: byId(articles),
allIds: allIds(articles),
}
default:
return state
}
}
const authorsReducer = (state = initialState, action) => {
switch (action.type) {
case 'RECEIVE_DATA':
const authors = extractRelation(action.data, 'author')
return {
...state,
byId: byId(authors),
allIds: allIds(authors),
}
default:
return state
}
}
After the action is dispatched, we‘ll have a normalized shape to the articles table with only IDs and no nested data, and the authors
table will also be normalized without any duplication.
Common Patterns
In the previous post, we discussed patterns pertaining to the domain, application, infrastructure, and input layers. Now let’s go over patterns for keeping the state layer sane and easy to reason about. Some of them are not meant to be used all the time but only in specific situations.
Selectors
Sometimes we’ll need more than just plucking data off the state: we might need to compute derived state with filtering or grouping so that our views get re-rendered if the derived data changes. For example, if we’re filtering a TODO list by completed items, we won’t need to re-render the view if an incomplete item gets updated, right? Also, computing data directly on consumer side makes it coupled to the shape of the data, in a way that if we need to restructure the state, we’ll also need to update the code of the consumer as a side-effect. This is exactly the kind of problem we can avoid with selectors.
Selectors are functions which, as the name implies, select data that is relevant to a particular context. They receive a portion of the whole state as a parameter and compute it as the consumer expects. Let’s get back to our TODO list using React + Redux as an example. What would the code look like before and after using selectors?
/* view/todo/TodoList.js */
const TodoList = ({ todos, filter }) => (
<ul>
{
todos
.filter((todo) => todo.state === filter)
.map((todo) =>
<li key={todo.id}>{ todo.text }</li>
)
}
</ul>
);
const mapStateToProps = ({ todos, filter }) => ({
todos,
filter
});
export default connect(mapStateToProps)(TodoList);
/* state/todos.js */
import * as Todo from '../domain/todo';
export const getTodosByFilter = (todos, filter) => (
// notice that we isolate the domain rule into the domain/todo entity
// so if the shape of the todo object changes it will only affect our entity file, not here :)
todos.filter((todo) => Todo.hasState(todo, filter))
);
// ---------------------------------
/* view/todo/TodoList.js */
import { getTodosByFilter } from '../../state/todos';
const TodoList = ({ todos }) => (
<ul>
{
todos
.map((todo) =>
<li key={todo.id}>{ todo.text }</li>
)
}
</ul>
);
const mapStateToProps = ({ todos, filter }) => ({
todos: getTodosByFilter(todos, filter)
});
export default connect(mapStateToProps)(TodoList);
We can see that the refactored component has no idea about what kind of TODOs are present within the collection because we extracted this logic out into a selector called getTodosByFilter
. This is pretty much what selectors are for, so consider using one when you notice your component knows too much about your state.
Consider using a selector when you notice your component knows too much about your state.
Selectors also give us the possibility of leveraging a performance improvement technique called memoization, which avoids re-rendering and re-computing data as long as the raw data remains intact. With Redux, we can implement memoized selectors using the reselect library, which you can read about in Redux’ docs.
In case you’re using Vuex, there’s already a built-in way of implementing selectors called getters. You’ll see that the mindset for “getters” is exactly the same as Redux selectors’. NgRx has a selector feature as well, and it even performs memoization for you!
If you’re wondering about where to put your selectors, keep reading and you’ll soon find out!
Ducks/Modules
Remember when we said architecture is not the same as file organization, but that it would be good that our file organization reflected our architecture? The Ducks pattern is exactly about that: it follows the definition of the Common Closure Principe (CCP), which says:
The classes in a package should be closed together against the same kinds of changes. A change that affects a package affects all the classes in that package.
— Robert Martin
A duck (or module) is a file where we gather reducer, actions, action creators, and selectors that belong to the same feature, in a way that if we need to add or change a new action, we won’t need to touch more than one file.
But wait a minute, is this pattern specific for Redux applications? Of course not! Even though the name Ducks is inspired by the word Redux, we can follow its mindset for any state management approach we want, even if not using a library.
For Redux users, there’s the documentation for the proposed ducks approach. For Vuex applications, there’s a thing called modules that’s based around the same idea but is even more “native” to Vuex since it’s part of the API’s core library. And in case you’re using Angular with NgRx, there is a proposal based on Ducks called ngrx-ducks.
But there’s a catch. The Ducks approach proposes us to keep action names at the top of the duck file, right? This might not be the best decision because it would make it difficult for reducers from other files to handle any action of our app, as they would be forced to duplicate the action name. We can circumvent this problem with a separate file for all the action names of our application, that each duck can import and use. This file would group the action names by feature with named exports for each of them. Here’s an example:
export const AUTH = {
SIGN_IN_REQUEST: 'SIGN_IN_REQUEST',
SIGN_IN_SUCCESS: 'SIGN_IN_SUCCESS',
SIGN_IN_ERROR: 'SIGN_IN_ERROR',
}
export const ARTICLE = {
LOAD_ARTICLE_REQUEST: 'LOAD_ARTICLE_REQUEST',
LOAD_ARTICLE_SUCCESS: 'LOAD_ARTICLE_SUCCESS',
LOAD_ARTICLE_ERROR: 'LOAD_ARTICLE_ERROR',
}
export const EDITOR = {
UPDATE_FIELD: 'UPDATE_FIELD',
ADD_TAG: 'ADD_TAG',
REMOVE_TAG: 'REMOVE_TAG',
RESET: 'RESET',
}
import { AUTH } from './actionTypes'
export const reducer = (state, action) => {
switch (action.type) {
// ...
case AUTH.SIGN_IN_SUCCESS: // <- same action for different reducers
return {
...state,
user: action.user,
}
// ...
}
}
import { AUTH } from './actionTypes'
export const reducer = (state, action) => {
switch (action.type) {
// ...
case AUTH.SIGN_IN_SUCCESS: // <- same action for different reducers
case AUTH.SIGN_IN_ERROR:
return {
...state,
showSpinner: false,
}
// ...
}
}
State Machines
Sometimes it may be overly complex to manage multiple booleans or multiple conditionals using the variables of our state to find out what should be rendered. A form component might have multiple possibilities to consider:
- The fields weren’t touched yet, so don’t show the validation messages;
- The fields were touched and are invalid, so show the validation messages;
- The fields weren’t touched but the submit button was clicked, so show the validation messages;
- The fields are valid and the submit button was clicked, so show a spinner and disable the fields;
- The request succeeded, so hide the spinner and show a confirmation message;
- The request failed, so hide the spinner, enable the fields, and show the error message.
Can you imagine how many booleans we’d use for that? It would probably result in something similar to:
{
(isTouched || isSubmited) && !isValid && <ErrorMessage errors={errors} />
}
{
isValid && isSubmited && !errors && <Spinner />
}
We usually end up with code like this when we try to use the data to define what should be rendered, so we add a bunch of boolean variables and try to coordinate them in a sane way — which turns out to be very hard. But what if we try to categorize all those possibilities into some explicit and well-named states? Think about it, our interface will always be in one of the following states:
- Pristine
- Valid
- Invalid
- Submitting
- Successful
Notice that from any given state, there are states we can’t transition to. For example, we can’t transition from “Invalid” to “Submitting”, but we can transition from “Invalid” to “Valid” and then to “Submitting”.
The idea of a state machine is to define a set of possible states and the transitions between them.
This kind of situation is better handled by a computer science concept known as finite state machine, or a variant thereof created specifically for this situation called statecharts. The idea of a state machine is to define a set of possible states and the transitions between them.
In our example, the state machine would look like this:
Form edition charts.
It may look complicated, but notice that having states and transitions well-defined improve the clarity of our code, making it easier to add new states in an explicit and concise way. Now our conditionals will only care about the current state, and we’ll no longer have to deal with complex boolean expressions:
{
(currentState === States.INVALID) && <ErrorMessage errors={errors} />
}
{
(currentState === States.SUBMITTING) && <Spinner />
}
OK, so how do we implement state machines in our code? The first thing to understand is that it doesn’t have to be a complex implementation. We can have a plain string with the name of the current state and update it for each action being handled by our reducers. Here’s an example:
import Auth from '../domain/auth';
import { AUTH } from './actionTypes';
const States = {
PRISTINE: 'PRISTINE',
VALID: 'VALID',
INVALID: 'INVALID',
SUBMITTING: 'SUBMITTING',
SUCCESS: 'SUCCESS'
};
const initialState = {
currentState: States.PRISTINE,
data: {}
};
export const reducer = (state = initialState, action) => {
switch(action.type) {
case AUTH.UPDATE_AUTH_FIELD:
const newData = { ...state.data, ...action.data };
return {
...state,
// ...
data: newData,
currentState: Auth.isValid(newData) ? States.VALID : States.INVALID
};
case AUTH.SUBMIT_SIGN_IN:
if(state.currentState === States.INVALID) {
return state; // makes it impossible to submit if it's invalid
}
return {
...state,
// ...
currentState: States.SUBMITTING
};
case AUTH.SIGN_IN_SUCCESS:
return {
...state,
// ...
currentState: States.SUCCESS
};
}
return state;
};
But sometimes we’ll need bigger state machines with more states and transitions, or we just want a specific tool for some other reason. For these cases, we can use something like XState. Keep in mind that state machines are agnostic to state management, so we can have them no matter if using Redux, Context API, Vuex, NgRx, or even no library!
There are a few links at the end of this post with more information about state machines and statecharts in case you want to learn more about them.
Common Pitfalls
Even when following a good architecture, there are tempting pitfalls to avoid while developing our frontend application. We say tempting because even though they look harmless, they have great potential to eventually bite our backs. Let’s talk about some don’ts regarding the state layer.
Don’t reuse the same async action for different purposes
Do you recall the first post of the series when we spoke about treating actions in the same way as controllers in backend applications, not having business rules in them and delegating work to use cases? Let’s get back to this subject, but first, let’s define what we mean when we say “actions with side-effects”.
A side-effect happens when the result of some operation affects something outside of its local environment. For our case, let’s consider a side-effect when an action does more than just changing the local state, like sending an AJAX request or persisting data to the LocalStorage. If our application uses Redux Thunk, Redux-Saga, Vuex Actions, NgRx Effects or even a special type of action that performs requests, that’s what we’re referring to.
What makes actions similar to controllers is that both of them have implications. They execute a whole use case and their side-effects, and this is the reason why we don’t reuse controllers and we shouldn’t reuse actions with side-effects as well. When we try to reuse the same action for a different purpose, we also inherit all of its side-effects, which is not desirable because it makes the code harder to reason about. Let’s make it a bit less abstract with an example.
Imagine a loadProducts
action that loads a list of products via AJAX and a view that shows a spinner while the request is pending (we’re going to use a Redux Thunk action in our example):
const loadProductsAction = () => (dispatch, _, container) => {
dispatch(showSpinner());
container.loadProducts({
onSuccess: (products) => {
dispatch(receiveProducts(products));
dispatch(hideSpinner());
},
onError: (error) => {
dispatch(loadProductsError(error));
dispatch(hideSpinner());
}
});
};
OK, but now we want to reload this list from time to time to keep it always up to date, so the first impulse would be to reuse this action, right? What if we want updates to happen in the background without showing a spinner? One might argue that it’s possible to add a withSpinner
flag for that, so let’s do it:
const loadProductsAction = ({ withSpinner }) => (dispatch, _, container) => {
if(withSpinner) {
dispatch(showSpinner());
}
container.loadProducts({
onSuccess: (products) => {
dispatch(receiveProducts(products));
if(withSpinner) {
dispatch(hideSpinner());
}
},
onError: (error) => {
dispatch(loadProductsError(error));
if(withSpinner) {
dispatch(hideSpinner());
}
}
});
};
This is already getting weird since there is some duplication to consider while using the flag, but let’s ignore that for a moment.
Now, what should we do if we want a different action to be triggered for the success case? Pass it as a parameter as well? Can you see that the more we try to make an action generic, the more complex and less focused it gets? How can we solve that and still reuse the action? The best answer is: we don’t.
Resist the urge of reusing actions with side-effects.
For cases like this, resist the urge of reusing actions with side-effects! Their complexity eventually gets unbearable, hard to understand, and hard to test. Instead, try creating two focused actions that leverage the same use case:
const loadProductsAction = () => (dispatch, _, container) => {
dispatch(showSpinner());
container.loadProducts({
onSuccess: (products) => {
dispatch(receiveProducts(products));
dispatch(hideSpinner());
},
onError: (error) => {
dispatch(loadProductsError(error));
dispatch(hideSpinner());
}
});
};
const refreshProductsAction = () => (dispatch, _, container) => {
container.loadProducts({
onSuccess: (products) => {
dispatch(refreshProducts(products));
},
onError: (error) => {
dispatch(loadProductsError(error));
}
});
};
Great! Now we can see both actions and understand exactly when each of them should be used.
Note that the same applies when an action with side-effects uses a second action that also has side-effects. We shouldn’t do it because the calling action will inherit all the side-effects from the called one.
Don’t have your views depend on the return of actions
We already know that reusing actions can make our code harder to understand. Now imagine our components relying on the return value of those actions to call side-effects. It doesn’t sound too bad, right?
But this can make our code even harder to understand. Imagine that we are debugging an action to fetch a product. After this action gets called, we realize that a list of comments is fetched for this product but we don’t know where it comes from, and we know for sure it doesn’t come from the action itself. Now it’s getting complicated, isn’t it?
// action
const loadProduct = (id) => (dispatch, _, container) => {
container.loadProduct({
onSuccess: (product) => dispatch(loadProductSuccess(product)),
onError: (error) => dispatch(loadProductError(error)),
})
}
// component
componentDidMount() {
const { productId, loadProduct, loadComments } = this.props
loadProduct(productId)
.then(() => loadComments(productId))
}
We are treating our actions as controllers, but would we chain calls to controllers in backend applications? I don’t think so.
Never depend on the return of actions to chain promises nor perform any other kind of work. If something should be done after the action call completes, the action itself should handle it.
So as a second rule, never depend on the return of actions to chain promises nor perform any other kind of work. If something should be done after the action call completes, the action itself should handle it — unless it’s a responsibility for another layer, like a redirect (this is actually a responsibility for the view layer, which we’re going to discuss in the next article of this series,) your actions should be the entry point of your app, so don’t spread redirect calls all over your components.
// action
const loadProduct = (id) => (dispatch, _, container) => {
container.loadProduct(id, {
onSuccess: (product, comments) => dispatch(loadProductSuccess(product, comments)),
onError: (error) => dispatch(loadProductError(error)),
})
}
// component
componentDidMount() {
const { productId, loadProduct } = this.props
loadProduct(productId)
}
Don’t store computed data
Sometimes we have raw data that needs to be transformed into human-readable values, like prices and dates. Let’s say we have a product model and receive something like { name: 'Product Name', price: 14.9 }
containing the price as a plain number. Now it’s our job to format this data before showing it off to the user.
So always remember, when a value can be transformed with a pure function, (which means, given the same input, we always have the same output,) we don’t really need to store it into our state; we can just call a transform function in the place where this value will be displayed to the user. In a React view, it would be as simple as <p>{formatPrice(product.price)}</p>
.
We often see developers storing the return value of formatPrice(product.price)
and this might lead to drawbacks. What happens if we want to send this value back to the server? Or if we need to run calculations with it on the frontend? In that case, we would need to transform it back into a plain number, which is not ideal and can be totally avoided by not storing it.
One can argue that calling a function within a render numerous times can affect performance, but with techniques like memoization we avoid processing it every time. Therefore, performance is not an excuse not to do it. You can use a simple library like mem, or you can abstract this function call into a component like so <FormatPrice>{product.price}</FormatPrice>
and use its own React.memo function. But keep in mind that memoization is only needed when your function requires intensive processing.
Coming Next
This post came out a little longer than expected but we’re happy to say that this one, together with the previous post, covers the most common patterns we use to develop scalable frontend applications.
Of course, there are other concerns a modern application needs to handle, like authentication, error handling and styling — which will be discussed in future posts. In our next one, we’re going to talk about the interaction between the view layer and the state layer, and how to make them rely on each other while still keeping them decoupled, and also about routing. See you there!
Recommended Links
- Rethinking Web App Development at Facebook
- Lift State Up
- Structuring Reducers
- Getting started with Redux
- Building React Applications with Idiomatic Redux
- Vuex Docs
- NgRx Docs
- Advanced Redux Patterns: Selectors
- Redux: Colocating Selectors with Reducers
- Robust React User Interfaces with Finite State Machines
- Redux modules and code-splitting
- Welcome to the world of Statecharts
- Decomposing the TodoMVC app with state diagrams
- Writing tests for Redux
- Writing tests for VueX
- Writing tests for NgRx
Written by Iago Dahlem Lorensini and Talysson de Oliveira.
We want to work with you. Check out our "What We Do" section!