Reinventing Redux through React Refactors

Image of Author
February 28, 2022 (last updated September 15, 2022)

Introduction

This blog post will walk through refactoring a simple todo app built with React hooks. Each section will introduce a small refactor. These small refactors will often involve writing small helper functions with familiar sounding names for people who have used Redux before. By the end of the journey, we will have intuitively discovered, and reinvented with our own simple implementations, some of the core Redux and Redux Toolkit abstractions like createStore, Provider, useDispatch, useSelector, createSlice, and combineReducers.

Section numbers 1, 8, and 9 will show the full file (~50-120 LOC), and, as we work through the refactors in each section I will show the changed code. You should be able to follow along programmatically if you want, and at each step of the journey have a working todo app.

1. Starting point

import { useState } from "react";

const rand = () => Math.random().toString(36).slice(2);

export default function Root() {
  return <Todos />;
}

function Todos() {
  const [todos, setTodos] = useState([]);

  const create = () => {
    setTodos(todos.concat([{ id: rand(), content: rand(), done: false }]));
  };

  const destroy = (todo) => {
    setTodos(todos.filter((t) => t.id != todo.id));
  };

  const toggle = (todo) => {
    setTodos(todos.map((t) => (t.id == todo.id ? { ...t, done: !t.done } : t)));
  };

  return (
    <main>
      <h1>Todos</h1>
      <button onClick={() => create()}>New Todo</button>
      {todos.map((todo) => (
        <Todo {...{ todo, destroy, toggle }} />
      ))}
    </main>
  );
}

function Todo({ todo, destroy, toggle }) {
  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => toggle(todo)}
      />
      <span>{todo.content}</span>
      <button onClick={() => destroy(todo)}>X</button>
    </div>
  );
}

This is good React code. But, the Todos component is a bit noisy. If this component grew in complexity, it would have state logic and display logic all mixed together. Let's extract out the state logic into a custom hook.

2. Custom useTodos hook

I'll only show the parts that changed and their nesting blocks.

function useTodos() {
  const [todos, setTodos] = useState([]);

  const create = () => {
    setTodos(todos.concat([{ id: rand(), content: rand(), done: false }]));
  };

  const destroy = (todo) => {
    setTodos(todos.filter((t) => t.id != todo.id));
  };

  const toggle = (todo) => {
    setTodos(todos.map((t) => (t.id == todo.id ? { ...t, done: !t.done } : t)));
  };

  return { todos, create, destroy, toggle };
}

function Todos() {
  const { todos, create, destroy, toggle } = useTodos();
}

Awesome. That feels better to me. But, passing down functions through a component hierarchy can get out of hand quickly. A real world component hierarchy could require passing state actions into deeply nested components, making refactoring and adding additional functionality more tedious and error prone. It would be easier to pass down a single function that could handle all possible state functions, both current and future. That's what the dispatch function does in the useReducer hook. Let's create a reducer (redux concept) and a state object (redux concept), and in return we'll get back the updated state and a dispatch function (redux concept). We'll rename useTodos to useState since we are making the state object more generic.

3. useReducer-based useState hook

I'll only show the parts that changed and their nesting blocks.

function reducer(state, action) {
  switch (action.type) {
    case "CREATE":
      return {
        ...state,
        todos: state.todos.concat([
          { id: rand(), content: rand(), done: false },
        ]),
      };
    case "DESTROY":
      return {
        ...state,
        todos: state.todos.filter((t) => t.id != action.todo.id),
      };
    case "TOGGLE":
      return {
        ...state,
        todos: state.todos.map((t) =>
          t.id == action.todo.id ? { ...t, done: !t.done } : t
        ),
      };
    default:
      throw new Error(`action.type ${action.type} not supported.`);
  }
}

function useState() {
  const [state, dispatch] = useReducer(reducer, { todos: [] });
  return [state, dispatch];
}

function Todos() {
  const [state, dispatch] = useState();

  return (
    <main>
      <h1>Todos</h1>
      <button onClick={() => dispatch({ type: "CREATE" })}>New Todo</button>
      {state.todos.map((todo) => (
        <Todo {...{ todo, dispatch }} />
      ))}
    </main>
  );
}

function Todo({ todo, dispatch }) {
  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => dispatch({ type: "TOGGLE", todo })}
      />
      <span>{todo.content}</span>
      <button onClick={() => dispatch({ type: "DESTROY", todo })}>X</button>
    </div>
  );
}

Great. We've simplified the parameter passing. But, now we have these actions that are being manually created each time, which is both tedious and error prone. Let's write some helper functions to create those actions. We can refer to them as action creators (redux concept).

4. Action Creators

I'll only show the parts that changed and their nesting blocks.

const create = () => ({ type: "CREATE" });
const destroy = (todo) => ({ type: "DESTROY", todo });
const toggle = (todo) => ({ type: "TOGGLE", todo });

function Todos() {
  return (
    <main>
      <button onClick={() => dispatch(create())}>New Todo</button>
    </main>
  );
}

function Todo({ todo, dispatch }) {
  return (
    <div>
      <input onChange={() => dispatch(toggle(todo))} />
      <button onClick={() => dispatch(destroy(todo))}>X</button>
    </div>
  );
}

Great, now we use global action creators in components no matter how deeply nested they are. But, we still have to pass that single dispatch function around. It's probably being used in almost every component, too, since it is the universal action handler. React has a special place for universal objects and functions. It's called the context. Let's create one, put our state and dispatch in it, and then access it with the useContext hook. Since we are storing multiple things in the context, state and dispatch, let's call it the StoreContext. We will also be getting rid of our useState and instead use useReducer directly inside of the Root component.

5. StoreContext for state and dispatch

I'll only show the parts that changed and their nesting blocks.

const StoreContext = React.createContext({ state: null, dispatch: null });

export default function Root() {
  const [state, dispatch] = useReducer(reducer, { todos: [] });

  return (
    <StoreContext.Provider value={{ state, dispatch }}>
      <Todos />
    </StoreContext.Provider>
  );
}

function Todos() {
  const { state, dispatch } = useContext(StoreContext);
}

function Todo({ todo }) {
  const { dispatch } = useContext(StoreContext);
}

Great. We have a StoreContext that wraps the whole application and we setup the state behavior in the Root component. But, now the Root component is getting a bit noisy. In a larger app that Root component would start to fill up with a bunch of state setup logic. It would be better to move that all into a single place. Let's create a createStore function (redux concept) and a Provider component (redux concept) that simplifies the state-component interface.

6. createStore and Provider component

I'll only show the parts that changed and their nesting blocks.

(Note that createStore is really a hook here, and so would be called useStore if you actually used an abstraction like this in your real code, not that I'd particularly recommend you do, though. But, the goal here is to look like Redux as much as possible. So, from here on out, please forgive me as I bend over backwards to name these upcoming functions the same as their Redux counterparts.)

const initState = { todos: [] };

function createStore(reducer, initState) {
  const [state, dispatch] = useReducer(reducer, initState);
  return { state, dispatch };
}

function Provider({ store, children }) {
  return (
    <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
  );
}

export default function Root() {
  const store = createStore(reducer, initState);

  return (
    <Provider store={store}>
      <Todos />
    </Provider>
  );
}

Awesome. The Root component is looking cleaner. But, we are still using useContext(StoreContext) in our Todos and Todo component. In a larger application that would mean we are accessing and digging through way more information than needed in each of our components. It would be better to be able to select exactly what we needed from the state, and also to get straightforward access to the dispatch instead of digging around in context to get it. Let's try and simplify by creating a useSelector hook (redux concept) for selecting parts of state we care about, and a useDispatch hook (redux concept) for straightforward dispatch access.

7. useDispatch and useSelector

I'll only show the parts that changed and their nesting blocks.

function useDispatch() {
  const { dispatch } = useContext(StoreContext);
  return dispatch;
}

function useSelector(selector) {
  const { state } = useContext(StoreContext);
  return selector(state);
}

function Todos() {
  const todos = useSelector((state) => state.todos);
  const dispatch = useDispatch();
}

function Todo({ todo }) {
  const dispatch = useDispatch();
}

Great. We have some nice helper functions to help us access exactly what we need in a component and nothing more. Things are looking pretty good. But, the state logic and helper functions are really starting to add up. This isn't surprising, since, by design, all the logic lives there. Still, let's clean it up a bit by creating two namespaces, one full of the helper functions which we'll call Redux, and one full of the app-specific logic which we'll call MyFiles. This will let us group our functionality appropriately (while still all being in a single file).

8. Redux and MyFiles namespaces

This is the entire file up to now.

import React, { useContext, useReducer } from "react";

namespace Redux {
  const StoreContext = React.createContext({ state: null, dispatch: null });

  export function createStore(reducer, initState) {
    const [state, dispatch] = useReducer(reducer, initState);
    return { state, dispatch };
  }

  export function Provider({ store, children }) {
    return (
      <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
    );
  }

  export function useDispatch() {
    const { dispatch } = useContext(StoreContext);
    return dispatch;
  }

  export function useSelector(selector) {
    const { state } = useContext(StoreContext);
    return selector(state);
  }
}

namespace MyFiles {
  export namespace ActionCreators {
    export const create = () => ({ type: "CREATE" });
    export const destroy = (todo) => ({ type: "DESTROY", todo });
    export const toggle = (todo) => ({ type: "TOGGLE", todo });
  }

  export const initState = { todos: [] };

  export function reducer(state, action) {
    const rand = () => Math.random().toString(36).slice();

    switch (action.type) {
      case "CREATE":
        return {
          ...state,
          todos: state.todos.concat([
            { id: rand(), content: rand(), done: false },
          ]),
        };
      case "DESTROY":
        return {
          ...state,
          todos: state.todos.filter((t) => t.id != action.todo.id),
        };
      case "TOGGLE":
        return {
          ...state,
          todos: state.todos.map((t) =>
            t.id == action.todo.id ? { ...t, done: !t.done } : t
          ),
        };
      default:
        throw new Error(`action.type ${action.type} not supported.`);
    }
  }
}

const { createStore, Provider } = Redux;
const { reducer, initState } = MyFiles;

export default function Root() {
  const store = createStore(reducer, initState);

  return (
    <Provider store={store}>
      <Todos />
    </Provider>
  );
}

const { useSelector, useDispatch } = Redux;
const { create } = MyFiles.ActionCreators;

function Todos() {
  const todos = useSelector((state) => state.todos);
  const dispatch = useDispatch();

  return (
    <main>
      <h1>Todos</h1>
      <button onClick={() => dispatch(create())}>New Todo</button>
      {todos.map((todo) => (
        <Todo {...{ todo, dispatch }} />
      ))}
    </main>
  );
}

const { toggle, destroy } = MyFiles.ActionCreators;

function Todo({ todo }) {
  const dispatch = useDispatch();

  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => dispatch(toggle(todo))}
      />
      <span>{todo.content}</span>
      <button onClick={() => dispatch(destroy(todo))}>X</button>
    </div>
  );
}

Our components have reached their final form at this point. They are as decoupled as they possibly can be while still being fundamentally hierarchical. But, the logic in MyFiles is a bit messy. Both the reducer and action creators rely on arbitrary string values to work appropriately, which means renaming them will be difficult and error prone. Also, the reducer has a lot of boilerplate nesting logic going on which is error prone. We can deal with both of these problems by creating a slice of the global state that deals specifically with todos. Within this slice we can write a sub-reducer that only deals with todos. Also, if we write our reducer functions in object-form instead of switch-case form, we can use the names of the reducer functions to auto-generate action creator functions for them. Let's call this helper function createSlice (redux toolkit concept). Now, all we have to do is combine the slices together, which we can do with a combineReducers helper function (redux concept).

9. createSlice and combineReducers

This is the entire file.

import React, { useContext, useReducer } from "react";

namespace Redux {
  const StoreContext = React.createContext({ state: null, dispatch: null });

  export function createStore(reducer, initState) {
    const [state, dispatch] = useReducer(reducer, initState);
    return { state, dispatch };
  }

  export function Provider({ store, children }) {
    return (
      <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
    );
  }

  export function useDispatch() {
    const { dispatch } = useContext(StoreContext);
    return dispatch;
  }

  export function useSelector(selector) {
    const { state } = useContext(StoreContext);
    return selector(state);
  }

  export function createSlice({ name, reducers, initialState }) {
    const actionsEntries = Object.keys(reducers).map((r) => [
      `${r}`,
      (payload) => ({ type: `${name}/${r}`, payload }),
    ]);

    return {
      actions: Object.fromEntries(actionsEntries),
      initialState,
      reducer: (state, action) => {
        const key = Object.keys(reducers).find((k) => action.type.endsWith(k));
        return reducers[key](state, action);
      },
    };
  }

  export function combineReducers(reducers) {
    return (state, action) => {
      const key = Object.keys(reducers).find((k) => action.type.startsWith(k));
      return { ...state, [key]: reducers[key](state[key], action) };
    };
  }
}

namespace MyFiles {
  const { createSlice, combineReducers } = Redux;

  const rand = () => Math.random().toString(36).slice(2);

  export const todosSlice = createSlice({
    name: "todos",
    initialState: [],
    reducers: {
      create: (state) =>
        state.concat([{ id: rand(), content: rand(), done: false }]),
      destroy: (state, { payload: { todo } }) =>
        state.filter((t) => t.id != todo.id),
      toggle: (state, { payload: { todo } }) =>
        state.map((t) => (t.id == todo.id ? { ...t, done: !t.done } : t)),
    },
  });

  export const initState = { todos: MyFiles.todosSlice.initialState };
  export const reducer = combineReducers({ todos: MyFiles.todosSlice.reducer });
}

const { createStore, Provider } = Redux;
const { reducer, initState } = MyFiles;

export default function Root() {
  const store = createStore(reducer, initState);

  return (
    <Provider store={store}>
      <Todos />
    </Provider>
  );
}

const { useSelector, useDispatch } = Redux;
const { create } = MyFiles.todosSlice.actions;

function Todos() {
  const todos = useSelector((state) => state.todos);
  const dispatch = useDispatch();

  return (
    <main>
      <h1>Todos</h1>
      <button onClick={() => dispatch(create())}>New Todo</button>
      {todos.map((todo) => (
        <Todo {...{ todo, dispatch }} />
      ))}
    </main>
  );
}

const { toggle, destroy } = MyFiles.todosSlice.actions;

function Todo({ todo }) {
  const dispatch = useDispatch();

  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => dispatch(toggle({ todo }))}
      />
      <span>{todo.content}</span>
      <button onClick={() => dispatch(destroy({ todo }))}>X</button>
    </div>
  );
}

This is pretty great. Now my state logic within MyFiles is minimal and there's no more coupling based on string values, etc. Most of the complexity lives in the Redux helper functions. This is going to be the end of the journey, but, for what it's worth, the last little refactor that is jumping out is using Immer inside the createSlice function (which Redux does as well) to allow for "mutating" immutable reducer functions. That would let us "simplify" our reducer logic yet again.

Conclusion

The goal of this blog post was to build intuition around why the core abstractions of Redux are the way they are, and to do so using modern React (aka, React with hooks). I hope I succeeded, and that this was an informative journey through the world of React and Redux. I learned a lot writing it, so I hope you learned a bit reading it.

Thanks for reading :D