DEV Community

Cover image for useState vs useReducer vs XState - Part 1: Modals
Matt Pocock
Matt Pocock

Posted on

useState vs useReducer vs XState - Part 1: Modals

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);
};
Enter fullscreen mode Exit fullscreen mode

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' });
};
Enter fullscreen mode Exit fullscreen mode

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' });
};
Enter fullscreen mode Exit fullscreen mode

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');
  }
};
Enter fullscreen mode Exit fullscreen mode

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' });
};
Enter fullscreen mode Exit fullscreen mode

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' });
};
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (6)

Collapse
likern profile image
Victor Malov

Thank you. Really interesting and amazing article. Exactly what I was looking for.

I would want to see auth flow. For me was the hardest part.
I have in mobile application:

  1. Onboarding screens
  2. On last screen I make a request to Firebase auth to sign in / up.
  3. Only if that was successful I initialize local database schema

After that onboarding screens can't be seen (user logged in).
When auth check to Firebase is made (when opening app) - while deciding is user logged in or not - user see splash screen.

I abstracted all of this into useAccount custom hook. But that was really hard and a lot of code.

Would be happy to see how this can look like with XState.

Collapse
fredericbonnet profile image
Frédéric Bonnet

Great article, thanks! Another (major IMHO) argument in favor of useMachine over useState/useReducer is that the latter both depend on useEffect hooks that are a potential source of race conditions with asynchronous code. See for example:

flufd.github.io/avoiding-race-cond...
joel.net/beware-of-reactuseeffect-...

Another big win for XState is that the machines are framework-agnostic. This means you can share the same your state logic across codebases (front/back/etc) and reuse the code in case your technical requirements change (e.g. front-end framework).

Self-documentation is the cherry on the cake.

Collapse
parksj10 profile image
parksj10 • Edited

Really great, thanks for posting over at the Stately discord [discord.com/invite/xtWgFTgvNV]

Collapse
esakkiraj profile image
Esakki Raj

Thanks for the article Matt. I would be interested to see how State machines can be used to solved Form State and Multi-step sequences.

Collapse
hanzlahabib profile image
hanzlahabib

Its wonderful for multi-step sequences, it let you visualize the flow of application, have used it in current project, where we tend to manage 3 different flows using parallel states in xstate,

Collapse
hanzlahabib profile image
hanzlahabib

Thanks Matt, great article