320H.12 - Redux State, Actions, and Reducers
Learning Objectives
By the end of this lesson, learners will be able to:
- Describe key Redux terms.
- Use the
createAction
function to generate action creator functions. - Use the
prepare
callback within action creators to handlepayload
preparation. - Create reducer functions using the
createReducer
helper. - Describe the benefits of the
createReducer
function for creating reducers. - Describe the relationship between
createAction
,createReducer
, andcreateSlice
. - Use
createSlice
to generate state slices with their own actions and reducers. - Describe the purpose of reducer enhancers.
- Implement basic undo/redo functionality using a reducer enhancer.
CodeSandbox
This lesson uses CodeSandbox as one of its tools.
If you are unfamiliar with CodeSandbox, or need a refresher, please visit our reference page on CodeSandbox for instructions on:
- Creating an Account
- Making a Sandbox
- Navigating your Sandbox
- Submitting a link to your Sandbox to Canvas
Expanded Redux Toolkit Functionality
Redux Toolkit comes with many, many tools to make state management more intuitive, less verbose, and more maintainable. During the last lesson, we looked at the createSlice
API and some of the other functionality it exposes for us, like automatically created action creators.
Before we dive any deeper into the expanded functionality of each of these terms, let's briefly review the key concepts behind state management in a React Redux application. Almost all of these core terms become more manageable with the Redux Toolkit; we will expand upon their usage within RTK as we continue.
Actions
An action is a plain JavaScript object that has a type
field. You can think of an action as an event that describes something that happened in the application.
The type
field should be a string that gives this action a descriptive name, like "todos/todoAdded
." We usually write that type
string like "domain/eventName
," where the first part is the feature or category that this action belongs to, and the second part is the specific thing that happened.
An action object can have other fields with additional information about what happened. By convention, we put that information in a field called payload
.
A typical action object might look like this:
const addTodoAction = {
type: 'todos/todoAdded',
payload: 'Buy Milk'
}
Action Creators
An action creator is a function that creates and returns an action object. We typically use these so we don't have to write the action object by hand every time:
const addTodo = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}
Reducers
A reducer is a function that receives the current state and an action object, decides how to update the state if necessary, and returns the new state: (state, action) => newState
. You can think of a reducer as an event listener which handles events based on the received action (event) type.
const initialState = { value: 0 }
function counterReducer(state = initialState, action) {
// Check to see if the reducer cares about this action
if (action.type === 'counter/increment') {
// If so, make a copy of `state`
return {
...state,
// and update the copy with the new value
value: state.value + 1
}
}
// otherwise return the existing state unchanged
return state
}
Store
The current Redux application state lives in an object called the store.
The store is created by passing in a reducer, and has a method called getState
that returns the current state value:
import { configureStore } from '@reduxjs/toolkit'
const store = configureStore({ reducer: counterReducer })
console.log(store.getState())
// {value: 0}
Dispatch
The Redux store has a method called dispatch
. The only way to update the state is to call store.dispatch()
and pass in an action object. The store will run its reducer function and save the new state value inside, and we can call getState()
to retrieve the updated value:
store.dispatch({ type: 'counter/increment' })
console.log(store.getState())
// {value: 1}
We typically call action creators to dispatch the correct action consistently:
const increment = () => {
return {
type: 'counter/increment'
}
}
store.dispatch(increment())
console.log(store.getState())
// {value: 2}
Selectors
Selectors are functions that know how to extract specific pieces of information from a store state value. As an application grows bigger, this can help avoid repeating logic as different parts of the app need to read the same data:
const selectCounterValue = state => state.value
const currentValue = selectCounterValue(store.getState())
console.log(currentValue)
// 2
Application State
Now that we understand some of the terminology behind Redux, let's dive deeper into the core concepts behind these terms.
In order to understand the core concepts of Redux, we will use a plain-JavaScript example of a todo list, borrowed from the Redux documentation. Take the following state object:
{
todos: [{
text: 'Eat food',
completed: true
}, {
text: 'Exercise',
completed: false
}],
visibilityFilter: 'SHOW_COMPLETED'
}
This object has no setter methods for changing its values (mutating the state), which provides protection against arbitrary changes to its values within the code. To change something within the state, you would need to dispatch an action.
RTK APIs: createAction
In core Redux (without RTK), we would write action creator functions by hand, which creates a lot of boilerplate. Imagine writing creator functions for every action in a very large application.
Even a small creator function can take up a lot of real estate:
const INCREMENT = 'counter/increment'
function increment(amount) {
return {
type: INCREMENT,
payload: amount,
}
}
const action = increment(3)
// { type: 'counter/increment', payload: 3 }
While createSlice
will provide action creators for us automatically based on our reducers, there is sometimes a need for a manually-defined action creator.
The createAction
helper function makes this process simple:
import { createAction } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
This has a few added benefits:
- The argument provided to the action creator will automatically be put into the
payload
field of the action. - The action creator has a
toString()
method that will provide you with the string representation of the action, if needed.
console.log(increment.toString())
// 'counter/increment'
There is still a small issue with this approach, however. With a simple counter example, we can easily infer what we should pass to our action creator when we dispatch the action (a number), but what about much more complicated structures?
If we have a todo list, for example, and want each item to have a unique ID or a timestamp with the time of posting, how would we handle this? Would we need to know that ahead of time for each action dispatch, and format our payload
argument perfectly each time?
const addTodo = createAction('todos/add');
console.log(addTodo({
id: nanoid(),
createdAt: new Date().toISOString(),
'Master RTK'
}))
/**
* {
* type: 'todos/add',
* payload: {
* text: 'Master RTK',
* id: '4AJvwMSWEHCchcWYga3dj',
* createdAt: '2023-05-03T07:53:36.581Z'
* }
* }
**/
If this seems inefficient, that's because it is.
As an answer to this, Redux Toolkit provides prepare
functions. As the second argument to createAction
, the prepare
callback function is used to prepare the payload
so that the user calling the action creator does not need to worry about its structure.
prepare
takes any number of arguments, and returns a payload
object that will be inserted into the action
object created by the action creator.
Here's how that changes the example above:
import { createAction, nanoid } from '@reduxjs/toolkit'
const addTodo = createAction('todos/add', function prepare(text) {
return {
payload: {
id: nanoid(),
createdAt: new Date().toISOString(),
text
},
}
})
console.log(addTodo('Master RTK'))
/**
* {
* type: 'todos/add',
* payload: {
* text: 'Master RTK',
* id: '4AJvwMSWEHCchcWYga3dj',
* createdAt: '2023-05-03T07:53:36.581Z'
* }
* }
**/
While defining the action creator has become more verbose, every call to addTodo
that follows will be much simpler and less prone to error. When considering a large application at scale, this will save a lot of time and effort.
Your prepare
function can also provide meta
and/or error
fields alonside payload
that may contain extra information about the action or details about the action failute.
Other Action Types
Redux allows you to use any kind of value as an action type. You don't have to use strings - they can be numbers, symbols, or anything else (though they should at least be serializable). That being said, Redux Toolkit assumes you are using strings for your action types, and some of its features rely on that assuption.
It is strongly recommended that you only use string action types.
RTK APIs: createReducer
A traditional, hand-written reducer function can be quite prone to error, and often contain a lot of boilerplate code.
Here's what reducer functions you may have written in the past might look like:
const initialState = { value: 0 }
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'increment':
return { ...state, value: state.value + 1 }
case 'decrement':
return { ...state, value: state.value - 1 }
case 'incrementByAmount':
return { ...state, value: state.value + action.payload }
default:
return state
}
}
The createReducer
helper function makes the implementation of reducer functions more robust in a number of ways (not the least of which is enabling Immer for the use of mutable logic inside of the reducers).
createReducer
takes the initial state as its first argument, and a builder callback function as the second. The builder
object provided to this callback function provides the addCase
, addMatcher
, and addDefaultCase
functions that can define which actions the reducer will handle.
addCase
calls must come before addMatcher
or addDefaultCase
calls. addCase
takes two arguments:
actionCreator
, which is either a plain action type string or an action creator generated bycreateAction
that can be used to determine the action type.- The actual reducer function.
addMatcher
allows you to match against your own filter functions, rather than relying solely on action.type
for matching. If multiple reducers match an action, all of them will be executed in the order they were defined in (you can think of this as "and if" logic). addMatcher
takes two arguments:
- The
matcher
function that accepts theaction
as a parameter and returns true or false, depending on the matching logic within. - The actual reducer function.
addDefaultCase
must be the last call, and is only executed if no other reducer was executed for the given action.
Here's how the above counter example would change using createReducer
:
import { createAction, createReducer } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
const decrement = createAction('counter/decrement')
const incrementByAmount = createAction('counter/incrementByAmount')
const initialState = { value: 0 }
const counterReducer = createReducer(initialState, (builder) => {
builder
.addCase(increment, (state, action) => {
state.value++
})
.addCase(decrement, (state, action) => {
state.value--
})
.addCase(incrementByAmount, (state, action) => {
state.value += action.payload
})
})
Take note of a few important features:
- The actions created by
createAction
can be used directly within theaddCase
calls, thanks to theirtoString()
methods which automatically get called. This ensures consistency between the actions and their associated reducers, since you don't need to type the actiontype
twice (or store it in an extraneous variable). - The state modifications now use mutating logic, because
createReducer
includes Immer under the hood.
Here's another example, which illustrates the behavior of addMatcher
:
import { createReducer } from '@reduxjs/toolkit'
const reducer = createReducer(0, (builder) => {
builder
.addCase('increment', (state) => state + 1)
.addMatcher(
(action) => action.type.startsWith('i'), // the matching function
(state) => state * 5 // the reducer function
)
.addMatcher(
(action) => action.type.endsWith('t'),
(state) => state + 2
)
})
console.log(reducer(0, { type: 'increment' }))
// Returns 7, as the 'increment' case and both matchers all ran in sequence:
// - case 'increment": 0 => 1
// - matcher starts with 'i': 1 => 5
// - matcher ends with 't': 5 => 7
The current Utility
It is useful during development to console.log
many, many things. One of the issues with logging state values within a reducer function, however, is that the proxy returned by Immer within createReducer
is difficult for browsers to read, often resulting in incorrect or nonexistant logs.
The current
utility from Immer creates a separate plain copy of the current draft state within reducers, allowing you to log it for viewing when desired. The Redux Toolkit re-exports this utility function, allowing you to access it easily.
import { createAction, createReducer, current } from '@reduxjs/toolkit'
...
const counterReducer = createReducer(initialState, (builder) => {
builder
.addCase(increment, (state, action) => {
console.log(`State Before: ${current(state)}`);
state.value++;
console.log(`State After: ${current(state)}`);
})
...
})
RTK APIs: createSlice
During the last lesson, we discussed the magic of createSlice
. Now, it's no longer magic, as you know exactly how it works.
createSlice
uses createAction
and createReducer
under the hood in order to provide all of the functionality of the two in a single comprehensive call.
createSlice
requires a single configuration object with the properties name
, initialState
, and reducers
:
name
should be a descriptive string, as it will be used in action types as the prefix.initialState
is a value of any type, setting the initial state for the reducer.reducers
is an object of "case reducers". Key names for each reducer should be descriptive, as they will be used to generate actions.
Based on the above, the following code will handle the action with type
'counter/increment':
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
},
})
This is important to recognize, because if any other part of the application dispatches an action with the exact same type string, this reducer will be run. Be careful working with slices of state that have similar names and/or actions!
Since createSlice
uses both createAction
and createReducer
, we can make use of all of the properties we previously discussed with those APIs. Your reducers can include a prepare
callback function for implementing custom payload preparation logic.
Here's the todo list example from earlier, written with createSlice
:
import { createSlice, nanoid } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: {
reducer: (state, action) => {
state.push(action.payload)
},
prepare: (text) => {
const id = nanoid()
const createdAt = new Date()
return { payload: { id, createdAt, text } }
},
},
},
})
createSlice
returns an object that looks like this:
{
name : string,
reducer : ReducerFunction,
actions : Record<string, ActionCreator>,
caseReducers: Record<string, CaseReducer>.
getInitialState: () => State
}
Every function within the reducers
field has a cooresponding action creator that is included in this object's actions
field, using the same name as the function. This means that { addTodo } = todosSlice.actions
will give us access to the addTodo
action creator in the example above. It is extremely common to destructure these action creators and export them individually, as it makes searching for references much easier in large codebases.
The reducer
field contains the reducer function that should be passed into the Redux store. When multiple slice reducer functions are involved in an application (as is almost always the case), they can be passed into combineReducers
before being passed into the Redux store configuration:
import { createStore, combineReducers } from 'redux'
const reducer = combineReducers({
counter: counter.reducer,
user: user.reducer,
})
const store = createStore(reducer)
When using configureStore
from Redux Toolkit, combineReducers
is automatically called on the reducer object passed in. We will discuss configureStore
and other Redux store tools in the next lesson.
The functions passed to reducers
can be directly accessed through the caseReducers
field, which is typically used for testing purposes.
extraReducers
There is also one more field that createSlice
looks for in its configuration object: extraReducers
. The extraReducers
field allows state slices to respond to actions that they did not generate. While each slice reducer "owns" its state slice, they should still be able to independantly respond to a given action type when desired.
Reducers specified with extraReducers
do not create associated actions within the .actions
field, because they are supposed to be external actions, not ones generated by the slice in question.
Reducers created within extraReducers
also adhere to a different declaration style - the builder
syntax:
import { createAction, createSlice } from '@reduxjs/toolkit'
const incrementBy = createAction('incrementBy')
const decrement = createAction('decrement')
function isRejectedAction(action) {
return action.type.endsWith('rejected')
}
createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(incrementBy, (state, action) => state.value += action.payload )
.addCase(decrement, (state, action) => state.value--)
.addMatcher(
isRejectedAction,
(state, action) => {}
)
.addDefaultCase((state, action) => {})
},
})
Reducer Enhancers
A reducer enhancer is a function that takes a reducer and returns a new reducer that is able to process new actions or hold more state. These are also referred to as "higher order reducers" (you may be familiar with higher order functions or higher order components).
For all actions the enhancer doesn't know how to process, it defaults control back to the original reducer.
Depending on what you are seeking to accomplish with a reducer enhancer, the way the enhancer looks could vary wildly. That being said, here is a very generic skeleton of an enhancer:
function enhance(reducer) {
// Determine initial state by calling the original reducer with an empty action.
const initialState = reducer(undefined, {});
// Return a new reducer that does something new.
return function(state = initialState, action) {
switch (action.type) {
case 'something': // handle a new action
return // something
default: // give control back to the original reducer
return reducer(state, action)
}
}
}
There is a lot more to writing good enhancers for specific use cases, but you are already familiar with one: combineReducers
.
combineReducers
is just a reducer enhancer that takes multiple reducers and returns a single, new reducer. Here's how that might look:
function combineReducers(reducers) {
return function (state = {}, action) {
return Object.keys(reducers).reduce((nextState, key) => {
// Call every reducer with the part of the state it manages
nextState[key] = reducers[key](state[key], action)
return nextState
}, {})
}
}
Another common application for reducer enhancers is implementing undo/redo functionality. Undo and redo are simply snapshots of previous and next states, so we can store that state in state to keep track of it, and then move forward or backward in time with the click of a button.
Like most common functionality, there is a prepackaged solution available that gives us an undoable()
reducer enhancer. At this stage, however, it is very beneficial for you to inspect the source behind these functions and analyze how they work.
Practice Activity: Implementing Undo and Redo
Read through the section of the Redux documentation on writing a reducer enhancer for undoable functionality, from "Second Attempt: Writing a Reducer Enhancer" to the end of the article.
Once you have finished reading, fork the CodeSandbox below which contains the counter example from previous lessons, and implement both an undo and redo button using what you have learned (you don't need to write undoable
from scratch, just implement it in this application using the tools provided in the documentation)!
If you get stuck during this activity, discuss blockers with your peers and instructors.
Afterwards, we'll take a look as a class at how we implemented this functionality (don't peek early; you won't do yourselves any favors)! Note that there are different viable approaches to this, but all of our work was done in Counter.js
and counterSilce.js
.
A React + Redux Application: Part Two
Now that you have a deeper understanding of the hows and whys of createSlice
and its companions, continue working through part two of GLAB 320.11.1 - Redux State Management. Save the remaining parts for after future lessons.