Web Unit 3 Sprint 10 - Redux & State Management
Module 1: The Reducer Pattern
In this module, you'll learn about the reducer pattern and how it can be used to manage state in React applications. You'll explore concepts like immutability, finite state machines, and how to effectively use reducers to handle state updates.
Learning Objectives
- Explain what immutability is in programming and demonstrate its benefits
- Describe the finite state machine pattern and its relationship to building Redux applications
- Use the reducer hook to manage complex state
- Write reducers to respond to actions and update state
Immutability in JavaScript
Learn why immutability is important and how to work with immutable data structures in JavaScript.
Key Concepts
- Object spread operator:
const newObj = { ...oldObj, property: newValue }
- Array spread operator:
const newArray = [...oldArray, newItem]
- Non-destructive array methods:
map()
,filter()
,concat()
Guided Project
Resources
Starter Repo: The Reducer Pattern
Solution Repo: The Reducer Pattern Solution
Finite State Machines
Mutable objects are objects whose state is allowed to change over time. An immutable value is an exact opposite; after it has been created, it can never change. There are some real benefits from making your state immutable. We won't go over all the benefits here, but we will talk about predictability and avoiding mutation.
Mutation hides change, which can create unexpected side effects. This can lead to some nasty bugs in our code. When we enforce immutability, we can keep our application architecture and mental model simple, making it easier to reason about the application. Simply put, it is very easy to predict how the state object will change based on certain actions/events. Without immutability, our state object can be changed or updated in unpredictable ways, causing weird behavior or bugs.
How to Build It
You should know that numbers and strings are immutable types of data. A number cannot magically turn into a
different number. If the number is held inside a variable declared with let
, then this variable
can be reassigned to a different number, creating the illusion that the number itself is changing. The same
goes for strings: strings are immutable: once declared, a string never changes. Methods like
toUpperCase
just return a brand new string.
Objects are different. These are mutable data structures! By default, in JavaScript we can add, overwrite and delete the properties of any object. The object itself is the same object, but its shape suffers mutations if we perform those kinds of operations. This is part of what makes JavaScript so flexible and dynamic, but it can also be a source of bugs in certain scenarios, when an object unexpectedly changes shape.
In functional programming, and when working with React and Redux, we try to avoid mutating objects. The spread operator comes in handy for creating copies of objects to avoid mutating in place the original ones:
const obj = { name: 'Pam', age: 34 }
// obj.age = 35 // CAREFUL! This would mutate the original object in place
const obj2 = { ...obj, age: 35 } // Copy with a modification, preserving the original
This preserves the original object. It must be noted that a copy built like this is a shallow copy. The spread operator won't recursively copy mutable structures nested inside the object. If a property of an original object contains another object, creating a shallow copy means that the nested object is one and the same for both original and copy. Suppose we wish to increase the age of a user nested inside an object, without mutating the object:
const data = { id: 7, user: { name: 'John', age: 45 } } // How can we increase the age?
const newData = { ...data } // Shallow copy
newData.user.age = 46 // Mutating data, falsely believing the original data.user is safe
newData.user.age // Evaluates to 46 like we wanted...
data.user.age // Oh, no! This also evaluates to 46. We mutated the original!
Luckily it's easy to modify our technique to avoid mutating the original user:
const data = { id: 7, user: { name: 'John', age: 45 } }
const newData = { ...data, user: { ...data.user, age: 46 } } // A birthday without mutations
Arrays in JavaScript are also mutable data structures. Many array methods, like push
,
pop
or splice
are destructive, in the sense that they mutate the array on which the
method is called. Other methods are non-destructive and return a new array without damaging the original one,
like filter
, map
and concat
. The spread operator also comes in handy,
if we wish to add elements to an array without causing any mutations:
const arr1 = [1, 2, 3]
const arr2 = arr1.concat(4)
const arr3 = [...arr2, 5]
arr1 // Evaluates to [1, 2, 3]
arr2 // Evaluates to [1, 2, 3, 4]
arr3 // Evaluates to [1, 2, 3, 4, 5]
In conclusion, whenever you are working with an array or an object, ask yourself whether the operation you're performing is destructive or non-destructive! Depending on the situation, you might wish to avoid causing mutations.
Creating Reducers
Reducer functions take two arguments: the current state and and an action. Then, a new, updated state object based on both arguments is returned.
In pseudocode, this looks like this:
(state, action) => newState
This seemingly simple idea is going to allow us to clean up our React components, by removing state calculations from them. Reducers also will unlock the ability to create extremely complex applications, beyond what we would be able to achieve with the State hook alone.
How to Build It
Consider a function in JavaScript that, when passed to an integer, would return that value + 1, without mutating the original integer's value. Notice we could pass our initialState value - 0 - and then return a new value - 1 - without overriding the initialState.
const initialState = 0
const reducer = (state) => {
const newState = state + 1
return newState;
}
const newStateValue = reducer(initialState);
console.log(initialState, newStateValue); // 0, 1
Often, returning something such as an integer or a string is not the best choice, especially as data grows more complex.
Let's consider instead using an object as the data structure of choice for state:
const initialState = { count: 0 }
const reducer = (state) => {
return { count: state.count + 1 }
}
Again, we are returning a new object and are not directly mutating or overriding the
initialState
object.
This reducer function is a pure function without any side effects. Therefore, reducer functions are the perfect fit for managing changes in state in an immutable manner.
We've discussed the nature of the incoming state value, but what about the action?
The action, represented by an object, contains information related to some action that happens in our app.
Every action object is required to have a type
property, which will tell the reducer what's going
on inside the app. Specifically, the type
allows the reducer to know what parts of the state need
to change.
Fleshing out the reducer above a bit more, let's notify the reducer that we want to increment our count state
by passing in an action
with 'increment'
as the type.
const initialState = { count: 0 }
const reducer = (state, action) => {
if (action.type === 'increment') {
return { count: state.count + 1 }
}
}
const nextState = reducer(initialState, { type: 'increment' })
This strategy is especially powerful when we want our reducer to be able to reduce the state. Let's flesh it out a bit more, by adding more cases:
const initialState = { count: 0 }
const reducer = (state, action) => {
if (action.type === 'increment') {
return { count: state.count + 1 }
}
if (action.type === 'decrement') {
return { count: state.count - 1 }
}
}
const state1 = reducer(initialState, { type: 'increment' }) // state1 is {count:1}
const state2 = reducer(state1, { type: 'increment' }) // state2 is {count:2}
const state3 = reducer(state2, { type: 'decrement' }) // state3 is {count:1}
Now our state management is very predictable!
We can also add a payload
property to our action objects (sometimes called data
).
Sometimes our reducer needs to have some extra information passed into it through the action, to be able to
update the state correctly, and this is where that data would live.
const initialState = { name: 'Donald Duck' }
const reducer = (state, action) => {
if (action.type === 'changeName') {
// how do we know what to change the name to? The action payload!
return { name: action.payload }
}
}
const nextState = reducer(initialState, { type: 'changeName', payload: 'Mickey Mouse' });
There's one last edit we need to make to get to production quality. As you can imagine, if
,
if else
, … etc, statements are going to get very complex and long. We'll use JavaScript's
switch
statement to make that part of our reducer a lot more readable:
Back to the count example, look at the change here:
const initialState = { count: 0 }
const reducer = (state, action) => {
switch(action.type) {
case 'increment':
return { count: state.count + 1 }
case 'decrement':
return { count: state.count - 1 }
default: // this is in case the action type is unknown!
return state
}
}
const state1 = reducer(initialState, { type: 'increment' })
const state2 = reducer(state1, { type: 'decrement' })
(Read more about switch statements here)
It's very important that reducers be deterministic: for the same arguments it should always return the same value. We should not mutate state directly nor cause any other side effects. There should be no randomness happening inside the reducer: a reducer is not the place to generate a random id, for example, as the function would not be deterministic anymore. And we must always return state. Do not forget to add a default case to your switch!
In conclusion, reducers will allow us to extract uncomfortable state recalculations out of components, so that components can focus on rendering UI, which is what they do best.
Using the Reducer Hook
The Reducer hook is an alternative to the State hook. It is preferable when you have complex logic that you
have to deal with in a component, or when you find yourself with a lot of state properties in a single
component. useReducer
, takes in a reducer
function (which we build), as well as a
value for the initialState
. Then it returns both the current state and a dispatch function that
allows to modify state, similar to what the State hook does:
const [state, dispatch] = useReducer(reducer, initialState)
The dispatch
function is a significant addition to our arsenal here. It will send an action to
our reducer when specific events occur in our application. The dispatch allows us to leverage the reducer
function from our previous section to maintain our state at the level of the component.
The useReducer
hook has all the functionality we love from the useState
hook and
combines it with the power of the reducers we are building ourselves. In doing so, it provides access to both
the state and a function that dispatch actions to our reducer.
How to Build It
Let's build out a component to go along with our counter reducer. Please pay attention to the comments in the code that will walk us through this process:
import React, { useReducer } from 'react' // import the hook
// decide what the initial state looks like
const initialState = { count: 0, /* other slices of state */ }
// build a reducer
function reducer(state, action) {
switch (action.type) {
case 'INCREASE':
return { ...state, count: state.count + 1 }
case 'DECREASE':
return { ...state, count: state.count - 1 }
// likely there will be many other cases
default: // do not forget your default case
return state
}
}
export default function Counter() {
// use the hook at the top of the component,
// passing it the reducer and the initial state
const [state, dispatch] = useReducer(reducer, initialState)
return (
<>
{/* `state` holds all the slices of state, like `count` */}
<div className="count">Count: {state.count}</div>
{/* `dispatch` alerts the reducer that the state must be recalculated */}
<button onClick={() => dispatch({ type: 'INCREASE' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREASE' })}>-1</button>
</>
)
}
The Reducer hook might be overkill in a component with such simple state, but it's a lifesaver when things start getting complicated. The reducer function may be put in a separate file, for easier maintenance, and to keep the component it powers a bit neater.
Module Project
This project will have you build an Inspirational Quotes app that allows users to view, create and delete quotes. You will use the reducer hook to manage the state of the entire app. This removes state calculations from components and results in cleaner, more maintainable code.
The Module Project contains advanced problems that will challenge and stretch your understanding of the module's content. The solution video is available below in case you need help or want to see how we solved each challenge (note: there is always more than one way to solve a problem). If you can successfully complete all the Module Projects in a sprint, you are ready for the Sprint Challenge and Assessment.
The link below will provide you with a copy of the Module Project on GitHub:
- Starter Repo: The Reducer Pattern
- Fork and clone the code repository to your machine, and
- open the
README.md
file in VSCode, where you will find instructions on completing this Project. - Submit your GitHub url for the updated repository to the Sprint Challenge Submissions tab of your BloomTech portal for grading and review.
Watch if you get stuck or need help getting started.