Redux is a great state management system. When we are developing complex systems such as Google Docs or a reporting system that lets us look up various user statistics in real time, it can manage the state of our application. However, it can lead to some overly complicated systems that may not need the state management that it represents.
Redux takes the philosophy that no one object should be able to mutate the state of an application. All of that state needs to be hosted in a single location and there should be functions that handle state changes. This would mean a single location for writes, and multiple locations able to read the data. This is similar to some concepts that we will want to utilize later.
However, it does take things a step further and many articles will want us to pass back brand-new objects. There is a reason for this. Many objects, especially those that have multiple layers, are not easy to copy off. Simple copy operations, such as using Object.assign({}, obj) or utilizing the spread operator for arrays, will just copy the references that they hold inside. Let's take a look at an example of this before we write a Redux-based application.
If we open up not_deep_copy.html from our repository, we will see that the console prints the same thing. If we take a look at the code, we will see a very common case of copying objects and arrays:
const newObj = Object.assign({}, obj);
const newArr = [...arr];
If we make this only a single layer deep, we will see that it actually executes a copy. The following code will showcase this:
const obj2 = {item : 'thing', another : 'what'};
const arr2 = ['yes', 'no', 'nope'];
const newObj2 = Object.assign({}, obj2);
const newArr2 = [...arr2]
We will go into more detail regarding this case and how to truly execute a deep copy, but we can begin to see how Redux may hide problems that are still in our system. Let's build out a simple Todo application to at least showcase Redux and what it is capable of. So, let's begin:
- First, we will need to pull down Redux. We can do this by utilizing Node Package Manager (npm) and installing it in our system. It is as simple as npm install redux.
- We will now go into the newly created folder and grab the redux.min.js file and put it into our working directory.
- We now will create a file called todo_redux.html. This will house all of our main logic.
- At the top of it, we will add the Redux library as a dependency.
- We will then add in the actions that we are going to perform on our store.
- We will then set up the reducers that we want to use for our application.
- We will then set up the store and prepare it for data changes.
- We will then subscribe to those data changes and make updates to the UI.
The example that we are working on is a slightly modified version of the Todo application from the Redux example. The one nice thing is that we will be utilizing the vanilla DOM and not utilizing another library such as React, so we can see how Redux can fit into any application if the need arises.
- So, our actions are going to be adding a todo element, toggling a todo element to complete or not complete, and setting the todo elements that we want to see. This code appears as follows:
const addTodo = function(test) {
return { type : ACTIONS.ADD_TODO, text };
}
const toggleTodo = function(index) {
return { type : ACTIONS.TOGGLE_TODO, index };
}
const setVisibilityFilter = function(filter) {
return { type : ACTIONS.SET_VISIBILITY_FILTER, filter };
}
- Next, the reducers will be separated, with one for our visibility filter and another for the actual todo elements.
The visibility reducer is quite simple. It checks the type of action and, if it is a type of SET_VISIBILITY_FILTER, we will handle it, otherwise, we just pass the state object on. For our todo reducer, if we see an action of ADD_TODO, we will return a new list of items with our item at the bottom. If we toggle one of the items, we return a new list with that item set to the opposite of what it was set to. Otherwise, we just pass the state object on. All of this looks like the following:
const visibilityFilter = function(state = 'SHOW_ALL', action) {
switch(action.type) {
case 'SET_VISIBILITY_FILTER': {
return action.filter;
}
default: {
return state;
}
}
}
const todo = function(state = [], action) {
switch(action.type) {
case 'ADD_TODO': {
return [
...state,
{
text : action.text,
completed : false
}
}
case 'TOGGLE_TODO': {
return state.map((todo, index) => {
if( index === action.index ) {
return Object.assign({}, todo, {
completed : !todo.completed
});
}
return todo;
}
}
default: {
return state;
}
}
}
- After this, we put both reducers into a single reducer and set up the state object.
The heart of our logic lies in UI implementation. Notice that we set this up to work off the data. This means that data could be passed into our function and the UI would update accordingly. We could make it the other way around, but making the UI be driven by data is a good paradigm to live by. We first have a previous state store. We can utilize this further by only updating what was actually updated, but we only use it for the first check. We grab the current state and check the differences between the two. If we see that the length has changed, we know that we should add a todo item. If we see that the visibility filter was changed, we will update the UI accordingly. Finally, if neither of these is true, we will go through and check which item was checked or unchecked. The code looks like the following:
store.subscribe(() =>
const state = store.getState();
// first type of actions ADD_TODO
if( prevState.todo.length !== state.todo.length ) {
container.appendChild(createTodo(state.todo[state.todo.length
- 1].text));
// second type of action SET_VISIBILITY_FILTER
} else if( prevState.visibilityFilter !==
state.visibilityFilter ) {
setVisibility(container.children, state);
// final type of action TOGGLE_TODO
} else {
const todos = container.children;
for(let i = 0; i < todos.length; i++) {
if( state.todo[i].completed ) {
todos[i].classList.add('completed');
} else {
todos[i].classList.remove('completed');
}
}
}
prevState = state;
});
If we run this, we should get a simple UI that we can interact with in the following ways:
- Add todo items.
- Mark existing todo items as complete.
We are also able to have a different view of it by clicking on one of the three buttons at the bottom as seen in the following screenshot. If we only want to see all of our completed tasks, we can click the Update button.
Now, we are able to save the state for offline storage if we wanted to, or we could send the state back to a server for constant updates. This is what makes Redux quite nice. However, there are some caveats when working with Redux that relate to what we stated previously:
- First, we are going to need to add something to our Todo application to be able to handle nested objects in our state. A piece of information that has been left out of this Todo application is setting a date by when we want to complete that item. So, let's add some fields for us to fill out to set a completion date. We will add in three new number inputs like so:
<input id="year" type="number" placeholder="Year" />
<input id="month" type="number" placeholder="Month" />
<input id="day" type="number" placeholder="Day" />
- Then, we will add in another filter type of Overdue:
<button id="SHOW_OVERDUE">Overdue</button>
- Make sure to add this to the visibilityFilters object. Now, we need to update our addTodo action. We are also going to pass on a Date object. This also means we will need to update our ADD_TODO case to add the action.date to our new todo object. We will then update our onclick handler for our Add button and adjust it with the following:
const year = document.getElementById('year');
const month = document.getElementById('month');
const day = document.getElementById('day');
store.dispatch(addTodo(input.value), {year : year.value, month : month.value, day : day.value}));
year.value = "";
month.value = "";
day.value = "";
- We could hold the date as a Date object (this would make more sense), but to showcase the issue that can arise, we are just going to hold a new object with year, month, and day fields. We will then showcase this date on the Todo application by adding another span element and populating it with the values from these fields. Finally, we will need to update our setVisibility method with the logic to show our overdue items. It should look like the following:
case visibilityFilters.SHOW_OVERDUE: {
const currTodo = state.todo[i];
const tempTime = currTodo.date;
const tempDate = new Date(`${tempTime.year}/${tempTime.month}/${tempTime.day}`);
if( tempDate < currDay && !currTodo.completed ) {
todos[i].classList.remove('hide');
} else {
todos[i].classList.add('hide');
}
}
With all of this, we should now have a working Todo application, along with showcasing our overdue items. Now, this is where it can get messy working with state management systems such as Redux. What happens when we want to make modifications to an already created item and it is not a simple flat object? Well, we could just get that item and update it in the state system. Let's add the code for this:
- First, we are going to create a new button and input that will change the year of the last entry. We will add a click handler for the Update button:
document.getElementById('UPDATE_LAST_YEAR').onclick = function(e) {
store.dispatch({ type : ACTIONS.UPDATE_LAST_YEAR, year :
document.getElementById('updateYear').value });
}
- We will then add in this new action handler for the todo system:
case 'UPDATE_LAST_YEAR': {
const prevState = state;
const tempObj = Object.assign({}, state[state.length -
1].date);
tempObj.year = action.year;
state[state.length - 1].date = tempObj;
return state;
}
Now, if we run our code with our system, we will notice something. Our code is not getting past the check object condition in our subscription:
if( prevState === state ) {
return;
}
We updated the state directly, and so Redux never created a new object because it did not detect a change (we updated an object's value that we do not have a reducer on directly). Now, we could create another reducer specifically for the date, but we can also just recreate the array and pass it through:
case 'UPDATE_LAST_YEAR': {
const prevState = state;
const tempObj = Object.assign({}, state[state.length - 1].date);
tempObj.year = action.year;
state[state.length - 1].date = tempObj;
return [...state];
}
Now, our system detects that there was a change and we are able to go through our methods to update the code.
The better implementation would be to split out our todo reducer into two separate reducers. But, since we are working on an example, it was made as simple as possible.
With all of this, we can see how we need to play by the rules that Redux has laid out for us. While this tool can be of great benefit for us in large-scale applications, for smaller state systems or even componentized systems, we may find it better to have a true mutable state and work on it directly. As long as we control access to that mutable state, then we are able to fully utilize a mutable state to our advantage.
This is not to take anything away from Redux. It is a wonderful library and it performs well even under heavier loads. But, there are times when we want to work directly with a dataset and mutate it directly. Redux can do this and gives us its event system, but we are able to build this ourselves without all of the other pieces that Redux gives us. Remember that we want to slim the codebase down as much as possible and make it as efficient as possible. Extra methods and extra calls can add up when we are working with tens to hundreds of thousands of data items.
With this introduction into Redux and state management systems complete, we should also take a look at a library that makes immutable systems a requirement: Immutable.js.