React's useReducer with Redux Toolkit. Why not?
Who said Redux and useReducer couldn't enjoy some time together?
useReducer
is a convenient React Hook that lets you manage complex state updates, as much as you would do with a Redux reducer. useReducer
, and the Context API might look like a replacement for Redux, but don't get fooled.
I enjoy using React hooks, but I also like Redux so much, and these days with Redux Toolkit Redux is even more compelling.
While working with useReducer
I found myself thinking: now that we have createAction
, createReducer
, createSlice
in Redux Toolkit, why on earth would I write a reducer with its actions by hand even if I'm using just useReducer
?
An example with useReducer
Consider a contrived example with useReducer
. I understand the point of it: we want to get rid of Redux. To stay as much boilerplate-free possible we can do:
import React, { useReducer } from "react";
const authState = {
isRequestingToken: "",
username: "",
token: "",
error: ""
};
function authReducer(state, action) {
switch (action.type) {
case "LOGIN_START":
return {
...state,
isRequestingToken: "yes",
username: action.payload.username
};
case "LOGIN_SUCCESS":
return {
...state,
isRequestingToken: "no",
token: action.payload.token };
default:
return state;
}
}
export function SillyThings() {
const [state, dispatch] = useReducer(authReducer, authState);
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const username = formData.get("username");
dispatch({ type: "LOGIN_START", payload: { username }});
// omit for brevity
}
// omit for brevity
}
What I don't like here is case "LOGIN_START":
, and dispatch({ type: "LOGIN_START", payload: { username }});
. These floating strings in actions, inline payloads, are solved problems in Redux.
At the very least I'd write named actions, and action creators, but this way I'm getting closer to replicate Redux:
import React, { useReducer } from "react";
const LOGIN_START = "LOGIN_START";
const LOGIN_SUCCESS = "LOGIN_SUCCESS";
function loginStart(payload) {
return {
type: LOGIN_START,
payload
};
}
function loginSuccess(payload) {
return {
type: LOGIN_SUCCESS,
payload
};
}
const authState = {
isRequestingToken: "",
username: "",
token: "",
error: ""
};
function authReducer(state, action) {
switch (action.type) {
case LOGIN_START:
return {
...state,
isRequestingToken: "yes",
username: action.payload.username
};
case LOGIN_SUCCESS:
return {
...state,
isRequestingToken: "no",
token: action.payload.token };
default:
return state;
}
}
export function SillyThings() {
const [state, dispatch] = useReducer(authReducer, authState);
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const username = formData.get("username");
dispatch(loginStart({ username }));
// omit for brevity
}
// omit for brevity
}
So much boilerplate! It doesn't have to be! What I'm suggesting here is to borrow createAction
and createReducer
from Redux Toolkit to clean up things a bit. Let's see.
Redux Toolkit: createAction
In the late months of 2018 Redux saw the introduction of Redux starter kit, later renamed to Redux Toolkit. It aims to simplify Redux with a convenient abstraction over the "boilerplate" that so many developers complained about.
Redux Toolkit has a bunch of helpers functions. For now, we'll borrow createAction
and createReducer
. To install toolkit in your project:
npm i @reduxjs/toolkit
It's good practice in Redux to have action creators and named actions. With createAction
we can get rid of action creators and named actions to condense all in one place. So instead of:
import React, { useReducer } from "react";
const LOGIN_START = "LOGIN_START";
const LOGIN_SUCCESS = "LOGIN_SUCCESS";
function loginStart(payload) {
return {
type: LOGIN_START,
payload
};
}
function loginSuccess(payload) {
return {
type: LOGIN_SUCCESS,
payload
};
}
we can do:
import React, { useReducer } from "react";
import { createAction } from "@reduxjs/toolkit";
const loginStart = createAction("loginStart");
const loginSuccess = createAction("loginSuccess");
Here loginStart
and loginSuccess
are both action creator and named actions. They are callable and ready to accept a payload:
dispatch(loginStart({ username }));
Redux Toolkit: createReducer
After actions creators and named actions, reducers are where most of the Redux "boilerplate" grows. Traditionally you would have a switch
with a group of case
for handling action types:
function authReducer(state, action) {
switch (action.type) {
case LOGIN_START:
return {
...state,
isRequestingToken: "yes",
username: action.payload.username
};
case LOGIN_SUCCESS:
return {
...state,
isRequestingToken: "no",
token: action.payload.token };
default:
return state;
}
}
To abide immutability we're also forced to always return a new state from each clause. With createReducer
we can cut this reducer to:
const authReducer = createReducer(authState, {
[loginStart]: (state, action) => {
state.isRequestingToken = "yes";
state.username = action.payload.username;
},
[loginSuccess]: (state, action) => {
state.isRequestingToken = "no";
state.token = action.payload.token;
}
});
createReducer
shines when dealing with mutations. Under the hood it uses immer, which allows for writing mutative logic, which in reality does not alter the original object.
Notice how we use actions from createAction
as computed properties for the mapping.
Wrapping all together
Having introduced Redux Toolkit let's now apply createReducer
and createAction
to the original example. We can go from this:
import React, { useReducer } from "react";
const LOGIN_START = "LOGIN_START";
const LOGIN_SUCCESS = "LOGIN_SUCCESS";
function loginStart(payload) {
return {
type: LOGIN_START,
payload
};
}
function loginSuccess(payload) {
return {
type: LOGIN_SUCCESS,
payload
};
}
const authState = {
isRequestingToken: "",
username: "",
token: "",
error: ""
};
function authReducer(state, action) {
switch (action.type) {
case LOGIN_START:
return {
...state,
isRequestingToken: "yes",
username: action.payload.username
};
case LOGIN_SUCCESS:
return {
...state,
isRequestingToken: "no",
token: action.payload.token };
default:
return state;
}
}
export function SillyThings() {
const [state, dispatch] = useReducer(authReducer, authState);
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const username = formData.get("username");
dispatch(loginStart({ username }));
// omit for brevity
}
// omit for brevity
}
To this:
import React, { useReducer } from "react";
import { createAction, createReducer } from "@reduxjs/toolkit";
const loginStart = createAction("loginStart");
const loginSuccess = createAction("loginSuccess");
const authState = {
isRequestingToken: "",
username: "",
token: "",
error: ""
};
const authReducer = createReducer(authState, {
[loginStart]: (state, action) => {
state.isRequestingToken = "yes";
state.username = action.payload.username;
},
[loginSuccess]: (state, action) => {
state.isRequestingToken = "no";
state.token = action.payload.token;
}
});
export function SillyThings() {
const [state, dispatch] = useReducer(authReducer, authState);
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const username = formData.get("username");
dispatch(loginStart({ username }));
// omit for brevity
}
// omit for brevity
}
As a stylish touch we can also use createSlice
to simplify even more. I call createSlice
the holy grail of Redux:
import React, { useReducer } from "react";
import { createSlice } from "@reduxjs/toolkit";
const authState = {
isRequestingToken: "",
username: "",
token: "",
error: ""
};
const authSlice = createSlice({
name: "auth",
reducers: {
loginStart: (state, action) => {
state.isRequestingToken = "yes";
state.username = action.payload.username;
},
loginSuccess: (state, action) => {
state.isRequestingToken = "no";
state.token = action.payload.token;
}
},
initialState: authState
});
const { loginStart, loginSuccess } = authSlice.actions;
const authReducer = authSlice.reducer;
export function SillyThings() {
const [state, dispatch] = useReducer(authReducer, authState);
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const username = formData.get("username");
dispatch(loginStart({ username }));
// omit for brevity
}
// omit for brevity
}
Here's the complete component in case you're curious:
import React, { useReducer } from "react";
import { createSlice } from "@reduxjs/toolkit";
const authState = {
isRequestingToken: "",
username: "",
token: "",
error: ""
};
const authSlice = createSlice({
name: "auth",
reducers: {
loginStart: (state, action) => {
state.isRequestingToken = "yes";
state.username = action.payload.username;
},
loginSuccess: (state, action) => {
state.isRequestingToken = "no";
state.token = action.payload.token;
}
},
initialState: authState
});
const { loginStart, loginSuccess } = authSlice.actions;
const authReducer = authSlice.reducer;
export function SillyThings() {
const [state, dispatch] = useReducer(authReducer, authState);
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const username = formData.get("username");
dispatch(loginStart({ username }));
fetch("https://api.valentinog.com/api/token-create/", {
method: "POST",
body: formData
})
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
})
.then(json => dispatch(loginSuccess({ token: json.token })));
}
return state.token ? (
<p>Welcome back {state.username}</p>
) : (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username"> Username</label>
<input type="text" id="username" name="username" />
</div>
<div>
<label htmlFor="password">Password</label>
<input type="password" id="password" name="password" />
</div>
<button type="submit">LOGIN</button>
</form>
);
}
Notes
Since useReducer
provides the initial state for our reducer we can omit it from createReducer
:
const authReducer = createReducer({}, {
//
})
or from createSlice
:
const authSlice = createSlice({
//
initialState: authState // << might omit
});
Conclusions
This post might look bizarre. We created a chimera, half React, half Redux.
It's certainly feasible to pair React's useReducer
with Redux Toolkit. However, take my notes with a grain of salt. Will update the post if I find any quirk or drawback.
Feel free to bash me on Reddit