Web Unit 3 Sprint 10 - Redux & State Management

Module 3: Redux Toolkit

In this module, you'll learn about Redux Toolkit, a powerful library that simplifies Redux development. You'll explore how to create slices, handle async actions with Redux Thunk, and implement middleware in your Redux store.

Learning Objectives

  • Explain what Redux is and the problem it solves
  • Create a Redux store and connect it to a React application
  • Write actions and action creators to describe state changes
  • Write reducers to respond to actions and update state
  • Effectively use Redux Thunk and asynchronous action creators

Guided Project

Resources

Starter Repo: Redux Toolkit

Solution Repo: Redux Toolkit Solution

Understanding Redux

In a web application, 'state' refers to the dynamic elements such as the URL, user inputs, and interactive features. This is akin to the changing conditions of a car, like speed or fuel level. In Redux, state is treated as a single source of truth, representing the entire state of the application at any given moment.

Front-end developers often grapple with how to manage state effectively. Choices include storing state within the DOM, building a singular large object for the entire application's state, or allowing the URL to influence state. These decisions impact how easily state can be tracked, manipulated, and maintained.

In response to these challenges, the front-end community has created numerous libraries and frameworks. Each offers different approaches to state management, addressing various needs and complexities of web applications. Redux emerged as a popular choice due to its simplicity and predictability.

How to Build It

Redux is a library that centralizes the application's state, making it more predictable and easier to manage. It's commonly used in complex applications for consistent state management, but it's flexible enough to manage only parts of the state, as needed.

Redux's centralized approach provides a clear overview of the application's state at any point in time. It offers powerful development tools, like state time-travel, and ensures a clean separation between state management logic and UI components. Redux Toolkit, an official Redux package, further simplifies Redux development by providing useful utilities.

Redux's widespread adoption is evidenced by its massive download numbers and active community. Its ongoing maintenance by passionate developers ensures that it stays relevant and efficient in managing modern web application states.

Redux's architecture revolves around a single, immutable state object. The state is never mutated directly; instead, actions are dispatched to signify state changes. Reducers, which are pure functions, then take the current state and an action to calculate a new state. This approach guarantees predictability and ease of debugging.

Actions in Redux are plain JavaScript objects describing what happened, and reducers are functions that determine how the state changes in response to these actions. This pattern makes it straightforward to trace, test, and maintain state changes across the application.

Redux encourages a design where components are 'dumb' regarding state management. They dispatch actions and may read state but are not involved in state mutation. This separation of concerns leads to more maintainable and scalable codebases.

Redux is a robust framework for state management, building on the reducer pattern to ensure a predictable application state. Redux Toolkit further enhances Redux by offering utilities like createSlice and createAsyncThunk, which simplify common Redux patterns and reduce boilerplate. Overall, Redux and Redux Toolkit provide a structured approach to managing state in complex web applications.

Creating a Redux App

In order to use Redux in a React project, you will install two NPM packages: Redux Toolkit and React Redux. You may also bootstrap a React app that already includes these dependencies by running npx @bloomtools/react my-first-redux-app in your terminal.

In this Objective, you will learn to get Redux up and running in your application. The state will be very simple: Redux will be tracking the state of a counter. But once you have a little bit of state working, it's very easy to add some more.

Configuring Redux will involve four different aspects:

  1. Creating a slice of application state, and defining the state changing functions.
  2. Building a Redux store, which is the place where all slices are combined.
  3. Wrapping the component tree with a Provider, which uses Context to "teleport" application state to any component.
  4. Displaying and updating state from any component in the tree.

Let's get into it!

How to Build It

1- Create a Slice of Application State

In the project's front-end folder, a new folder called 'state' and a file 'slice.js' are created. This file holds a part of the application state, with potential for multiple slices in more complex applications. The slice is defined using the 'createSlice' function from Redux Toolkit. It includes a name ('count_state'), an initial state object (e.g., { count: 0 }), and reducers with methods like 'increment' to update the state. The 'slice.reducer' and actions like 'increment' are exported for use in components, allowing for state changes when actions are emitted:


// frontend/state/slice.js
import { createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
  name: 'count_state',
  initialState: { count: 0 }, // we can add many more properties besides 'count'
  reducers: {
    increment(state) {
      // this is like a branch in the switches you wrote in The Reducer Pattern
      return { ...state, count: state.count + 1 }
    },
  }
})

export default slice.reducer // there can be other reducers in the app besides this one

export const { increment } = slice.actions // components will use these functions
                

Redux Toolkit includes a library called Immer> which allows you to write seemingly mutable logic in your reducers, which is then translated into immutable updates under the hood. For example, it allows you to rewrite your 'increment' function like this:


increment(state) {
    // thanks to the magic of Immer:
    state.count++ // mutate freely, and don't return!
},
                

Redux Toolkit addresses in this way the concerns of many developers regarding the verbosity of 'classic' reducers, without actually changing the way reducers work under the hood.

2- Build a Redux Store

A single store for the entire application state is created in 'store.js'. It is configured with the 'configureStore' function from Redux Toolkit, incorporating the created slice(s):


// frontend/state/store.js
import { configureStore } from '@reduxjs/toolkit'
import reducer from './slice'

export const store = configureStore({
  reducer: {
    counters: reducer,
    // other reducers would go here
  }
})
                

In this way, Redux combines all the slices of state in the application into one big reducer.

3- Wrap the Component Tree with a Provider

The React application is wrapped with a Provider from React Redux, which is provided with the created store to enable state access across all components:


// frontend/index.js
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './components/App'
import { Provider } from 'react-redux'
import { store } from './state/store'

const root = createRoot(document.getElementById('root'))

root.render(
  <Provider store={store}>
    <App />
  </Provider>
)
                

Redux is using the Context API under the hood, to 'teleport' props to any component in the tree, no matter how deeply nested. You only need to do this step once per application. You can wrap the whole app like you see here, or just a part of the component tree.

4- Display and Update State in Components

Components, like the App component, use the 'useSelector' and 'useDispatch' hooks from React Redux to access and update state. 'useSelector' retrieves specific state data (e.g., the count), and 'useDispatch' allows dispatching actions like increment:


// frontend/components/App.js
import React from 'react'
import {
  useSelector, // allows to select pieces of state
  useDispatch, // allows to dispatch actions to the reducers
} from 'react-redux'
import { increment } from '../state/slice' // the action

export default function App() {
  const count = useSelector(st => st.counters.count)
  const dispatch = useDispatch()
  return (
    <div>
      <button onClick={() => {
        const action = increment()
        dispatch(action) // the reducer will detect this action
      }}>The count is {count}</button>
    </div>
  )
}
                

If you install the Redux Dev Tools extension for Chrome, you should be able to see the state of your app and the actions dispatched, when you click the increment button!

Working with Reducers and Actions

With the scaffolding of Redux in your React app out of the way, in this Objective we will take a closer look at writing more complex reducers and dispatching actions from components. We will also discuss the difference between an action object and an action creator. Along the way, you will discover the power of selectors.

Let's get into it!

How to Build It

Introduction

Starting from our basic counter-tracking React app, let's enhance it by adding a new state slice. This will track the current day of the week. While we could use strings like 'Monday', 'Tuesday', etc., using numeric representations (0 for Monday, 1 for Tuesday, and so on) offers greater flexibility for operations:


// frontend/state/slice.js
import { createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
  name: 'count_state',
  initialState: {
    count: 0,
    day: 0, // ❗ the week will start on Monday
  },
  reducers: {
    increment(state) { state.count++ }
  }
})

export default slice.reducer
export const { increment } = slice.actions
                

Display Issue and Selectors

When integrating this state into our UI, we encounter a user experience problem: displaying numeric day values isn't great:


// frontend/components/App.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { increment } from '../state/slice'

export default function App() {
  const count = useSelector(st => st.counters.count)
  const day = useSelector(st => st.counters.day) // ❗ getting the day as is...
  const dispatch = useDispatch()
  return (
    <div>
      <button onClick={() => dispatch(increment())}>
        The count is {count}
      </button>

      <button>
        The day of the week is {day} {/* ❗ this shows 0 instead of Monday! */}
      </button>
    </div>
  )
}
                

This is where Redux selectors shine! They allow us to transform this raw state into a user-friendly format. Below, we map our numeric day to its corresponding string name, effortlessly bridging our state representation with user expectations. Just return from your selector function what you want your derived state to look like:


const day = useSelector(st => {
    const days = [
        'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'
    ]
    return days[st.counters.day]
    })
                

This is more like it! Another nice thing about selectors is that you can keep collections of them in their own modules, and then import them wherever they're needed.

Reducer Enhancement

Next, we'll enhance our reducer to allow cycling through the days. After Sunday, our logic smartly loops back to Monday, reflecting a typical week cycle. This is a great example of how reducers can centralize state logic so that components can remain blissfully ignorant of if:


// frontend/state/slice.js
import { createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
  name: 'count_state',
  initialState: { count: 0, day: 0 },
  reducers: {
    increment(state) { state.count++ },
    nextDay(state) {
      // ❗ make sure to go back to Monday after Sunday!
      const isSunday = state.day === 6
      state.day = isSunday ? 0 : state.day + 1
    }
  }
})

export default slice.reducer
// ❗ don't forget to export out your `nextDay` action creator
export const { increment, nextDay } = slice.actions
                

Reducer vs. Action Creator

It's important to note the distinction between the reducer function and the action creator in Redux. While the reducer (nextDay) updates the state, the action creator (nextDay()) returns a plain action object.


// ❗ note: this block is just pseudo-code
nextDay() // returns an action object { type: "count_state/nextDay", payload: undefined }
dispatch(nextDay()) // this is literally dispatching an action to the nextDay reducer
                

The Redux Toolkit conveniently generates these action creators for us, simplifying our workflow.

Component Integration

Finally, integrating this into our component, we connect the Redux state to our UI. The useDispatch hook triggers our nextDay action, resulting in a seamless user experience where clicking a button updates the day. This demonstrates the power of Redux in managing and connecting state to our React components.


// frontend/components/App.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { increment, nextDay } from '../state/slice' // ❗ import your action creator

export default function App() {
    const count = useSelector(st => st.counters.count)
    const day = useSelector(st => {
    const days = [
        'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'
    ]
    return days[st.counters.day]
    })
    const dispatch = useDispatch()
    return (
    <div>
        <button onClick={() => dispatch(increment())}>
        The count is {count}
        </button>

        <button onClick={() => dispatch(nextDay())}>  {/* ❗ dispatching a nextDay action */}
        The day of the week is {day}
        </button>
    </div>
    )
}
                

Dispatching Actions

This section provides a comprehensive guide on dispatching actions within a React application, focusing on the Redux Toolkit framework. We will cover essential aspects such as actions and payloads. To do this, we will expand on the the basic 'counter' Redux app from Objective 2, and implement a new feature to track savings, as well as to save and spend money!

How to Build It

Initialization of State in Slice

We will add a new state property, 'savings', in the Redux slice. The initial value of 'savings' will be set at $10. To go along with this new state, reducers will be needed to handle adding money to savings, and spending money from savings. Finally, we will expose to other modules the action creator functions that will trigger their corresponding reducers:


// frontend/state/slice.js
import { createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
  name: 'count_state', // ❗ feel free to use a better name
  initialState: { savings: 10, /* other states */ },
  reducers: {
    save(state, action) { /* TODO */ },
    spend(state, action) { /* TODO */ },
    // other reducers
  }
})

export default slice.reducer
export const { save, spend, /* other actions */ } = slice.actions
                

You might notice that the 'spend' and 'save' reducers are accepting a second argument we haven't used so far, but which should be familiar from The Reducer Pattern: the action object. The action is important if the action 'type' on its own is not enough for the reducer to know how exactly to compute the next state. How much are we spending? How much are we saving? That kind of information will come included inside the action object.

Dispatching Spend and Save Actions

From the side of the component that displays our savings and allows us to save and spend (hopefully more of the former):


// frontend/components/App.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { save, spend } from '../state/slice'

export default function App() {
  const savings = useSelector(st => st.counters.savings) // ❗ assuming a 'counters' slice
  const dispatch = useDispatch()
  return (
    <div>
      <h2>My savings are at ${savings}</h2>

      <button onClick={() => {
        const saveAction = save(10) // ❗ note the 10
        dispatch(saveAction)
      }}>Save $10</button>

      <button onClick={() => {
        const spendAction = spend(5)  // ❗ note the 5
        dispatch(spendAction)
      }}>Spend $5</button>
    </div>
  )
}
                

It makes sense that we supply the amount we wish to save (or spend) as the argument to each action creator. But where does this amount end up? To try and answer that question, we can console log 'saveAction' and 'spendAction':


console.log(saveAction) // prints { type: 'count_state/save', payload: 10 }
// etc
console.log(spendAction) // prints { type: 'count_state/spend', payload: 5 }
                

Mystery solved! Whatever data we pass as the argument to the action creator ends up as action.payload inside the action object. No wonder these functions are called action creators! And unlike with The Reducer Pattern, these functions are provided to us by Redux Toolkit so we don't have to write them manually, nor juggle the typo-prone action type strings.

Implementing Save and Spend Reducers

Reducers never sleep! They are always waiting for an action to be dispatched by a component. If we use the save and spend buttons which we have implemented in the App component, this is how it will play out:


// frontend/state/slice.js
import { createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
  name: 'count_state',
  initialState: { savings: 10, /* other states */ },
  reducers: {
    save(state, action) { // ❗ the action payload contains 10
      state.savings += action.payload
    },
    spend(state, action) { // ❗ the action payload contains 5
      if (state.savings > action.payload) {
        state.savings -= action.payload
      } else {
        // ❗ we don't want to end up with negative savings!
        state.savings = 0
      }
    },
    // other reducers
  }
})

export default slice.reducer
export const { save, spend, /* other actions */ } = slice.actions
                

Having closed the development loop, the spend and save buttons work as expected! We can inspect the actions and payloads in detail, using Redux Dev Tools. We can even time-travel to past states, by clicking on a past action!

Managing Complex State

In this Objective we will learn to write more complicated reducers - of the type you will encounter whenever you need to track lists of objects and support 'create', 'update' and 'delete' operations on them.

How to Build It

Introduction

Let's imagine our Redux app must support the following features:

  1. Should display friend names (and a little heart if the friend is one of the favorite ones).
  2. Allow to delete a friend.
  3. Allow to toggle the favorite status of a friend.
  4. Allow to add a new friend by entering the name into the input box.

Building the Slice

Getting to work on the 'friends' slice, this is how things might look like:


import { createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
  name: 'friends',
  initialState: {
    list: [ // ❗ note the unique ID on each friend
      { id: 'ldo', name: 'Pam', fav: true },
      { id: '1sb', name: 'Jess', fav: false },
      { id: 'xu7', name: 'Ana', fav: false },
    ],
  },
  reducers: {
    deleteFriend(state, action) { /* TODO */ },
    favFriend(state, action) { /* TODO */ },
    createFriend(state, action) {  /* TODO */ }
  }
})

export default slice.reducer

export const {
  deleteFriend, createFriend, favFriend,
} = slice.actions
                

Writing Reducers for Delete and Update Operations

The 'deleteFriend' and 'favFriend' reducers are straightforward and similar to other reducers and state updaters you have written in the past, except that now we have Immer to make the work much easier:


deleteFriend(state, action) {
    // ❗ the ID to delete comes inside action.payload
    state.list = state.list
        .filter(fr => fr.id !== action.payload)
    },
    favFriend(state, action) {
    // ❗ the ID to update comes inside action.payload
    const fr = state.list
        .find(fr => fr.id === action.payload)
    fr.fav = !fr.fav
    },
                

Problems Creating a New Friend

Creating a new friend has an unexpected complication. The component, when the user clicks on the 'create' button, can provide the desired name, but a presentational component should not be expected to 'know' that new friends have a 'fav' status of false, or be forced to generate a random ID.

On the other hand, generating a random ID also cannot be the job the 'createFriend' reducer. Reducers are supposed to be deterministic functions! No randomness is allowed inside of them.

Luckily, we can create a 'prepare' function that takes care of processing the argument sent by the component when it calls 'createFriend' (the desired name), and putting together a new payload for the reducer:


import { createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
  name: 'friends',
  initialState: {
    list: [
      { id: 'ldo', name: 'Pam', fav: true }, /* etc */
    ],
  },
  reducers: {
    createFriend: { // ❗ we change this to be an object
      prepare(name) { // ❗ the component supplies the desired name
        const newFriend = {
          name,
          fav: false,
          // ❗ random ID generation:
          id: Math.random().toString(36).slice(2, 5),
        }
        return { payload: newFriend } // ❗ payload for the reducer
      },
      reducer(state, action) {
        state.list.push(action.payload)
      }
    },
    deleteFriend(state, action) { /* etc */ },
    favFriend(state, action) { /* etc */ },
  }
})

export default slice.reducer

export const {
  deleteFriend, createFriend, favFriend,
} = slice.actions
                

Friend Creation Fixed!

The component that calls 'createFriend' passing a string will cause the 'prepare' function to run and put together a proper friend object, which is then taken by the reducer from inside action.payload. Here is the component that dispatches the creation of a new friend:


import React, { useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { createFriend, deleteFriend, favFriend } from '../state/slice'

export default function App() {
  const friends = useSelector(st => st.friends.list)
  const dispatch = useDispatch()
  const ref = useRef() // ❗ used to grab the input value from the DOM
  return (
    <div>
      <input ref={ref}></input>
      <button onClick={() => {
        dispatch(createFriend(ref.current.value))
        ref.current.value = '' // ❗ resetting the input
      }}>create</button>
      <ul>
        {friends.map(fr => (
          <li key={fr.id}>
            {fr.name}
            <button onClick={() => dispatch(favFriend(fr.id))}>fav</button>
            <button onClick={() => dispatch(deleteFriend(fr.id))}>del</button>
            {fr.fav && ' ❤️'}
          </li>
        ))}
      </ul>
    </div>
  )
}
                

And this concludes your Redux objectives! Make sure to check out your Guided Project and your Module Project.

Module Project

For this project you will revisit the Inspirational Quotes app, which allows users to view, create and delete quotes. This time you will use Redux Toolkit to manage the global state of the app. This removes state calculations from components, and scales well to very large React applications.

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: Redux Toolkit
  • 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.