Becoming a Form Wizard: Intuitive Multi-Step Workflows

Bookmark

Forms are a core part of many applications and complex actions are often broken up into multiple forms as steps in this workflow. Using React’s Context API and a conventional state machine, we can build a reusable system for building these wizards and make the web a bit more magical.

by



Transcription


♪♪ ♪♪ Welcome to Becoming a Form Wizard, a talk about building intuitive, multi-step workflows powered by state machines. My name is Nick Hare. I'm a staff software engineer on the front-end platform team at Betterment in New York City. In the past, I've helped organize events and meetups such as Manhattan.js, and now I organize an internal talk series at Betterment called BetterDev. On the side, I like to tinker with embedded JavaScript. I'm also a fan of cycling outdoors and climbing indoors. You can find me online at hipsterbrown.com or at hipsterbrown on Twitter and GitHub. On our journey today, we're going to learn all about what is a wizard, how they're typically created, how state machines can help make them better, what we can use to implement them, and where we go from here. Wizards have been around for many years. While building applications, requesting user input to create records or perform actions is not always as simple as a form on a single page. Many user experiences require customers to click through multiple steps to submit all the information. This UI pattern is often called a wizard, a term dating back to the late 80s and early 90s to indicate step-by-step guides that are designed to walk you through a complex task. A popular source of this phraseology is the Microsoft Page Publisher wizard feature and the Connection wizard. However, the name usually reminds me of this wizard. Many services and apps will break up the registration process into a series of steps to make this feel easier, especially if there's quite a bit of information to gather in order to get started. Filling out long forms can be incredibly tedious, so wizards provide an experience where people can focus on the individual pieces of those forms with a sense of accomplishment with each step forward. Based on choices made through this process, the wizard can even help people skip irrelevant paths and potential confusion. I think we've seen those sections on tax forms with a bunch of fine print conditionals that eventually lead to ignoring it altogether. This is what we want to avoid. This can be overwhelming, distracting, and even, as I said before, very tedious to go through. This is what we want to see instead. At Betterment, we call this pattern a flow or workflow, and we use it quite a lot. As a financial services company, there's a lot of information and complex concepts to cover. We want to ensure our clients understand these decisions. Along with the usual sign-up task, we formalize creating goals, moving money, connecting external accounts, and more, all as flows. Given how many we've had to build and maintain, it makes sense to have some common conventions and utilities for our teams to use. So how are they typically built? If you've ever had to tackle this pattern in the past, you might have searched for multi-step forms or a form wizard for X, where X is the framework of choice for your product. I know I definitely looked around for such a solution when starting this journey at Betterment. Naturally, I looked up to the docs and examples for Formic, the form state manager of choice for React at Betterment. Looking at the Formic repo, we can find an example for a multi-step wizard that creates an abstract wizard component with wizard stepchild components. This allows for nice composition with splitting up a large form. And it could be reused across features as long as they use the same layout. One notable downside is the lack of routing, so the current step in the flow is lost if the page is reloaded. And we can see our top-level wizard component here, where we have an array of steps, and we have some next and previous actions in order to move through the wizard. We have our submission handler, which will allow the step to declare its own on submission. And we'll move forward to the next step, unless we're on the last step in which we perform a final submission. And it's all collected under a single Formic context with individual steps displayed and our back and submit buttons performing either the previous or next actions. And then, as I said before, it can all be composed under the single wizard, wizard steps, and then each step declaring the fields in which the values that they care about are being gathered. Now, in the same repo, we can find examples of routed multi-step forms, one using number steps and the other using name steps. The numbered steps provide a simpler composition benefit as the first example, although numbers aren't too helpful as path names to communicate the intent of the step. With name steps, the ability to customize validation and form submission per step is lost. So we can see here our specific path locations for each page. We see the path name, the path locations for each page. We still have a wizard base and now a page component associated to pass through children instead of a step component. We still have our next and previous actions, some validation, handle submission, which progresses us forward next. And then we have some logic in order to display our high-level Formic context, our form, the active page, and then that previous and submission action again, all being composed under a single route with a wizard and their individual wizard pages collecting all the fields in which they care about. And we can see our named pages here, which have individual page components, which now link to the next or even the previous step within the wizard, all collected as individual routes under the high-level router around a single form. The greatest weakness with each of these examples is how tied they are to Formic, which ultimately makes sense as these are examples for this library. However, many approaches found online will promote similar patterns, especially with unrouted steps. They are all generally focused on moving sequentially or in the order in which they're declared. It's not easy to see how conditional paths can be integrated. Looking at the purpose-built libraries, nothing appeared to solve all the concerns of building flows at Betterment. So that's when I set out to solve it myself. To help direct the work towards building this new solution, I came up with three primary directives. The first maintains the sense that good web experiences have URLs and aligned with the existing Rails-driven flows we built in the past. The second was learning from those existing flows that made it complex to maintain them overall. The last one came from looking at our needs at Betterment and how wizards may gather information, but it's not always required per step. So this is the dream API that we're going to implement. It looks a lot like what we saw before with the Formic examples, where we have a high-level wizard, and individual step components that will present a set of fields or whatever they wish to do. And these ones have names versus numbers or being unnamed at all. And we're going to use this set of supplies in order to do it. We have our React context, the user reducer hook from React, route components from React Router, and a custom hook to pull it all together. So we'll start with our React context to collect all the information we need to share between the high-level wizard and the individual steps. In our wizard component, we're going to iterate over the children in order to grab ones that match our step component that we'll build out, and look for the name prop and collect our list of names as our steps. We're going to set the default step as the first one in that list and then declare our reducer. The wizard reducer we'll look at next, but we're setting the initial state to the current step as the first step in the flow, the initial values to help seed any initial data that we're gathering in the flow, and that list of steps as well. And the value returned by user reducer will be set as the wizard context provider. We're going to display all the children passed through our wizard component and then initial redirect from the index of this feature to the default step. So anything directing or linking into this feature does not have to know what the first step is. Now our wizard reducer is fairly simple as reducers go. We have two actions, which contains the sync to update our current step and then update values in which to, as the name suggests, update the values we're gathering throughout the lifetime of our wizard. And here's the step component we were referencing before. So we have our component prop and our name, and then we're going to pull in our use wizard context hook we'll declare next in order to spread this state as props on our components. This is similar to other APIs that React router uses in the past, and we're going to map the name of our step as the path of our route. And we'll see how useful having the state mapped to props on our component can be next. So in our wizard context hook, we're going to, again, pull in that context using the use context hook from React. And as the result of user reducer, we're going to get the wizard state and that dispatch function in order to send actions to our reducer. We're going to wrap these dispatch actions into our own custom functions just to make it easier for anything consuming this hook to call them. So we have our sync action, our update values action, and then we have something called go to step, which will use both of those in order to either call update values if we have them and then call sync and then push forward in history or just push and navigate to the next step or previous step depending on how it's used. And so our next step will look for making sure that we're not on the last step in the wizard and then call go to step if we're not versus the previous step, which will make sure we're not on the first step and allow us to call previous if that's the case. And then we'll return it all as a result of our hook. And so here's that API again. Let's take a look at how an initial step can compose this. As we declared as part of what are our directives, it can be form agnostic. And so we have go to next step, go to previous step and wizard state all coming from that hook passed through the step component itself. But again, we could use that hook if we wanted to directly. This cleans up a little bit of boilerplate and having to always pull in that hook and allows for a bit of a dependency injection in case you want to test this individually later. And so our handle submit function just uses a regular form and we're going to grab all the form values from the HTML form using the new form day API and this object from entries method and then call go to next step with that object of values. We can even use existing values from wizard state in order to set the default on our input and then very familiar looking actions such as our previous button and submit in order to either go backwards or forwards. If we want to clean up some of the boilerplate, we can use formic and our go to next step maps exactly onto the on submit prop for formic and our wizard state values is just an object. So we can use that to see the initial values formic. And then we have our field, which will automatically inherit the and be bound to that first name value within the wizard state values and the actions can remain the same because they are just a button and a submission handler. And then no form is needed at all, because if we just want to display wizard state, then we can display it as we like. Now, the solution appears to cover most of our usage and needs. However, we still don't have a way of providing conditions without having to maybe update something specific within the individual step components rather at this high level. So what happens when displaying a step or set of step is dependent on a choice made in an earlier part of our wizard. And how do state machines help us solve this problem? So for folks unfamiliar with state machines, they're formally known as finite state automata. They provide a mathematical model of computation that describes the behavior of a system that can only be displayed one state at a given time. And the ability to transition between these states is determined by specific events. Now, if we wanted to relate that directly into a wizard, we can say a wizard is an interactive experience that can display exactly one of a finite number of steps at a given time. The wizard can change from one step to another in response to some external inputs or events. The change from one step to another is called navigation. A wizard is defined by a list of steps, its initial step, and the conditions for each navigation. And we can use this idea of modeling our wizards at state machines in the form of state diagrams. So this state diagram is built using text and Mermaid.js in order to generate the visualization. And so we can see our individual states or steps start, about, and complete, can perform transitions or navigations using next or previous in order to go forward or backwards. And this allows us to even use things like the stately AI visualizer to visualize this in a drag and drop or even code-based manner. As I said before, we can control the conditions of each navigation. So looking at a modified version of that diagram we saw before, we can see that start receives a next event and then may progress to either the Gandalf or Merlin based on what is known as a guard. And so this guard looks at some logic to see if we have an interest in Gandalf in order to progress there or otherwise falling back to Merlin. And each of those steps will progress next to complete. And here it is again using the stately AI visualizer to give us another viewpoint. And we see that guard declared in that next to Gandalf. And this always makes me think of how Gandalf will help guard the path for the journey to wherever they're going. And so we can create this within our wizard abstraction using a function that takes in the current values of the wizard and any next state passed to go to next step. And looking at the values in the next state, if we have an interest in Gandalf, then it will return true. We can declare the list of available next steps on our start component, overriding the default behavior of going in sequence in which they're declared. And so in that array, we can also declare another array as the tuple of logic. And so we have our Gandalf when, and we'll go there when we're interested in Gandalf. Otherwise we'll fall back to the Merlin step. And on the Merlin and Gandalf steps, we can override that default behavior of next and previous by just declaring what we want to go to next without any sort of conditions. And you can see a similar pattern being used by Cassidy Williams in her talk, choosing your own adventures in Next.js where she uses X state in order to do a similar journey through Next.js. So we went through building our wizard using just React and a set of other utilities within there, but we didn't see how we could integrate a state machine within that pattern. Well, in order to make all that easier, I've created a library called RoboWizard. And RoboWizard is all about building intuitive multi-step workflows backed by a state machine. As we just saw here, it doesn't rely on anything to do with React or any specific framework or form. So you can bring your own UI and form manager, even if it's just HTML. And we're using X state FSM, which is a subset of the wider X state package in order to power all of this underneath a intuitive API. And so this was originally inspired by a package called robot, which allowed us to power all of this using functional composition. However, I eventually switched it over to using X state FSM in order to access the wider community of X state and the visualizer and all the APIs that it provides. RoboWizard at its core looks like this. We have a create wizard function, which takes in a list of steps and then any initial values that we want to gather throughout the lifetime of that wizard. And when we start the wizard, we can list for any changes to the step and current values. We can call go to next step, go to previous step, all familiar looking for what we saw before. And there are currently three official packages for RoboWizard, including this core package we see here, the RoboWizard React integration and the RoboWizard React router integration for those routed navigable steps. However, there are a handful of example projects within the repo to show how RoboWizard can be integrated into Vue, Svelte, Alpine, and again, plain old HTML. So why don't we take a look at what it looks like to create this machine using the X state machine configuration by yourself. If you wanted to mostly recreate RoboWizard, this is how you would do it. That one line in the preview example creates this complete JSON object. We have our JavaScript object, not just JSON. We have initial step here. We have our context of the values we're gathering, our individual states, what events will trigger going to the next state or step, and the actions we take upon that event and transition. And so we can see here you can go next to about, and we'll update values along that. Previous from about will go back to start, so on and so forth. So let's see a practical example of RoboWizard in the browser. So this is RoboWizard React router integration using code sandbox demo, and we're going to gather some signup values for email, password, first name, last name, phone number, and term agreements. We're using Chakra UI React in order to provide a little bit of nice UI through this demo. And we can see our step and wizard components being pulled in from our shared package, and we're going to set some initial values and then declare our list of steps, all going in order and not overriding any of the initial behavior. If we look at an individual step, such as our email, we can see we're pulling in the use wizard contact hook, very familiar to what we built before. And on that wizard, we can grab current values for initial values, and our submission handler we're going to call go to next step with a values field called values. And we see we're still using the regular field components and a submit button. Anything that wants to go backwards, we can set a backwards go back button. And so looking at submitting this, let's create some initial values here. And so we've progressed forward on submission. I can go back using this back button. I can continue forward. I can also use the browser back buttons and forward buttons in order to keep track of our history. And so within this wizard, we can sign up and become a RoboWizard. So filling out a couple of values here. Who knows why a wizard might need a phone number. We can see our big long terms, Laura Mimpson, in order to continue, we'll hit continue and progress forward. And we see here, we've stopped, we're at the end, we're on success, we have now become a forum wizard. We can receive letters and alerts and then learn more about this magic at RoboWizardJS.org. Now, if we go back, we can see if we don't keep our values of agreeing to terms, we can still continue forward. Now, what if we wanted to send them in a completely different direction if they don't agree to the user agreement? Well, let's do that without touching any of the logic and the individual steps themselves. So I'm going to go over here and through some magic demo, I'm going to change some of the steps here to save us some time. And I'm going to pull on a pre-created draft components and then also grab my guard function. And this guard function looks at the next values being passed through and then looks for if we agreed to the terms. It's being very explicit about it, we could just return the Boolean that's existing there. And then we're going to pull in our when guard helper from RoboWizard. And so let's go through this again without changing any of the existing steps, just staying at this high level. So dof.gray, pick something super secure. I'm just going to shorten this a little bit. And then if we don't agree to our lorem ipsum, we continue forward and, oh no, drat, the journey ends here. So shall we try again? Go back and continue. And now we've successfully submitted and become a foreign wizard. And so where do we go from here? Well, a wizard's journey is never done. So how can we communicate better about wizards or communicate about wizards better? Well, we can learn to visualize all this wizard behavior using tools like the Mermaid.js diagrams or the stately AI visualizer. And we can build upon that to create a shared language that all people that go into building these successful features can understand, product design and engineering. And we can add things like entry and exit steps, which allow us to define where we can go into the flow or the wizard and how we can exit. So things like redirects don't exist within the individual steps themselves, but again, at that top level. We can define final steps, which allow us to pause or prevent any other navigation from that step, which means you can't go backwards again or you can never go forwards in the case of success or where you don't want to resubmit some sort of action or in the case of unrecoverable errors where they need to restart in order to try again. We can create branching flows or nested services in the case of something like XState, which allows us to pick a fork in the road and go to a completely different ending than when any other sort of condition would. And we can also trigger server-driven transitions. So before we saw how this integrated with the client side and client side navigation, what about something driven by Next.js or Remix or even some other general server back end? It doesn't even have to be done with JavaScript. However, XState makes this much easier to do altogether. And if all this sounds interesting to you, you can come help. We still need help with RoboWizard to build out official integrations for all those other frameworks people want to do, try all the different use cases for this library and learn all we can do to build out a better experience this way. And so thank you and happy building. You can find out more at RoboWizard.js.org or at HepsAround on Twitter. Thank you. ♪♪♪
26 min
21 Jun, 2022

Check out more articles and videos

We constantly think of articles and videos that might spark Git people interest / skill us up or help building a stellar career

Workshops on related topic