React's useReducer with Redux Toolkit. Why not?

Who said Redux and useReducer couldn't enjoy some time together?

React's useReducer with Redux Toolkit. Why not?

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

Resources

Valentino Gagliardi

Hi! I'm Valentino! I'm a freelance consultant with a wealth of experience in the IT industry. I spent the last years as a frontend consultant, providing advice and help, coaching and training on JavaScript, testing, and software development. Let's get in touch!