As web applications grow in complexity, so does the task of updating and displaying their underlying data. Many approaches to managing this data result in a complex web of views. These views may be listening for updates from various models which are pushing their changes to yet more views. This leaves developers with opaque, non-deterministic code that is nearly impossible to change without forgetting to attach a listener to an important strand in the web. Even worse, developers could introduce a bug in a different, seemingly unconnected corner of the application. Enter, Redux! It's a predictable state container for JavaScript apps that offers a solution to this problem.
Redux revolves around 3 core concepts:
- There is a single source of truth for your entire application state
- That state is read-only
- All changes to the application state are made by pure functions
When used alongside a handful of community-discovered best practices, these principles produce maintainable, easy-to-test applications and happy developers.
#The Core Concepts
1. SINGLE SOURCE OF TRUTH
When you use Redux, the underlying data for your entire application is represented by a single JavaScript object, referred to as the state or state tree. This object can be as simple or complex as your application demands. For example, the state for a simple todo app might be a single array of todo objects.
const state = [
{
id: 1,
task: 'Do laundry',
completed: true
},
{
id: 2,
task: 'Paint fence',
completed: false
}
];
The state for a social media site might be a dictionary that contains information about posts, notifications, profile data, and other social data.
const defaultState = {
posts: [
// post objects to appear in user's feed
],
notifications: [
// unread notifications for the user
],
messages: [
// new messages
],
friends: [
// other online users
],
profile: null
}
Regardless of the size of the application, all state data is stored in a single object. There will be more on techniques for managing a large application state later.
2. STATE IS READ-ONLY
The presentation layer will never directly manipulate the state of your app. For example, the submit handler on the add-todo form wouldn't directly push a new task onto your todos array. Instead, that handler would emit an action that says "Hey app, I'd like to add a 'Buy milk' task to the todos array".
An action is a simple JavaScript object that expresses an intent to mutate the state object.
In Redux, an action is a simple JavaScript object that expresses an intent to mutate the state object. It contains the minimal information needed to describe what should change as a result of the user interaction. The only required attribute of an action is a type; all other data included in the action will be specific to your application and the type of action being emitted. When the user adds a 'Buy milk' task, the emitted action might look like this:
{
type: 'ADD_TODO',
task: 'Buy milk',
id: 3
}
3. CHANGES ARE MADE WITH PURE FUNCTIONS
So, what happens to the actions once they're emitted by the UI? There is a single function that listens for these actions. It's basically a big switch statement that hinges on the action type
field. Each action type that can be emitted in your app needs a case that calculates the new app state based on the current state and the data in the action. This function must be pure! If you're unfamiliar with pure functions, I highly recommend that you watch Dan Abramov, creator of Redux, explain them here.
A function is pure if it returns the same value every time a given set of arguments is passed to it.
An input of A and B will always yield C in a pure function. If the function is impure, inputs A and B could yield C or they could yield a different value D. The output is determined from the input and nothing else. Pure functions do not have any side effects, so they do not make network requests or query databases. Additionally, pure functions do not modify their input arguments. Instead, they use the input to calculate a value and then return that calculated value.
Continuing with our todos example, the case for the 'ADD_TODO'
action type won't push a new value onto the todos array. That's not pure, because it modifies the existing array. Instead, the 'ADD_TODO'
case will make a copy of the todos array, add the todo to the end of that new array, and then return the new array as the next application state.
(currentState, action) => {
switch(action.type){
case 'ADD_TODO':
const nextState = [
...currentState,
{
id: action.id,
task: action.task,
completed: false
}
];
return nextState;
break;
default:
return currentState;
}
};
This pure function that knows how to transform the current application state plus any action into an updated application state is called the rootreducer. The fact that the root reducer calculates the next state rather than modifying the existing state is very important in the Redux framework. Using this pattern, state calculations remain fast, since we can simply pass the reference of any unchanged data chunk in the current state through to the next state. We also get the security of declaring our state immutable and knowing that it cannot be modified by anything outside of the action -> reducer chain.
#Best Practices
Now that we've looked at the core concepts of Redux, I'm excited to share some best practices that will help keep your Redux application code looking good. These come from Dan Abramov's Getting Started with Redux video series, the Redux documentation, and my development team's experience working on production Redux applications.
STATE SHAPE
Flat Objects
Keeping your state structure flat is a great way to reduce complexity and make development, maintenance, and debugging easier for yourself down the road. This is the same idea as normalizing your database tables. Let's say your todo list is going to be edited by a million people and you want to keep track of who created each todo item. You could represent that data by adding an author object to each todo task:
const state = [
{
id: 1,
task: 'Do laundry',
completed: true,
author: {
name: 'Billy Bob',
role: 'Assistant Editor'
}
},
{
id: 2,
task: 'Paint fence',
completed: false,
author: {
name: 'Billy Bob',
role: 'Assistant Editor'
}
}
];
That's fine for a small application without a bunch of moving parts, but we should flatten those todo objects if we're working on a large application that needs to be easy to extend and maintain. We can pull the author objects out of the todo array and just reference the author id in our todo objects.
const state = [
todos: [
{
id: 1,
task: 'Do laundry',
completed: true,
authorId: 1
},
{
id: 2,
task: 'Paint fence',
completed: false,
authorId: 1
}
],
authorsById: {
1: {
id: 1,
name: 'Billy Bob',
role: 'Assistant Editor'
}
}
];
We can take this a step further and create a separate object, indexed by id, for our todos. Then our todos list can be represented by a simple array of ids.
const state = {
todos: [1, 2],
todosById: {
1: {
id: 1,
task: 'Do laundry',
completed: true,
authorId: 1
},
2: {
id: 2,
task: 'Paint fence',
completed: false,
authorId: 1
}
},
authorsById: {
1: {
id: 1,
name: 'Billy Bob',
role: 'Assistant Editor'
}
}
};
With this flat structure, there is a single place that needs to be updated when changes are made to the underlying application data. Developers can feel confident that their changes in one part of the application (ex. an author's user info) won't break another part of the application (ex. the order of the todo list). It also makes it easy for multiple views to reference this data and display it in different ways.
If you find yourself looking for a way to flatten JSON API responses to store in your application state, you should check out Normalizr, a library that helps flatten JSON data.
ACTIONS
Keep your actions small! Each action should contain only the minimal amount of information needed to transform the application state. For example, each todo in our application state includes a completed boolean. Since we know that the completed field will always be false for a new todo, we don't need to specify that field in our 'ADD_TODO'
action.
It is also very common for Redux applications to pull the logic for creating actions out of the application's view code into functions that can be used in different parts of the application. These extracted functions are calledActions Creators. They should be kept separate from your views and your reducers. Action creators are super handy for documentation purposes, because they provide a complete list of actions that your components can emit to modify the application state.
A button that adds a todo to your Redux application might look like this, without using action creators:
<button onclick="dispatch({ type: 'ADD_TODO', task: 'Walk dog', id: nextTodoId++ })">Add Walk Dog Todo</button>
<script>
// Redux setup code would go here
let nextTodoId = 0;
</script>
Side note: dispatch is a function of the Redux store object-- it's what you use to emit actions throughout your app. Here's a short video that explains how to include Redux in your project and setup the initial store object. I'll include the code that sets up your Redux store a little later.
That same application logic would look something like this if it were using action creators. (The addTodo function is the action creator.)
<button onclick="dispatch(addTodo('Walk dog'))">Add Walk Dog Todo</button>
<script>
// Redux setup code would go here
let nextTodoId = 0;
const addTodo = (task) => {
return {
type: 'ADD_TODO',
id: nextTodoId++,
task
};
};
</script>
Notice how the add todo button no longer needs to know the next id for a todo? That information can be maintained by the addTodo action creator which gives other views the ability to add todo objects. Additionally, the addTodo action creator makes it trivial to add a 'Feed Cat' todo button. The script that contains the action creator provides a nice list of actions that are available to our views.
Since our reducers must be pure, action creators provide a good place to put code with side effects or async function calls. And, because our action creators are decoupled from the view logic, it makes testing the application logic easier.
REDUCERS
As your app grows, so will the root reducer function that handles all the action types. To keep up with this growth, your application's root reducer function can hand off the management of different parts of its state tree to other, specialized reducers. In our todos example, our root reducer can hand off the todo object to a todo reducer and the author object to an author reducer. This break-it-up-and-hand-it-off pattern is called reducer composition. It helps scale development, since it cleanly separates the application logic into contained chunks that different developers can take ownership of.
To keep the example simple, I'm just going to include an array of authors and todos in the state. But, as previously discussed, it would be better to store this data in objects indexed by id for larger applications.
At the very beginning of this journey, we created a reducer to manage our application's todos:
const todos = (currentState = [], action) => {
switch(action.type){
case 'ADD_TODO':
const nextState = [
...currentState,
{
id: action.id,
task: action.task,
completed: false
}
];
return nextState;
break;
default:
return currentState;
}
};
Now we need to create a reducer that manages our author objects. That could look something like this:
const authors = (currentState = [], action) => {
switch(action.type) {
case 'ADD_AUTHOR':
const nextState = [
...currentState,
{
id: action.id,
name: action.name,
role: action.role
}
];
return nextState;
break;
default:
return currentState;
}
};
To bring it all together, we'll create the root reducer that combines the objects managed by those reducers into a single state object!
const todoApp = (currentState = {}, action) => {
return {
todos: todos(currentState.todos, action),
authors: authors(currentState.authors, action),
}
};
The todoApp function is our root reducer which hands off the todo and author branches of the application state to specialized reducers. This example looks at reducer composition using whole objects, but you can do the same thing with arrays and their contents. One reducer would know how to add and remove items from the array and a separate reducer would know how to update individual items in the array. In this pattern, the "parent" array reducer would call the item reducer when it needs to add or modify one of its items. This video does a great job of explaining that kind of composition.
The todoApp reducer is a single, pure function that transforms the current state and an action into the next state for our app. This is the reducer Redux uses to create our application store. Here's a super simple html page with 2 buttons that uses action creators and reducer composition to add authors and todos to our application state. If you save this html and open it in a browser, you can add todos and authors and look at the application state in the console.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Super Simple Redux Example</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.5.2/redux.js"></script>
</head>
<body>
<button onclick="store.dispatch(addTodo('Walk dog')); console.log(store.getState());">Add Walk Dog Todo</button>
<button onclick="store.dispatch(addAuthor('Billy Bob', 'Assistant Editor')); console.log(store.getState());">Add Billy Bob Author</button>
<script>
// Action Creators
let nextTodoId = 0;
const addTodo = (task) => {
return {
type: 'ADD_TODO',
id: nextTodoId++,
task
};
};
let nextAuthorId = 0;
const addAuthor = (name, role) => {
return {
type: 'ADD_AUTHOR',
id: nextAuthorId++,
name,
role,
};
};
</script>
<script>
// Reducers
const todos = (currentState = [], action) => {
switch(action.type){
case 'ADD_TODO':
const nextState = [
...currentState,
{
id: action.id,
task: action.task,
completed: false
}
];
return nextState;
break;
default:
return currentState;
}
};
const authors = (currentState = [], action) => {
switch(action.type) {
case 'ADD_AUTHOR':
const nextState = [
...currentState,
{
id: action.id,
name: action.name,
role: action.role
}
];
return nextState;
break;
default:
return currentState;
}
};
const todoApp = (currentState = {}, action) => {
return {
todos: todos(currentState.todos, action),
authors: authors(currentState.authors, action),
}
};
</script>
<script>
// Redux setup
const { createStore } = Redux;
const store = createStore(todoApp);
</script>
</body>
</html>
To recap, here's a quick summary of the Redux best practices we covered:
- Keep your state object flat
- Pass as little data as possible in your actions
- Use action creators to dispatch actions instead of assembling and emitting them directly from your views
- Your root reducer should be composed of smaller reducers that manage specific parts of the application state
Keep those tips in mind as you're designing and creating your Redux application, and you'll be golden.
#Testing
Writing tests for your Redux application code is actually a pretty pleasant experience. Pure reducers make it easy to know what the result of an action should be, and action creators make it easy to isolate and test the actual application logic that's being executed by your views. I'm going to use the expect library to write some simple tests for our todo app, but that is certainly not the only framework you can use to test your application. The Redux documentation recommends using Mocha as the testing engine.
ACTION CREATORS
When we test our application's action creators, we want to make sure that the right action is being created. That's a pretty simple task since our action creators return plain JavaScript objects. Here's a test for our addTodo action creator:
const taskText = 'Walk dog';
const expectedAction = {
type: 'ADD_TODO',
task: taskText,
id: 0
};
expect(addTodo(taskText)).toEqual(expectedAction);
REDUCERS
To test your application's reducers, simply ensure that the next state is the state you would expect given the current state and a specific action. Passing undefined as the current state to your root reducer is a good way to ensure that your application is setting up the initial state correctly. In our previous examples, we specified an empty array for the default current state in our author and todo reducers. We can check that with the following test:
const initialState = {
todos: [],
authors: []
};
expect(todoApp(undefined, {})).toEqual(initialState);
And here's a simple test to ensure that our Billy Bob author is correctly added to the application state:
const initialState = {
todos: [],
authors: []
};
const newAuthor = {
name: 'Billy Bob',
role: 'Assistant Editor',
id: 0
};
const addAuthorAction = {
type: 'ADD_AUTHOR',
name: newAuthor.name,
role: newAuthor.role,
id: newAuthor.id
};
expect(todoApp(initialState, addAuthorAction)).toEqual({
todos: [],
authors: [ newAuthor ]
});
Reducers are pure functions.
The number one most important thing about reducers is that they are pure functions. So, in addition to checking that the final state object contains the data we expect, we should also ensure that our reducers don't mutate the state object. We can do this by calling freeze on our objects before passing them to our reducers. That way, if we do attempt to modify the state, our tests will let us know. Deep freeze is a nice utility library for calling JavaScript's Object.freeze() recursively on our state object.
const initialState = {
todos: [],
authors: []
};
const newAuthor = {
name: 'Billy Bob',
role: 'Assistant Editor',
id: 0
};
const addAuthorAction = {
type: 'ADD_AUTHOR',
name: newAuthor.name,
role: newAuthor.role,
id: newAuthor.id
};
deepFreeze(initialState);
expect(todoApp(initialState, addAuthorAction)).toEqual({
todos: [],
authors: [ newAuthor ]
});
#Wrapping it up
So that's Redux in a nutshell! It's a lovely solution for web application state management, especially if you're familiar with the woes of alternative methods. The single, read-only source of truth, pure reducers, and easy-to-test components will surely boost the confidence and productivity of any JavaScript application developer. And, since it's not tied to a specific view engine (although it is often used with React), you/your team can easily plug Redux into your existing development stack if application state management has been a pain point on past projects.