Using Redux Toolkit's createReducer with React Context and TypeScript
Example App running on GitHub Pages
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
You can Turn this: Into this:
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
blockName: pagesState
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>;
};
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.
One of the neat tricks that the Redux Toolkit createAction
lets you do is use a reference to the actionCreator
function as the key for calling itself.
Call createReducer
1
2
3
4
5
6
7
8
9
10
blockName: createReducer
export const reducer: Reducer<
PagesState,
PagesAction
> = createReducer(initialState, (builder) =>
builder
.addCase(addPage, addPageReducer)
.addCase(deletePage, deletePageReducer)
.addCase(setCurrentPage, setCurrentPageReducer)
);
The type of the state object that your context manages.
The type of the action object that your context accepts.
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
blockName: deletePage
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;
This is the action creator. 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
blockName: setCurrentPage
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;
Define the type of the payload.
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.
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
blockName: addPage
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;
Import the payload interface declaration. You could also declare it inside the action file.
Typing 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.
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
blockName: actionsIndex
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;
Using the Actions
You use the actions by calling the action creator with and then dispatching it.
Dispatching action with no payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
blockName: useDeletePage
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>
);
};
Dispatching action with primitive payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
blockName: useSetCurrentPage
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>
);
};
Dispatching action with an object payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
blockName: useAddPage
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>
);
};
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
blockName: testing
//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);
});
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.
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.