Skip to content

Instantly share code, notes, and snippets.

@bholmesdev
Created September 22, 2020 16:03
Show Gist options
  • Save bholmesdev/1de289a1b862969630fd4cf8cc2f9bd4 to your computer and use it in GitHub Desktop.
Save bholmesdev/1de289a1b862969630fd4cf8cc2f9bd4 to your computer and use it in GitHub Desktop.

Simplifying HTML forms with React + state machines

If you're a frontend developer, you probably don't need me to tell you how annoying HTML forms can be to manage... especially when your team is using React. We've had a never-ending flood of solutions over the years to make all the little pieces work better: submission states, error handling, validation, you name it.

In the end, there's no silver bullet to solve forms for everyone. But working on recent projects at Peloton, we've found a neat, back-to-basics pattern that doesn't even require a library.

Overall, we're going to explore:

  1. The traditional, "booleans-everywhere" approach used to manage submission and error states
  2. A cleaner state-driven approach that just takes a few lines of JavaScript, and works even better with TypeScript
  3. Some takeaways and future directions with the XState library

Alright, let's ride ๐Ÿšดโ€โ™€๏ธ

Scenario: our tread email capture form

To explain this concept, we'll explore a UI flow we just finished for our Tread product launch (which is extremely exciting, go check it out ๐ŸŽ‰). We'll focus on the ZIP code capture pop-up in particular.

Full UI flow from email capture to postal code submission

You can click around / attempt to break this form on the live site as well ๐Ÿ˜

This demos some scenarios we need to consider for our users:

  1. You see a loading spinner while we submit your postal code
  2. You have a thank you message + disabled "submit" button once you're done
  3. You get an error banner (without the loading spinner!) when you try to submit an invalid postal code

First, the approach a lot of React devs are used to

If you're coming from a Redux background like the rest of our team, you might jump to representing all the possible scenarios using booleans. Stop me if you've heard this one before:

https://gist.github.com/ed8435efe87241176f27fde93f8f26f4

If you were modeling this using a reducer, you'd probably start with an object like this. Then, as the user starts clicking around, you'll have some business logic to modify this state over time.

This is a totally useable pattern if you're slapping everything into global state. To simplify our example a bit, let's model this approach using React's useState hook:

https://gist.github.com/d4679fa99869596ca830dcf022a06aac

If we look back at our preview, we see that most of this action happens when the user submits the form. We should be able to throw everything into an onSubmit function like so:

https://gist.github.com/548a2139a10e52f220fc2049408eac96

Cool! All we need is some ZIP code validation and we're good to go:

https://gist.github.com/adcdde4c9507a55952ac1c8344cbe99f

Where this starts to break down

For our isolated example, this doesn't seem so bad. Sure, we have some boolean setting we need to stay on top of (i.e. you shouldn't be "submitting" and "invalid" at the same time), but it's still pretty readable.

However, this pattern starts to break down as complexity grows. For example, say we're applying a different CSS class to our submit button depending on the scenario. To apply the right style at the right time, we'll need some mapper functions like this one:

https://gist.github.com/ff01e20c4d1560c13072179bf21b8594

What's more, our boolean soup could start to overflow as we tack on new functionality like multi-step forms and API errors:

https://gist.github.com/b2efee056cb66fefc75350223667b1b4

You can get around this complexity by making things less "flat" and using nested state objects. Still, you'll need to work harder and harder to prevent invalid states. This leads to some set-state-hell like this:

https://gist.github.com/78f1b879b23566320b78224a0ffd8e25

If you're not careful, your UI can slide into a state you didn't consider!

cycler-falling-off-handlebars

If anything, we just want to have a boolean with more than 2 values so we're not switching flags all over the place. What's more, we want each of our states to have a meaningful value we can slide into CSS classes, content keys, etc. Luckily, TypeScript gives us such superpowers ๐Ÿ’ช

Our new approach: the poor man's state machine

As you may have guessed, we can solve this boolean bonanza with a simple state machine. I've heard this approach called the "poor man's state machine" which is a super apt title as well!

All we need is the XState library a one-liner to model our state variables as a single type:

https://gist.github.com/f0cd82383a51ff95cb037c30e3558a61

You could certainly use an enum for this as well. We just prefer string literals since they're a bit shorter + more readable.

๐Ÿ’ก Aside: For all you enum stans out there, we also have an article on how string literals improved our experience with the Contentful CMS at Peloton!

With our type defined, we can condense all our state variables into one:

https://gist.github.com/204d4827fb8a9eb8ae3913c4707ef9e6

Now, we can slot this into our existing logic like so:

https://gist.github.com/1003aba742dcb46948e2d3b1884b5e8b

Pretty clean! Now that we it's impossible to be in 2 states at once, we don't need to worry about all the false / true toggling as we go. This should help a lot with readability and scalability for future states.

What's more, we can now model our states with actual, meaningful string values. This is super valuable for CSS class switching without the need for mappers, and it allowed us to map our CMS Content keys super easily:

https://gist.github.com/4fba8ccfc36b80c484a6ae3061e3c348

Since invalidPostalCode corresponds to an actual string, we can just pass our status as a prop directly.

Closing thoughts

Obviously, this post glosses over a lot of implementation details that vary by project. Looking at the bigger picture, this really just replaces each of your on / off booleans with super booleans that can represent any number of states.

Still, this misses some design constraints we might want to model on more complex flows:

  1. Preventing invalid transitions between states. For example, we should only be able to reach the submitted states by passing through the submitting state first. How can we prevent that without a bunch of conditional checks?
  2. Firing side effects as we progress between our states. Sure, we can trigger these by hand whenever we update our status, but this can lead to the same design problems as our boolean example (i.e. forgetting to keep all our logic in check).
  3. Reasoning about our UI without crawling through every line of code. This is especially true when comunicating with designers, who often think in terms of "artboards" users transition between. It would be nice if these flows could be directly translated to code without all the state setting overhead / possible bug reports.

Of course, this is what state management libraries like Redux are for, which is why it's powered onepeloton.com since its inception. Still, Redux doesn't quite address the third concern listed above. It relies on a lot of boilerplate to keep state and type checking under control, and it takes some added work to prevent invalid transitions.

So, we're starting to experiment with the XState library on future projects! We haven't tried it on production code yet, but some initial proofs of concept were super promising. Its visualization tool was especially exciting from a collaboration perspective, since we could get a handy chart that any audience could understand. We'll be sure to update the community as we experiment further ๐Ÿš€

Call to action: Go look for some tangled booleans in your codebase, and try to refactor with one (or multiple) "status" types!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment