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 ๐ดโโ๏ธ
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:
- You see a loading spinner while we submit your postal code
- You have a thank you message + disabled "submit" button once you're done
- You get an error banner (without the loading spinner!) when you try to submit an invalid postal code
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
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!
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 ๐ช
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.
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:
- 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!