Building a Multi step form with XState and React Hook Form

Ato Deshi - Jun 9 - - Dev Community

What will we be building

Recently I needed a multi step form for one of my personal projects https://www.cvforge.app/, in this app users can create resumes. Starting from scratch with a new resume can be quite overwhelming, to help with this I offer users the option to start from an example.

This is what that looks like, when a user creates a new resume they are prompted with this dialog:

CV Forge new resume dialog

When they choose to start from scratch, the dialog closes and nothing happens. When they choose to use an example the content of the dialog should change to a selection form. Which will look something like this:

CV Forge example resume dialog

From here they can either choose an example and continue, or go back to the previous dialog by click "Back".

Getting started

First we install the necessary dependencies, I assume you already have a React project setup so I will skip over that.

npm install @xstate/react xstate react-hook-form
Enter fullscreen mode Exit fullscreen mode

Next we will create our state machine. We have 2 states, an initial state which is the first dialog we show and then an example state which is the second dialog we only show if a user chooses to use an example. You can choose any values you like here, the same goes for the name of the state machine. I chose starterMachine since I use it for the starter dialog.

const starterMachine = createMachine({  
  initial: 'initial',  
  states: {  
    initial: {  
      on: {  
        EXAMPLE: 'example',  
      },  
    },  
    example: {  
      on: {  
        BACK: 'initial',  
      },  
    },  
  },  
});
Enter fullscreen mode Exit fullscreen mode

Per state we define what actions we allow for. So when the state is set to initial we have defined an EXAMPLE event and when this event is triggered we go to the example state. Then for the example state we have defined a BACK event, when this event is triggered we go back to the initial state as shown in the code snippet.

Next we will create a context for our state machine, which will allow us to easily update the state from within any of our components

const { Provider: MachineProvider, useSelector, useActorRef } = createActorContext(starterMachine);
Enter fullscreen mode Exit fullscreen mode

Now it is time to render this all out in some components. Let's start by creating components for our different states in the form. The initial state and the example state in my case.

function InitialState() {
    const { send } = useActorRef();

    return (
        <div>
            // ...
            <Button onClick={() => send({ type: 'EXAMPLE' })}>
                Use an example
            </Button>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

I have appropriately named my component InitialState to represent the initial state. As you can see I retrieve the send method from the useActorRef we get from the context, and use this to update the state of our state machine.

Now let's also create a component to represent the example state, this one includes a form since we will be asking the user for some input. I like to use react-hook-form for this in combination with zod, but this is of course optional.

const exampleSchema = z.object({  
  example_name: z.string({ message: 'Please select an example' }),  
});

function ExampleState() {
    const { send } = useActorRef();

    const form = useForm<z.infer<typeof exampleSchema>>({  
        resolver: zodResolver(exampleSchema),  
    });  

    function onSubmit(data: z.infer<typeof exampleSchema>) {  
      console.log(data) // do something with your data
    }

    <form onSubmit={form.handleSubmit(onSubmit)}>
        // ... input fields here
        <Button type="button" onClick={() => send({ type: 'BACK' })}>  
            Back
        </Button>
        <Button type="submit">  
          Get Started  
        </Button>
    </form>
}
Enter fullscreen mode Exit fullscreen mode

Here we again use the send method to update the state, in this case we send a BACK event in case the user wants to go back to the initial state.

Next we need a component to render out the correct component based on the state, for this you can use a ternary, but I like to use a switch case. Here we will use the useSelector hook we get from the context to read the value of our state

function StateRenderer() {  
  const state = useSelector((state) => state.value);  

  switch (state) {  
    case 'initial':  
      return <InitialState />;  
    case 'example':  
      return <ExampleState />;  
    default:  
      return null;  
  }  
}
Enter fullscreen mode Exit fullscreen mode

And finally we need to wrap all of this in our MachineProvider to ensure all components have access to our context. In my case we will be rendering this in a dialog, but this is of course optional.

export function StarterDialog() {  
  return (  
    <Dialog>  
      <DialogContent>  
        <MachineProvider>
          <StateRenderer /> 
        </MachineProvider>  
      </DialogContent>  
    </Dialog>  
  );  
}
Enter fullscreen mode Exit fullscreen mode

We now have a simple example of how to create a funnel using a state machine with XState in react. Explore the XState documentation to see what more you can do with this extensive library.

Thank you so much for reading, I hope this helps. Let me know if you have any questions or feedback!

Checkout https://www.cvforge.app/ to see it in action and follow me on Twitter/X for more content this https://x.com/atodeshi

. . .