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.
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 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.
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 hiddenconfirming
: while the modal is shown, and we’re asking the user if they’re realllly sureloading
: 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 theconfirming
state” - “When the
confirm
action occurs, go to theloading
state” - “When the
cancel
action occurs, go back to theinitial
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 atservice.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 thename
of the current state, thecontext
, and themachine
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.
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.
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 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 😎
Success! Now check your email.
Learning React can be a struggle — so many libraries and tools!
My advice? Ignore all of them :)
For a step-by-step approach, check out my Pure React workshop.
Learn to think in React
- 90+ screencast lessons
- Full transcripts and closed captions
- All the code from the lessons
- Developer interviews
Dave Ceddia’s Pure React is a work of enormous clarity and depth. Hats off. I'm a React trainer in London and would thoroughly recommend this to all front end devs wanting to upskill or consolidate.
Alan Lavender
@lavenderlens
from Hacker News https://ift.tt/3gaX7HU
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.