Making sense of Redux
Emerging trends
These days, there is an increasing use of single page applications (SPA), progressive web apps (PWA), Electron-based desktop apps and React-Native mobile apps. But why?
The reasons are simple and justifiable.
- All these apps are written in JavaScript and allow quick iterations.
- The end-users don't even experience any difference.
- The tooling and community is developer-friendly, so the app developers are usually happy.
- Management have to worry less about hiring the experienced developers.
- And, app owners can focus on increasing their business instead of worrying about technical glitches in their app.
But that doesn't mean everything is good in terms of developer experience and the challenges faced while building such apps.
Complex app state
There are very popular frontend frameworks such as React.js, Vue.js, Angular.js etc. that allow building SPAs, PWAs and desktop apps (using Electron) very easily. React-Native helps building mobile apps with the same ease. Each of these frameworks or technologies offer features to build and render the app views with less hassle, support dynamic routing, and also, support managing the app's state at some extent.
Since the old-school way of reloading the whole page to refresh the app's state doesn't make any sense now, the app has to store and manage the state locally and keep it in sync with the backend. Some developers try to keep the state centralized, while some keep it distributed within the app components.
Complex user interfaces may depend on the data that sometimes is common. So by fetching the same data over again to render some other parts of the app can often lead to bad user experience and unnecessary processing on the backend. But sharing such a common data across the app1 can be very challenging since it needs to be constantly cached, invalidated and updated.
The state can consist of many things and not just these —
- Responses from backend for various resources.
- Cached data.
- Local data that's not synced with the backend yet.
- UI state information such as active tabs, AJAX loading state, pagination criteria, etc.
There are so many options available to manage such a complex app state conveniently. Among which, Redux is the most popular, community-driven state management library.
What's Redux?
Redux is a standalone, tiny JavaScript library. That means it can be used in any JavaScript project and it also works very nicely with the popular frameworks such as React.js, Vue.js, Angular.js, etc.
Redux inherits a lot of ideas from the functional programming paradigm. It recommends writing pure, side-effects free and immutable code. But JavaScript isn't a pure functional programming language and doesn't restrict us from writing mutable code. That's why Redux abstracts a lot of things for interacting with the state and hides them from the developers to provide a highly predictable behaviour.
Because of the predictable (deterministic) behaviour, it is trivial for Redux to support features like undo-redo and time-travel debugging2.
Redux is essentially build upon just three main concepts.
1. Centralized store
The whole application state is stored in a single plain JavaScript object. This object is called as Redux store. The object can have nested information. It is recommended to put only those values in the Redux store which are JSON-serializable. Because the state is centralized, it can be easily fed with an initial data while server-rendering the app.
2. State is immutable
We can access the state in read-only mode. That means, we cannot mutate the state directly. To change the state, Redux needs us to dispatch actions. Actions are just plain JavaScript objects that describe the intent. Redux applies all such actions on the centralized store one-by-one to avoid any race conditions.
// An example "action" object.
// Only `type` key is mandatory, rest of the payload is optional.
{
type: "PUBLISH_ARTICLE",
id: "my-awesome-article-1234",
tweet: true
}
3. Reduce the state
These applied actions are passed to a plain JavaScript function that is known as a reducer function. The reducer function transforms the previous state to a new one according to the passed action as intended. The reducer function shouldn't mutate the previous state directly, instead it should copy the previous state and modify that copy. The reducer function should be a pure function3. There is just a single reducer function but it's very easy to split it into smaller composable reducer functions as per our needs.
// Signature of a "reducer" function.
(oldState, action) => nextState
So we now know a bit about Redux store, actions and the reducer. But how does these things work together?
Using Redux
We can create the Redux store but we need to specify a reducer to do that. So let's create a reducer first.
Implementing a reducer
Reducer is a simple function that takes the old state and an action as arguments and returns a new state. It implements how an action (having mandatory field type
) transforms the old state into a new one. If the reducer cannot recognize the given action, it must return the previous state as is.
// This is the shape of our example app state.
// Structuring the app structure is totally up to us.
// It just needs to be a plain JavaScript object with JSON-serializable values.
const initialState = {
articles: [],
comments: [],
users: [],
};
function myAppReducer(state = initialState, action) {
switch (action.type) {
case "ADD_ARTICLE":
// Notice that we are NOT mutating the 'state' object here. Instead we
// are using ES6 spread operator to create a new object from it by
// concatenating the specified article to an existing list of articles.
return { ...state, articles: [...state.articles, action.article] };
case "UPDATE_ARTICLE":
// Returns new state with the updated article...
case "PUBLISH_ARTICLE":
// Returns new state by marking the specified article as published...
case "UNPUBLISH_ARTICLE":
// Returns new state by unpublishing the specified article...
case "ADD_COMMENT":
// Returns new state by adding a new comment...
// More cases here...
default:
return state;
}
}
Look at the number of cases in that switch-case expression. That list can just keep increasing. Can we make it better?
Of course, we can!
// This reducer receives the previous state with just the list of articles.
function articlesReducer(state = [], action) {
switch (action.type) {
case "ADD_ARTICLE":
return [...state, action.article];
case "UPDATE_ARTICLE":
// Returns new state with the updated article...
case "PUBLISH_ARTICLE":
// Returns new state by marking the specified article as published...
case "UNPUBLISH_ARTICLE":
// Returns new state by unpublishing the specified article...
// More cases here...
default:
return state;
}
}
// And this reducer receives state of comments.
function commentsReducer(state = [], action) {
switch (action.type) {
case "ADD_COMMENT":
return [...state, action.comment];
// More cases here...
default:
return state;
}
}
// And this reducer gets the user list as the state.
function usersReducer(state = [], action) {
// ...
}
function myAppReducer(state = {}, action) {
return {
articles: articlesReducer(state.articles, action),
comments: commentsReducer(state.comments, action),
users: usersReducer(state.users, action),
};
}
We split the myAppReducer
into smaller reducers that deal with transforming only certain parts of the overall state. We can extract those split reducers into their own files if we want to.
Since, splitting and combining reducers is a very common practice, there's combineReducers
higher order function4 that simplifies the process of combining the multiple reducers into a single reducer.
import { combineReducers } from "redux";
const myAppReducer = combineReducers({
articles: articlesReducer,
comments: commentsReducer,
users: usersReducer,
});
Because we now have a reducer function, let's go and create the store.
Creating store
Creating the Redux store is straightforward.
import { createStore } from "redux";
// Pass the `myAppReducer` reducer function we just implemented.
const store = createStore(myAppReducer);
This store object holds the whole app's state.
Creating and dispatching actions
An action is a plain JavaScript object that mandatorily has a type
property that indicates "what it does". There could be or could not be additional properties in this object apart from the type
property.
Here are some example actions.
// An action without any payload
{ type: "TOGGLE_DARK_MODE" }
// An action with payload
{
type: "LOGIN",
email: "dan@example.com",
password: "dan'sSuperDuperSIMPLEPassword",
}
To change the Redux state, we need to dispatch an action.
The Redux store
object that we defined above has the dispatch()
method. We must use it to dispatch an action.
// Say, we want to add an article to the store.
// So, we dispatch an action of type "ADD_ARTICLE".
store.dispatch({
type: "ADD_ARTICLE",
article: {
id: "my-awesome-article-1234",
title: "My awesome article",
description: "I will write it tomorrow",
published: false,
},
});
This can be okay in smaller apps. But in large codebases, writing down that whole action structure by hand can be tricky and error-prone, and would require changing all the occurences in the codebase when its structure changes.
To simplify, we abstract this logic into an action creator.
An action creator is a function that creates an action. That's it.
// This action creator returns an action of type "ADD_ARTICLE".
const addArticle = (title, description) => {
return {
type: "ADD_ARTICLE",
article: { id: generateId(title), title, description, published: false },
};
};
const updateArticle = (id, title, description) => {
// Returns an action of type "UPDATE_ARTICLE"...
};
// More action creators here...
And this is how we can dispatch an action now by using an action creator.
store.dispatch(addArticle("My awesome article", "I will write it tomorrow"));
When an action is dispatched, Redux calls the reducer function. In our case, Redux calls the myAppReducer
function. Since myAppReducer
is combined of multiple reducers, all those reducer functions (such as articlesReducer
, commentsReducer
and usersReducer
) are called one by one in the defined order with that action. The articlesReducer
handles this action and updates the store accordingly. Other reducers can also handle this action as well if needed.
Async action creators
So far, we have been discussing the actions which are synchronous in nature. The state gets updated immediately when a synchronous action is dispatched. But the world is cruel. Not all actions happen instantly. They happen after some delay and often involve performing some side effects. Some common examples of asynchronous actions are — making an HTTP/AJAX request, generating a random number, manipulating DOM, interacting with LocalStorage, etc.
Consider this situation.
We want to fetch the articles from our backend API. To do so, we simply CANNOT update the store immediately with the latest articles from the backend by just dispatching a single FETCH_ARTICLES
action. We must make an HTTP call to our backend to fetch the articles and then based on the asynchronous outcome, update the store accordingly. This whole operation would consist of following actions.
- Action
FETCH_ARTICLES_INIT
to indicate that the asynchronous HTTP request began. Reducer may handle this and toggle a flag such asfetchingArticles
in the state totrue
. UI can show a loading spinner by reading this flag from the state. - Action
FETCH_ARTICLES_SUCCESS
to indicate that it contains latest articles from the backend. Reducer updates the articles in the state accordingly. Reducer can also toggle the flagfetchingArticles
tofalse
. UI would be refreshed with the latest articles. - Action
FETCH_ARTICLES_FAILURE
to indicate that the HTTP request failed for some reason. Reducer may handle this action and toggle the flagfetchingArticles
tofalse
as well as store the error message in the state so that UI can display that error to the user.
We can dispatch these various actions ourselves but that can be tedious. To make it easy, there are some Redux middlewares5 available. Redux Thunk, Redux Saga, Redux Observable are some popular ones among them. Redux Thunk is a standard way to deal with the async actions. Other middlewares can be used to tackle more advanced scenarios.
When Redux Thunk middleware is used, an action creator can return a function instead of an action object. Such an action creator is known as an async action creator and it becomes a thunk6. The Redux Thunk middleware executes such a function returned by an action creator. That returned function can perform side-effects and dispatch actions as we wish.
This is how our async action creator would look like.
import fetch from "cross-fetch";
// This is a synchronous action creator.
const fetchArticlesInit = () => ({ type: "FETCH_ARTICLES_INIT" });
// This is also a synchronous action creator.
const fetchArticlesSuccess = ({ articles }) => ({
type: "FETCH_ARTICLES_SUCCESS",
articles,
});
// This is a synchronous action creator as well.
const fetchArticlesFailure = error => ({
type: "FETCH_ARTICLES_FAILURE",
error,
});
// This is an async action creator.
const fetchArticles = () => {
// The returned function can dispatch actions with the received
// `dispatch` handler (1st argument) and can retrieve the current
// state using `getState` handler (2nd argument).
return (dispatch, getState) => {
dispatch(fetchArticlesInit());
fetch(`/api/v1/articles`)
.then(response => response.json())
.then(json => dispatch(fetchArticlesSuccess(json)))
.catch(e => dispatch(fetchArticlesFailure(e.message)));
};
};
The logic to create the Redux store needs to be updated a bit when we use a middleware like Redux Thunk.
import thunkMiddleware from "redux-thunk";
import { createLogger } from "redux-logger";
import { createStore, applyMiddleware } from "redux";
const loggerMiddleware = createLogger();
const store = createStore(
myAppReducer,
applyMiddleware(thunkMiddleware, loggerMiddleware)
);
// We dispatch our async action creator similar to how we dispatch
// the synchronous ones. There's no difference on the dispatch side!
store.dispatch(fetchArticles());
Retrieving state
We can manually fetch the current state using getState()
method on the store
object.
store.getState();
// 👆 This would return the current state
// that would be a plain JavaScript object:
//
// {
// articles: [
// {
// id: "my-awesome-article-1234",
// title: "My awesome article",
// description: "I will write it tomorrow",
// published: false,
// },
// ],
// comments: [],
// users: [],
// }
But how to automatically get notified whenever the store gets updated due to some action?
For that, we need to subscribe()
to the store
.
Subscribing to the store updates to stay in sync
Whenever store is updated after the reducer returns a new state because of a dispatched action, Redux invokes all the listeners previously registered using store.subscribe(listenerFunction)
method.
// This listener function gets called only when the store is updated.
const updateUI = () => {
const updatedState = store.getState();
// Logic to update the UI with the `updatedState` here...
};
store.subscribe(updateUI); // returns a function to unsubscribe this listener
Note that the listener function doesn't receive any information. So if we want, we have to call store.getState()
method to fetch the updated state within that listener.
Redux with React
Keeping UI in sync with the state by ourselves can be challenging. To make our life easier, Redux officially provides binding for React.
With the help of official react-redux package, using Redux in a React app then becomes very easy.
The main React component needs to be enclosed within the <Provider />
React Redux component that makes the Redux store available to the whole React app.
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import store from "./store";
import App from "./App";
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
As we can see, thanks to React Redux — we don't have to worry about manually subscribing/unsubscribing to the store!
Now onwards, to connect any React component7 to the Redux store, we use connect()
helper function.
import { connect } from "react-redux";
import { fetchArticles, toggleDarkMode } from "./actionCreators";
const ArticleListing = ({ articles, fetchArticles }) => (
<div>
<ul>
{articles.map(({ id, title }) => (
<li key={id}>{title}</li>
))}
</ul>
<button onClick={fetchArticles}>Refresh</button>
</div>
);
const mapStateToProps = (state /*, ownProps*/) => ({
articles: state.articles,
});
const mapDispatchToProps = (/* dispatch */) => ({
fetchArticles,
/* toggleDarkMode, */
});
export default connect(mapStateToProps, mapDispatchToProps)(ArticleListing);
The connect()
function accepts two optional arguments.
1. mapStateToProps
We provide this function to only select the data from the store that the connected component needs. It is called every time the store is updated. In our example above, we select the articles
property from the state object (line #16). The articles
property then is accessible as a prop by the connected component (line #4). This way the connected component can re-render itself whenever the state changes and keep itself in-sync with the state.
2. mapDispatchToProps
If this argument is NOT specified, the connected component receives the Redux store's dispatch
function as a prop. We specify this argument as a function to receive the needed action creator functions as props in the connected component so that we can call props.fetchArticles()
directly instead of calling props.dispatch(fetchArticles())
to dispatch an action.
Finally, one last question that many of us may have to answer often.
When to use Redux and what goes inside it?
Many applications may NOT need Redux at all. But if we discover that we are writing so much repetitive and low-level code to manage the app's state by-hand — and we are finding it hard to extend, manage and debug that custom state management logic then Redux is definitely a recommended option to roll in.
But Redux doesn't care or ditctate about what data we store in it and how we store it. We need to make that call. This is a good thing but that often makes it confusing for us to decide what to store and what not to store in Redux. Storing things like form state could hit Redux store on every key stroke.Imagine that — hitting the Redux store on every keypress! That could hamper the app's performance a lot. Therefore, we should NOT be putting everything in the Redux store. Instead, we must find out the data that other parts of application care about or the data that drives multiple components. Such a shared data can be stored inside Redux. But that's just an opinion. Everyone's situation is different. So we must study our application carefully and ensure that we are not over-using Redux or making its usage unnecessarily complex.
Final thoughts
We didn't cover many other important and advanced topics such as server rendering, caching, state selectors, normalizing data, and many more. But we know that they can be explored as per our needs.
Hopefully, Redux should be making a little bit of sense to you.
That's it for now, folks. Until then, happy Reduxing!
Footnotes
In this article, an app or an application basically means the frontend part of a web application.
The Redux time-travel debugger can help triage and fix bugs in the development environment. It can be injected as a middleware so it can record every change occurring in the app's state sequentially. With the Redux DevTools addon, one can easily replay or rewind those recorded changes back and forth and let us see how the app state tree got modified at each step. There's a small tutorial posted by Wes Bos on YouTube in 2016 showing Redux DevTools in action. It's pretty old and it has changed a lot but should help you understand what Redux is capable of doing.
A pure function always returns the same value every time when called with the same arguments. Also, it doesn't depend on any external dependencies or any external variables and it doesn't have any effect on the rest of the program.
- A higher order function either receives a function as an argument or returns a function.
Array.map
is a very common example of a higher order function.- The
combineReducers
function takes an object of different reducing functions, turns it into a single reducing function and returns it.
Redux middleware is an extension that gets injected at a point between dispatching an action, and the moment it reaches the reducer. Read more here.
Thunk is a function that's lazily evaluated only when it is needed.
which is a child of our main App
component