Build A Confirmation Modal in React with State Machines

By Dave Ceddia

Ever needed to wire up a confirmation dialog in React? You know the ones: “Really delete this file? — Yes / Cancel”

These dialogs tend to follow a consistent pattern:

  • User tries to do dangerous/destructive action
  • Modal pops up asking if they’re really really sure
  • On Cancel: hide the modal, do nothing
  • On Confirm: do the action, and then hide the modal after the action is done

I worked on an app that had a need to protect dangerous actions like Delete, in a few places across the app.

The asynchronous nature of the flow (Confirm, wait until done, then close) meant that there would be plenty of finicky useEffect code to show and hide the modal, wait for the async API call to finish before hiding it, and so on.

Or… could I avoid useEffect entirely by using a state machine?

It turned out the answer was yes!

In this article we’ll build a reusable state machine using React and Robot to handle this modal confirmation flow, and wrap it up into a custom hook.

What’s a State Machine?

State machines let you describe the various states that your app can be in, and also let you define transitions between them.

You can think of the individual states as the rest periods between actions that the user takes.

Confirmation modal state machine

Actions can be triggered by any kind of event: things like a user clicking a button, an API call finishing, a websocket message arriving, or a timer going off.

Traffic light state machine. Mere seconds between green and yellow, basically no time between yellow and red, and 4 hours between red and green.

Traffic lights are an everyday example of a state machine with actions that are based on timers and spite.

In some sense, your entire UI is already a state machine.

Imagine you’re on the Home page (state: home) and you click the About link (action: click About) and now you’re on the About page (state: about). Even if you didn’t build this using some state machine library, the app is still in various states.

A lot of bugs stem from the fact that the grand “state machine of our app” is usually implicit. Sure, maybe you drew out a block diagram for some parts – but after it’s built, you’ve gotta resort to piecing together this “state machine” by reasoning through the code.

Here in this article, though, we are actually going to build a concrete finite state machine: one that we’ll describe intentionally, using code.

Having a state machine powering an interaction reminds me of having good test coverage: it feels peaceful.

I can look at the state machine and know for a fact that there are no bugs lurking: these are the only states it can be in, and I hand-picked them, and all the transitions are accounted for. If the state machine is correct, the app will work correctly. Blissful peace of mind.

A Confirmation Modal in React

Here’s the state machine we’ll be building to control a confirmation dialog.

Confirmation modal state machine

We’ll start in the initial state. When the user clicks the “Delete” button, we’ll trigger the begin action that’ll take us to the confirming state. While confirming, we show the modal.

From here there are 2 things that can happen: the user can click Cancel, or they can Confirm. We go back to initial if they cancel, but if they confirm, we kick off our API call to delete whatever-it-is and hang out in the loading state until that succeeds or fails.

These blocks make up the only valid states that this flow can be in.

It’s impossible, for example, to click the Confirm button before the modal appears. It’s also impossible for the modal to disappear before the API call to delete the item either succeeds or fails. And it’s impossible to cancel the delete – once they click that button, it’s gone.

Fair warning: it can and probably will take extra effort and time the first few times you build a state machine. It’s a different way of thinking about problems, and it might feel awkward. The benefits are worth it though! Push through that feeling and you’ll be rewarded with some very reliable code.

Create a Project

Let’s start building a state machine to represent this. We’ll do it within a Create React App project, but we’ll ignore the React parts at first, and focus on the state machine.

create-react-app confirmation-modal

A nice thing about these state machines: you can use them with any UI or backend library! The first half of this post will apply whether you’re using Vue or Angular or Svelte or whatever.

Robot vs. XState

I built a flow like this for a client project, and we used the Robot library, so that’s what I’ll show here. XState is another great state machine library.

While we ended up using Robot, we could’ve just as easily gone with XState. Both create finite state machines. Both work with React.

In our case, Robot had a couple things we liked: the tiny size (it’s only 1kb) and the concise functional way it lets you declare states. “One way to do things” is a guiding principle of Robot. This can be a pro and a con, because it can take some time to wrap your head around that “one way.”

XState has its own advantages: the object-based way of declaring states can be easier to read, and the XState Visualizer is an awesome way to visually see and interact with the state machines you write. XState is larger than Robot, but it’s still only 11kb.

You can’t really go wrong with either one.

Install Robot

Start off by installing the library along with its React counterpart:

npm install robot3 react-robot

Then we can import a few functions from it and get started.

src/confirmationFlow.js
import { createMachine, state, transition } from 'robot3';

const confirmationFlow = createMachine({
  // State machine goes here
});

Then we’re going to fill out this object with states. We’ll have three:

  • initial: while the modal is hidden
  • confirming: while the modal is shown, and we’re asking the user if they’re realllly sure
  • loading: while the modal is still visible, but they’ve clicked Confirm, so we’re performing that request in the background
import { createMachine, state, transition } from 'robot3';

const confirmationFlow = createMachine({
  initial: state(),
  confirming: state(),
  loading: state()
});

You know how they say Naming Things is one of the hard problems in computer science? Yeah. Well…

I’m not gonna lie: coming up with names for the states feels weird at first. Drawing out a diagram was helpful to think through all the various states and what they could be called.

Expect it to be hard and awkward the first few times you sit down to try this on your own problems.

And take heart: if it’s difficult to reason through the different states this thing can be in, just imagine how buggy it could be without knowing what the states are ;)

Transition Between States

States by themselves aren’t very useful. They’re the resting positions, after all.

To move between them, we need transitions and actions. Let’s add a few.

import { createMachine, state, transition } from 'robot3';

const confirmationFlow = createMachine({
  initial: state(
    transition('begin', 'confirming')
  ),
  confirming: state(
    transition('confirm', 'loading'),
    transition('cancel', 'initial')
  ),
  loading: state()
});

The format of this function is transition(actionName, nextState), and a state can have as many transitions as you want.

These are saying:

  • “When the begin action occurs, go to the confirming state”
  • “When the confirm action occurs, go to the loading state”
  • “When the cancel action occurs, go back to the initial state”

We’ll look at how to trigger these actions in a bit.

Here’s an important rule that state machines follow: the only way out of a state is through a valid transition.

That means if we send in the “confirm” action while we’re in the “initial” state, nothing will happen. It won’t throw an error (although you can configure it to do that) – just nothing.

If a state doesn’t have any transitions, it’s a final state: there’s no way out! Right now, our loading state is final, which would mean the modal stays open forever. We’ll fix that in a minute.

Try Out the Machine

Before we build out the loading state, let’s actually try out what we have so far.

This confirmationFlow machine we’ve created is not actually alive yet. It’s like a template.

To start it up and interact with it, we need Robot’s interpret function.

import {
  createMachine, state, transition,
  interpret
} from 'robot3';

const confirmationFlow = createMachine({
  initial: state(
    transition('begin', 'confirming')
  ),
  confirming: state(
    transition('confirm', 'loading'),
    transition('cancel', 'initial'),
  ),
  loading: state(),
});

const service = interpret(confirmationFlow, () => {
  console.log('state changed to', service.machine.current);
})

service.send('begin')
service.send('cancel')

Try it here! - try calling service.send() with action names to see how it works.

Calling interpret gives us a “service” that we can use to send actions and inspect the current state of the machine.

In practice, once we add this to a React app, we won’t need to call interpret ourselves – the react-robot package provides a hook for this.

The service object has a few useful properties on it:

  • The send function for sending actions into the machine
  • The machine property that refers to this instance of the state machine (the current state is at service.machine.current)
  • The context object with whatever you’ve put in there, initially empty.

On Confirm, Delete the Thing

The next step is to actually call our API when the user clicks Confirm. We need another of Robot’s functions for this: invoke.

invoke creates a special state that calls a function when it is entered. Perfect for calling an API or doing some other async work.

import {
  createMachine, state, transition,
  interpret,
  invoke
} from 'robot3';

const deleteSomething = async () => {
  // call an API to delete something
}

const confirmationFlow = createMachine({
  initial: state(
    transition('begin', 'confirming')
  ),
  confirming: state(
    transition('confirm', 'loading'),
    transition('cancel', 'initial'),
  ),
  loading: invoke(deleteSomething,
    transition('done', 'initial'),
    transition('error', 'confirming')
  )
});

The function we invoke must return a promise (and since deleteSomething is marked with async, it always returns a promise).

  • When the action succeeds, we go back to the initial state.
  • If it fails, we go to confirming.

The ‘done’ and ‘error’ actions are ones that invoke will emit when the Promise resolves or rejects. We don’t need to define them anywhere.

Keep Track of Errors

As it’s current written, if an error occurs, the user will never know. Seems like we should show the user an error or something.

Turns out we can store things in the machine’s “context” for later: perfect for storing error info, and anything else that needs to stick around between state changes.

We’ll import the reduce function and add it to our ‘error’ transition:

import {
  createMachine, state, transition,
  interpret,
  invoke,
  reduce
} from 'robot3';

const deleteSomething = async () => {
  // call an API to delete something
}

const confirmationFlow = createMachine({
  initial: state(
    transition('begin', 'confirming')
  ),
  confirming: state(
    transition('confirm', 'loading'),
    transition('cancel', 'initial'),
  ),
  loading: invoke(deleteSomething,
    transition('done', 'initial'),
    transition('error', 'confirming',
      reduce((context, event) => {
        return {
          ...context,
          error: event.error
        }
      })
    )
  )
});

Try it here! - in particular, play around with the success and failure modes by swapping out the function passed to invoke.

The reduce function lets us change the context of the machine. Context is remembered between state changes, and you can access its value from service.context.

The function we pass in gets the current context along with the event that just occurred. Whatever it returns becomes the new context.

Here, we’re returning a new context that includes everything in the old one, plus the error. The event.error key holds the error that the Promise rejected with.

If instead it resolved successfully, then ‘done’ would be dispatched, and the event would have a data key with whatever the Promise returned. This way we can get the data back out to our app.

Build the App

Now that we have our state machine, let’s put it to work in a React component. We’re going to leave the machine in its own file, export it from there, and import it into our React component. (You could jam this all in one file if you want of course, but this’ll make it more reusable)

src/confirmationFlow.js
import {
  createMachine, state, transition,
  interpret, invoke, reduce
} from 'robot3';

const deleteSomething = async () => {
  // call an API to delete something
}

const confirmationFlow = createMachine({
  // ... everything we've written so far ...
});

export { confirmationFlow };

Then we’ll import the machine into src/App.js, along with the useMachine hook.

src/App.js
import React from "react";
import { confirmationFlow } from "./confirmationFlow";
import { useMachine } from "react-robot";

export default function App() {
  const [current, send] = useMachine(confirmationFlow);

  return (
    <div>
      <h1>Modal Test</h1>
      Current state: {current.name}
    </div>
  );
}

The useMachine hook is taking the place of the interpret function we used earlier. It returns an array of things (so you can name them whatever you like).

  • The first element, current here, holds the name of the current state, the context, and the machine instance.
  • The second element, send, is the function for sending actions into the machine

Next we’ll need a dialog that we can show and hide, and a button to trigger the process.

Set Up react-modal

Modal dialogs are tricky to get right (especially the accessibility aspects like focus handling), so we’ll use the react-modal library.

npm install react-modal

It requires a bit of extra setup to tell react-modal which element is the root, so take care of that in index.js first:

src/index.js
import React from "react";
import ReactDOM from "react-dom";
import Modal from "react-modal";

import App from "./App";

const rootElement = document.getElementById("root");

Modal.setAppElement(rootElement);

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  rootElement
);

(without this change, you’d get this warning)

Warning: react-modal: App element is not defined. Please use `Modal.setAppElement(el)` or set `appElement={el}`. This is needed so screen readers don't see main content when modal is opened. It is not recommended, but you can opt-out by setting `ariaHideApp={false}`.

Display The Modal Dialog

Add some code to our component to display the Destroy button, and conditionally display the modal dialog:

src/App.js
import React from "react";
import Modal from "react-modal";
import { confirmationFlow } from "./confirmationFlow";
import { useMachine } from "react-robot";

export default function App() {
  const [current, send] = useMachine(confirmationFlow);

  return (
    <div>
      <h1>Modal Test</h1>
      Current state: {current.name}

      <button onClick={() => send('begin')}>
        Destroy Something Important
      </button>

      <Modal
        onRequestClose={() => send('cancel')}
        isOpen={current.name === 'confirming'}
      >
        Are you sure?!
        <button onClick={() => send('cancel')}>
          Cancel
        </button>
        <button onClick={() => send('confirm')}>
          Yes Definitely
        </button>
      </Modal>
    </div>
  );
}

Read through the actions in the code (all of the send’s) and compare them with the diagram of our state machine.

Confirmation modal state machine

We can see how this works more clearly if we fill out our deleteSomething function with a little bit of a delay and some logging:

src/confirmationFlow.js
import {
  createMachine, state, transition,
  interpret, invoke, reduce
} from 'robot3';

const deleteSomething = async () => {
  // pretend to delete something
  return new Promise((resolve) => {
    console.log("Beginning deletion...");
    setTimeout(() => {
      console.log("Done deleting");
      resolve();
    }, 1000);
  });
};

const confirmationFlow = createMachine({
  // ...
});

export { confirmationFlow };

Try it out! - click the button, and watch the console

But wait! It doesn’t work right! The modal disappears immediately after clicking the confirm button! What happened to that promise of “bug-free state machines”?!

We can see the state changing, though: it goes from confirming to loading and back to initial, just as it should.

It’s just that our condition for when to show the modal is wrong. We’re only keeping it open during confirming, where we really need to leave it open during loading, too.

<Modal
  onRequestClose={() => send('cancel')}
  isOpen={current.name === 'confirming'}
>

Here’s a nice side effect of the state machine approach: it makes these kinds of mistakes more apparent.

Here’s another nice side effect: try clicking the Destroy button, then confirming, and, while it still says “Current state: loading”, try clicking Destroy again. The modal doesn’t open!

Ha! Take that, tricky bug.

That is 100% the kind of bug that would slip through, too. “The user says they’re getting a 500 error, says it tried to delete the same thing twice?” Yep. We just avoided that.

The state machine prevents us from getting into an invalid state, because we didn’t define a transition from loading -> confirming 😎

Likewise, after we fix this bug, the user will be able to smash the Confirm button all they want, but it will only trigger once.

Ok, let’s fix the modal condition though:

src/App.js
import React from "react";
import Modal from "react-modal";
import { confirmationFlow } from "./confirmationFlow";
import { useMachine } from "react-robot";

export default function App() {
  const [current, send] = useMachine(confirmationFlow);

  return (
    <div>
      <h1>Modal Test</h1>
      Current state: {current.name}
      <button onClick={() => send('begin')}>
        Destroy Something Important
      </button>
      <Modal
        onRequestClose={() => send('cancel')}
        isOpen={
          current.name === 'confirming' ||
          current.name === 'loading'
        }
      >
        Are you sure?!
        <button onClick={() => send('cancel')}>
          Cancel
        </button>
        <button onClick={() => send('confirm')}>
          Yes Definitely
        </button>
      </Modal>
    </div>
  );
}

Try it out! - the modal will stick around until the “delete” is finished.

Here’s an exercise to try: It would be nice if the buttons inside the modal were disabled while in the loading state. Try your hand at modifying the example to make that happen.

Pass Data Along With a Robot Action

As wonderful as this state machine is, it’s not very reusable in its current form. The deleteSomething function is hard-coded!

What if we wanted to pop a confirm dialog for some other kind of thing? Ideally we could pass a custom function.

We can do this by passing along a function with the begin action, saving that function in the machine’s context, and then calling it when we enter the loading state.

First, we’ll change the way we send the begin action to include our custom function.

To make it as customizable as possible, we’re also gonna wire it up so that the machine will pass the context and event to our onCommit function.

src/App.js
import React from 'react';
import Modal from 'react-modal';
import { confirmationFlow } from './confirmationFlow';
import { useMachine } from 'react-robot';

async function doSomethingCustom() {
  // pretend to delete something
  return new Promise((resolve) => {
    console.log('Beginning custom action...');
    setTimeout(() => {
      console.log('Done custom action');
      resolve();
    }, 1000);
  });
}

export default function App() {
  const [current, send] = useMachine(confirmationFlow);
  const isLoading = current.name === 'loading';

  return (
    <div>
      <h1>Modal Test</h1>
      Current state: {current.name}
      <button
        onClick={() => send('begin')}
        onClick={() =>
          send({
            type: 'begin',
            onCommit: (context, event) => doSomethingCustom()
          })
        }
      >
        Destroy Something Important
      </button>
      <Modal
        onRequestClose={() => send('cancel')}
        isOpen={
          current.name === 'confirming' ||
          current.name === 'loading'
        }
      >
        Are you sure?!
        <button onClick={() => send('cancel')}>
          Cancel
        </button>
        <button onClick={() => send('confirm')}>
          Yes Definitely
        </button>
      </Modal>
    </div>
  );
}

Instead of sending the string begin, now, we’re sending an object with a type: 'begin'. This way we can include extra stuff with the action. It’s freeform. Add anything you want to this object, and the whole thing will pop out as the event argument later.

Now we need to set up the machine to handle this action. By default, any extra properties on the event (like our onCommit) will be ignored. So we’ll need another reducer to grab that value and save it in context for later.

src/confirmationFlow.js
const confirmationFlow = createMachine({
  initial: state(
    transition(
      'begin',
      'confirming',
      reduce((context, event) => {
        return {
          ...context,
          onCommit: event.onCommit
        };
      })
    )
  ),
  confirming: state(

Then we can change our loading state to call our onCommit function. Robot passes the context and event along to the function it invokes.

src/confirmationFlow.js
const confirmationFlow = createMachine(
  /* ... */
  confirming: state(
    transition('confirm', 'loading'),
    transition('cancel', 'initial')
  ),
  loading: invoke(
    (context, event) => context.onCommit(context, event),
    deleteSometing,
    transition('done', 'initial'),
    transition(
      'error',
      'confirming',
      reduce((context, event) => {
        return {
          ...context,
          error: event.error
        };
      })
    )
  )

With that, our custom async action is wired up! Try it out!

Display The Error

The UX for errors is not great right now: if our custom function throws an error, the user will just be left at the modal, wondering what happened.

We’ve gone to the effort of saving the error, so we may as well display it!

Let’s change the function so that it always rejects with an error, instead of resolving.

Then we can display the error in the modal, when there’s an error.

src/App.js
import React from 'react';
import Modal from 'react-modal';
import { confirmationFlow } from './confirmationFlow';
import { useMachine } from 'react-robot';

async function doSomethingCustom() {
  // pretend to delete something
  return new Promise((resolve, reject) => {
    console.log('Beginning custom action...');
    setTimeout(() => {
      console.log('Done custom action');
      reject('Oh no!');
      resolve();
    }, 1000);
  });
}

export default function App() {
  const [current, send] = useMachine(confirmationFlow);
  const isLoading = current.name === 'loading';

  return (
    <div>
      <h1>Modal Test</h1>
      Current state: {current.name}
      <button
        onClick={() =>
          send({
            type: 'begin',
            onCommit: (context) => doSomethingCustom()
          })
        }
      >
        Destroy Something Important
      </button>
      <Modal
        onRequestClose={() => send('cancel')}
        isOpen={
          current.name === 'confirming' ||
          current.name === 'loading'
        }
      >
        {current.context.error && (
          <div>{current.context.error}</div>
        )}
        Are you sure?!
        <button onClick={() => send('cancel')}>
          Cancel
        </button>
        <button onClick={() => send('confirm')}>
          Yes Definitely
        </button>
      </Modal>
    </div>
  );
}

Try it out!

Try State Machines!

This article was a long-winded way of saying… I think state machines are great, and you should try them in your projects. The confidence they inspire is wonderful.

It’ll take a little practice before they feel natural. And I suspect, having built only small ones so far, that larger ones will be more challenging.

If the code I showed here with Robot doesn’t look like your cup of tea, give XState a try!

Either way you go, you’ll have a solid state machine to rely on.

Because whether or not you take the time to write out a complex feature with a state machine, that complexity will exist in your app. Better to think it through up front and pay that cost once, than to pay every time you have to play whack-a-mole with another bug 😎