Stately
All blog posts

useState vs useReducer vs XState - Part 1: Modals

By Matt Pocock on July 28, 2021Edit this page on GitHub

  • #modal
  • #react
  • #useState
  • #useReducer
  • #xstate

Managing state at different levels of complexity is hard. Different tools make different trade-offs between readability, complexity and speed of development. The worst part is that as apps get more complex, it’s easy to regret choices that were made early on.

This series of articles should help you make the right choice off the bat. The plan is to cover a bunch of state use cases, starting with the simple and graduating to more complexity as we go. We’ll see how easy they are to write, and also how they survive changing requirements.

Today, we’re starting with modals.

useState

For modals, the key piece of state is whether or not the modal is open. useState lets us capture that single piece of state pretty succinctly.

const [isOpen, setIsOpen] = useState(false);

const open = () => {
  setIsOpen(true);
};

const close = () => {
  setIsOpen(false);
};

const toggle = () => {
  setIsOpen(!isOpen);
};

Highly readable, simple enough, fast to write, bug-proof. For a simple toggle like this, useState is great.

useReducer

const reducer = (state = { isOpen: false }, action) => {
  switch (action.type) {
    case "OPEN":
      return {
        isOpen: true,
      };
    case "CLOSE":
      return {
        isOpen: false,
      };
    case "TOGGLE":
      return {
        isOpen: !state.isOpen,
      };
    default:
      return state;
  }
};

const [state, dispatch] = useReducer(reducer, { isOpen: false });

const open = () => {
  dispatch({ type: "OPEN" });
};

const close = () => {
  dispatch({ type: "CLOSE" });
};

const toggle = () => {
  dispatch({ type: "TOGGLE" });
};

useReducer gives us a reducer, a powerful centralized spot in our code where we can visualise the changes happening. However, it took us quite a few more lines of code to reach the same result as useState. For now, I’d say useState has the edge.

useMachine

useMachine is a hook from XState, which allows us to use the power of state machines in our code. Let’s see how it looks.

const machine = Machine({
  id: "modalMachine",
  initial: "closed",
  states: {
    closed: {
      on: {
        OPEN: {
          target: "open",
        },
        TOGGLE: "open",
      },
    },
    open: {
      on: {
        TOGGLE: "closed",
        CLOSE: "closed",
      },
    },
  },
});

const [state, send] = useMachine(machine);

const open = () => {
  send({ type: "OPEN" });
};

const close = () => {
  send({ type: "CLOSE" });
};

const toggle = () => {
  send({ type: "TOGGLE" });
};

You can see the state machine on the XState visualiser here.

It’s remarkably similar in structure to the reducer above. Similar amount of lines, nearly the same event handlers. The state machine takes the edge over the reducer because of being able to easily visualise its logic - that’s something the reducer can’t match.

However, the useState implementation still has the edge for me. The simplicity of execution, the elegance. It’s hard to see how it could be beaten...

ALERT: REQUIREMENTS CHANGING

Oh no. Requirements have changed. Now, instead of immediately closing, the modal needs to animate out. This means we need to insert a third state, closing, which we automatically leave after 500ms. Let’s see how our implementations hold up.

useState

Refactor 1: Our initial isOpen boolean won't handle all the states we need it to any more. Let’s change it to an enum: closed, closing and open.

Refactor 2: isOpen is no longer a descriptive variable name, so we need to rename it to modalState and setModalState.

Refactor 3: useState doesn’t handle async changes by itself, so we need to bring in useEffect to run a timeout when the state is in the closing state. We also need to clear the timeout if the state is no longer closing.

Refactor 4: We need to change the toggle event handler to add logic to ensure it only triggers on the closed and open states. Toggles work great for booleans, but become much harder to manage with enums.

// Refactor 1, 2
const [modalState, setModalState] = useState("closed");

// Refactor 3
useEffect(() => {
  if (modalState === "closing") {
    const timeout = setTimeout(() => {
      setModalState("closed");
    }, 500);
    return () => {
      clearTimeout(timeout);
    };
  }
}, [modalState]);

// Refactor 1, 2
const open = () => {
  setModalState("open");
};

// Refactor 1, 2
const close = () => {
  setModalState("closing");
};

// Refactor 1, 2, 4
const toggle = () => {
  if (modalState === "closed") {
    setModalState("open");
  } else if (modalState === "open") {
    setModalState("closing");
  }
};

Yuck. That was an enormous amount of refactoring to do just to add a simple, single requirement. On code that might be subject to changing requirements, think twice before using useState.

useReducer

Refactor 1: Same as above - we turn the isOpen boolean to the same enum.

Refactor 2: Same as above, isOpen is now improperly named, so we need to change it to status. This is changed in fewer places than useState, but there are still some changes to make.

Refactor 3: The same as above, we use useEffect to manage the timeout. An additional wrinkle is that we need a new action type in the reducer, REPORT_ANIMATION_FINISHED, to cover this.

Refactor 4: The same as above, but instead of the logic being in the event handler, we can actually change the logic inside the reducer. This is a cleaner change, but is still similar in the amount of lines it produces.

// Refactor 1, 2
const reducer = (state = { status: "closed" }, action) => {
  switch (action.type) {
    // Refactor 2
    case "OPEN":
      return {
        status: "open",
      };
    // Refactor 2
    case "CLOSE":
      return {
        status: "closing",
      };
    // Refactor 3
    case "REPORT_ANIMATION_FINISHED":
      return {
        status: "closed",
      };
    // Refactor 4
    case "TOGGLE":
      switch (state.status) {
        case "closed":
          return {
            status: "open",
          };
        case "open":
          return {
            status: "closing",
          };
      }
      break;
    default:
      return state;
  }
};

// Refactor 1
const [state, dispatch] = useReducer(reducer, { status: "closed" });

// Refactor 3
useEffect(() => {
  if (state.status === "closing") {
    const timeout = setTimeout(() => {
      dispatch({ type: "REPORT_ANIMATION_FINISHED" });
    }, 500);
    return () => {
      clearTimeout(timeout);
    };
  }
}, [state.status]);

const open = () => {
  dispatch({ type: "OPEN" });
};

const close = () => {
  dispatch({ type: "CLOSE" });
};

const toggle = () => {
  dispatch({ type: "TOGGLE" });
};

This file required the same number of refactors as the useState implementation. One crucial advantage is that these refactors were mostly located together: most changes occurred inside the reducer, and the event handlers went largely untouched. For me, this gives useReducer the edge over useState.

useMachine

Refactor 1: Add a new closing state, which after 500 milliseconds goes to the closed state.

Refactor 2: Changed the targets of the TOGGLE and CLOSE actions to point at closing instead of closed.

export const machine = Machine({
  id: "modalMachine",
  initial: "closed",
  states: {
    closed: {
      on: {
        OPEN: {
          target: "open",
        },
        TOGGLE: "open",
      },
    },
    // Refactor 1
    closing: {
      after: {
        500: "closed",
      },
    },
    open: {
      on: {
        // Refactor 2
        TOGGLE: "closing",
        CLOSE: "closing",
      },
    },
  },
});

const [state, send] = useMachine(machine);

const open = () => {
  send({ type: "OPEN" });
};

const close = () => {
  send({ type: "CLOSE" });
};

const toggle = () => {
  send({ type: "TOGGLE" });
};

See the changed machine here.

The difference here is stark. A minimal number of refactors, all within the state machine itself. The amount of lines has hardly changed. None of the event handlers changed. AND we have a working visualisation of the new implementation.

Conclusion

Before the requirements changed, useState was the champion. It’s faster, easier to implement, and fairly clear. useReducer and useMachine were too verbose, but useMachine took the edge by being easier to visualise.

But after the requirements changed, useState hit the floor. It quickly became the worst implementation. It was the hardest to refactor, and its refactors were in the most diverse places. useReducer was equally hard to refactor, with the same set of changes. useMachine emerged as the champion, with a minimal diff required to build in new, complex functionality.

So if you’re looking to build a modal fast, use useState. If you want to build it right, use useMachine.

I’m excited to work on this set of articles - I’m looking forward to tackling the toughest state models out there. What would you like to see covered in the next one? Some ideas:

  • Data fetching
  • Form state
  • Multi-step sequences (checkout flows, signup flows)

Let me know in the comments below, and follow me for the next article!

Copyright © Stately, 2021