Simplify your React Context reducer with Redux Toolkit's createReducer

jacobwicks

jacobwicks

Posted on August 17, 2020

Simplify your React Context reducer with Redux Toolkit's createReducer

Example App running on GitHub Pages

GitHub Repo for Example Code

You can Turn this:                                                                      Into this:
Big Switch Statement

I have been using React Context to manage state for my React projects for a while now. The heart of React Context's state management is the reducer, the function that processes actions and returns the new state object. I had been using a switch statement to make the reducer function work. But I found that with a switch statement the files for more complex Contexts were getting too big. The switch statement got bigger and bigger as I added cases to handle all my actions, and my test file for the Context component also got big. So for my latest project I decided to use Redux Toolkit's createReducer function.

What is createReducer?

createReducer is a function that takes all your cases and their individual reducers and creates the main reducer function that you want. Redux Toolkit has a nice createReducer function, and it even works well with TypeScript. Redux Toolkit also comes with the createAction function, which has some nice organizational benefits.

Why use createReducer?

When you use createReducer to make your context reducer function

  • reducer function is smaller
  • actions are self contained, making testing easy
  • uses Immer library- optional automatic nested state
  • createAction function
  • reference to the action creator function can also be used as the key value instead of using a separate string

Example App

I created an example app (linked here) that uses React Context to display pages with lists of questions.

This example app uses createReducer to manage 3 actions

  • addPage adds a new page object to the context
  • deletePage deletes the current page from the context
  • setCurrentPage sets the current page in the context

The context manages an array of Page objects. Each Page has two properties. Each Page has a property number, which is a number. The number is used to identify pages. Each Page has a property questions, which is an array of strings.

Example App Page Objects and the State Object

export type Page = {
  //the number of the page
  number: number;

  //the questions that are on the page
  questions: string[];
};

export type PagesState = {
current?: number;
pages: Page[];
dispatch: React.Dispatch<PagesAction>;
};

View on GitHub

Install Redux Toolkit

To use createReducer and createAction you need to install Redux Toolkit.

$ npm install @reduxjs/toolkit

createReducer

Here's how you set up the context reducer using createReducer.
The example app has three actions. Each of the three actions exports an actionCreator function and a reducer function.

Call createReducer

export const reducer: Reducer<
  PagesState,
  PagesAction
> = createReducer(initialState, (builder) =>
  builder
    .addCase(addPage, addPageReducer)
    .addCase(deletePage, deletePageReducer)
    .addCase(setCurrentPage, setCurrentPageReducer)
);

View on GitHub

Each call to addCase adds a case reducer to handle a single action type. The first argument is normally a string. But when you use createAction to make your action creators, you can use a reference to the action creator instead of a string. The action creators used here (addPage, deletePage, setCurrentPage) are exported from the action files.

Each Action is Self Contained in its Own File

Here's how to structure the action files. Each action file exports the action type, the reducer function, and the action creator function.

Action with no payload:

import { PagesState } from "../../";
import { PagesActionTypes } from "..";
import { createAction } from "@reduxjs/toolkit";

export type deletePage = {
  type: PagesActionTypes.deletePage;
};

const action = createAction(PagesActionTypes.deletePage);

export const reducer = (state: PagesState) => {
  state.pages = state.pages.filter((p) => p.number !== state.current);
  state.current = undefined;
};

export default action;

View on GitHub

Here we create the action creator by calling createAction. Because there is no payload, you just call createAction with the action type as an argument. The action creator returned by createAction will be correctly typed because createAction reads the action type that you give it.

The reducer function will get called with (state, action). But this reducer doesn't use the action object, so we can leave it out.

Redux Toolkit's createReducer function uses the Immer library. Immer lets you use simplified reducers. Write code that mutates the state directly and createReducer will use Immer to make sure that a new state object is return. Your code is shorter and it gets rid of the chance to make mistakes when creating your nested state return object.

Action with primitive payload

This one uses a number.

import { PagesState } from "../../";
import { PagesActionTypes } from "..";
import { createAction } from "@reduxjs/toolkit";

export type setCurrentPage = {
  type: PagesActionTypes.setCurrentPage;
  payload: number;
};

const action = createAction<number, PagesActionTypes.setCurrentPage>(
  PagesActionTypes.setCurrentPage
);

export const reducer = (
  state: PagesState,
  { payload }: { payload: number }
) => {
  state.current = payload;
};

export default action;

View on GitHub

You need to define the type of the payload that the action takes in the action type.

Type the payload required by your action creator by providing the payload type as the first type parameter, and the action type as the second type parameter in the call to createAction.

The reducer is called with (state, action). Use object destructuring to get the payload out of the action.

Again, Immer lets you mutate state directly. It feels weird to be mutating the immutable state object, but it's way more efficient.

Action with an object payload

The imported hasPage interface looks like this:

interface hasPage {
  page: Page;
}

Action file:

import { PagesState } from "../../";
import { hasPage, PagesActionTypes } from "..";
import { createAction } from "@reduxjs/toolkit";

export type addPage = {
  type: PagesActionTypes.addPage;
  payload: hasPage;
};

const action = createAction<hasPage, PagesActionTypes.addPage>(
  PagesActionTypes.addPage
);

export const reducer = (
  state: PagesState,
  { payload }: { payload: hasPage }
) => {
  state.pages.push(payload.page);
};

export default action;

View on GitHub

You need to type the payload in the action type declaration.

Type the payload required by your action creator by providing the payload type as the first type parameter, and the action type as the second type parameter in the call to createAction.

Use object destructuring to get the payload out of the action. The payload will match the interface because calls to the action creator are properly typed throughout the code.

The actions Index File

The actions index file is where you declare the enum of all the action types, action payload interfaces, and the union type of all the actions used by this context.

import { addPage } from "./AddPage";
import { deletePage } from "./DeletePage";
import { Page } from "..";
import { setCurrentPage } from "./SetCurrentPage";

//enum containing the action types
export enum PagesActionTypes {
  addPage = "addPage",
  deletePage = "deletePage",
  setCurrentPage = "setCurrentPage",
}

//declare payload interfaces
export interface hasPage {
  page: Page;
}

//union type for all possible actions
export type PagesAction = addPage | deletePage | setCurrentPage;

View on GitHub

Using the Actions

You use the actions by calling the action creator with and then dispatching it.

Dispatching action with no payload:

import deletePage from "../../services/PagesContext/actions/DeletePage";

const DeletePage = () => {
  const { dispatch } = useContext(PagesContext);

  const handleClick = () => dispatch(deletePage());

  return (
    <button className="btn" onClick={() => handleClick()}>
      <i className="fa fa-trash"></i> Delete Page
    </button>
  );
};

View on GitHub

Dispatching action with primitive payload:

import setCurrentPage from "../../services/PagesContext/actions/SetCurrentPage";

const Sidebar = () => {
  const { dispatch, current, pages } = useContext(PagesContext);
  return (
    <div className="sidenav">
      <AddPage />
      <br />
      {pages &&
        pages.map((page, index) => (
          <div key={index}>
            <button
              className="btn"
              style={
                current === page.number
                  ? { backgroundColor: "darkblue" }
                  : undefined
              }
              onClick={() => dispatch(setCurrentPage(page.number))}
            >
              Page {page.number} <br />
              {page.questions.length} Question
              {page.questions.length !== 1 ? "s" : ""}
            </button>
          </div>
        ))}
    </div>
  );
};

View on GitHub

Dispatching action with an object payload:

import addPage from "../../services/PagesContext/actions/addPage";

const AddPage = () => {
  const { dispatch, pages } = useContext(PagesContext);

  const handleClick = () => {
    const pageNumber = pages.length ? pages[pages.length - 1].number + 1 : 1;
    const newPage = getPage(pageNumber);
    dispatch(addPage({ page: newPage }));
  };

  return (
    <button className="btn" onClick={() => handleClick()}>
      <i className="fa fa-plus"></i> Add Page
    </button>
  );
};

View on GitHub

Testing

Testing the reducer function of each action is simple because each action file exports the individual reducer function. Here's the test for the reducer for setCurrentPage. This reducer should accept a number, and set the value of state.current to that number.

Remember: If you choose to write reducers that mutate state directly, you don't get a return value from them. You should assert that the state object that you passed in has mutated.

//import the action creator and the reducer function
import setCurrentPage, { reducer } from "./index";
import { initialState } from "../../../PagesContext";
import getPage from "../../../GetPage";

const page0 = getPage(0);
const page1 = getPage(1);
const page2 = getPage(2);
const page3 = getPage(3);

const stateWithPages = {
  ...initialState,
  current: 1,
  pages: [page0, page1, page2, page3],
};

it("changes the current page", () => {
  const newState = { ...stateWithPages };
  expect(newState.pages.length).toBe(4);
  expect(newState.current).toBe(1);

  //call the action creator
  const action = setCurrentPage(3);

  reducer(newState, action);

  expect(newState.current).toBe(3);
});

View on GitHub

The reducer mutates the newState object because we aren't using the Immer library in the testing environment.

When this reducer is called by the main reducer made using the createReducer function, Immer will be used. So instead of mutating state a new state object will be generated and returned.

You should assert that the state object was mutated.

That's it!

That's all you need to get started using createReducer and createAction with React Context. I think it's a really useful tool that simplifies and shortens the code, prevents mistakes, and makes testing easier.

💖 💪 🙅 🚩
jacobwicks
jacobwicks

Posted on August 17, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related