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:
- The traditional, “booleans-everywhere” approach used to manage submission and error states
- A cleaner state-driven approach that just takes a few lines of JavaScript, and works even better with TypeScript
- 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.
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:
- They should see a loading spinner while we submit their postal code
- They should see a thank you message + disabled “submit” button once they’re done
- They should get an error banner (without the loading spinner!) when they try to submit an invalid or empty postal code
This is the entrypoint to our Tread mailing list to keep new and prospective members up-to-date as the product rolls out. So, we need to make this experience as frictionless as possible to prevent page bouncing (i.e. use accessible tab indexing and clear button states), while also covering all our edge cases (i.e. error states). Yes, something as simple as a forgotten loading state could motivate members to click away!
Note: React Suspense could be applicable here
You may have noticed this follows an initial ->loading -> success/ failure pattern, which React Suspense (unreleased as of this writing) is great at handling.
However, we wanted to take a simplified, framework-agnostic approach that’s scalable to future states. For example, though Suspense is great at managing data fetching, it still requires some overhead for discrete error states and the like. So we’ll ignore this abstraction for the sake of simplicity, but know it could be applicable in your own projects!
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:
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:
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:
Cool! All we need is some ZIP code validation and we’re good to go:
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:
What’s more, our boolean soup could start to overflow as we tack on new functionality like multi-step forms and API errors:
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:
If you’re not careful, your UI can slide into a state you didn’t consider!
If anything, we just want a variable to represent 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 a one-liner to model our state variables as a single type:
You could certainly use an enum for this as well. We just prefer string literals since they’re a bit shorter and 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:
Now, we can slot this into our existing logic like so:
Pretty clean! Now that 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:
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 enums that can represent any number of states.
Still, this misses some design constraints we might want to model on more complex flows:
- Preventing invalid transitions between states. For example, we should only be able to reach the
submitted
states by passing through thesubmitting
state first. How can we prevent that without a bunch of conditional checks? - 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). - 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!