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 handle payload preparation.
  • Create reducer functions using the createReducer helper.
  • Describe the benefits of the createReducer function for creating reducers.
  • Describe the relationship between createAction, createReducer, and createSlice.
  • 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 by createAction 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 the action 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 the addCase calls, thanks to their toString() methods which automatically get called. This ensures consistency between the actions and their associated reducers, since you don't need to type the action type 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.

Copyright © Per Scholas 2024