IT Begins logo

Multistep form using Remix

July 3, 2022 — Simon Jespersen

There has been a lot of buzz about Remix on Twitter for a while now, but it wasn't until I started using it for IT Begins that I fell in love with it.

I often try to think about how I can use Remix to solve problems that have been a pain to do with frameworks I have used earlier in my career, and one such thing is a multistep form.

At one of my previous clients, we created a multistep form where users could register boats they own or represent. In the project, we were using Angular. I remember that we were able to create a good user experience, but the code was messy, and we feared it would be a pain point in maintaining the app.

As a fun little weekend project, I decided to put up a CodeSandbox of how it can be done in Remix. It has both client and server validation for each step and uses a couple of advanced components from @headlessui/react.

I built this with a couple of helper libraries for validation; zod and remix-validated-form.
The client-side validation was done using dynamic validators connected to the step of the form we are on, while validation on the server is a combination of the validation for each step.

const steps: Step[] = [
  { title: "Contact", validator: withZod(ContactZod) },
  { title: "Boat", validator: withZod(BoatZod) },
  { title: "Summary", validator: withZod(SummaryZod) },
];

// Server validation
export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const result = await withZod(CombinedZod).validate(formData);
  console.log(result.data);

  if (result.error) {
    return validationError(result.error);
  }

  return redirect(`/projects/${result.data.name}`);
};

<ValidatedForm
  onSubmit={_onSubmit}
  // Client validation for the current step
  validator={steps[currentStep].validator}
  method="post"
  formRef={ref}
>
  ...
</ValidatedForm>;

If we are not on the last part of the form, the submit handler will prevent the default browser behavior and advance to the next form step. The handler will not run if the form isn't valid. The reason we still want to use the same button for going to the next step as we do with submitting is so that each field behaves as it would inside a form. Specifically, the user can press enter while inside a field to advance the form.

const _onSubmit = (_: unknown, event: FormEvent) => {
  if (!isLastStep) {
    // We don't post since we are not on the last step
    event.preventDefault();

    setCurrentStep(Math.min(currentStep + 1, steps.length - 1));
    return;
  }
};

<ValidatedForm
  onSubmit={_onSubmit}
  validator={steps[currentStep].validator}
  // Default behaviour is to post on submit
  method="post"
  formRef={ref}
>
  ...
  <Button type="submit" direction="forward">
    {isLastStep ? "Save" : "Next"}
  </Button>
</ValidatedForm>;

As the Remix form doesn't have an API for getting the current values in the form I created a hook that can be called from the summary page to get the current data from a ref of the form element.

const useFormData = () => {
  const ref = useRef(null);

  const getFormData = () => {
    if (ref.current) {
      return new FormData(ref.current);
    }
    return undefined;
  };

  return {
    ref,
    getFormData,
  };
};

const { ref, getFormData } = useFormData();

<ValidatedForm formRef={ref}>
  ...
  {currentStep === 2 && (
    <div>
      <Summary getFormData={getFormData} />
    </div>
  )}
</ValidatedForm>;

I see this as a good starting point if I ever need to implement it in a project, but there are a couple of things I would like to improve. Getting the form data for the summary is a bit clunky, and keeping the state in the form elements when navigating between the parts works fine here, but maybe this should be handled better. I'm happy to hear suggestions for improvement, feel free to contact me on Twitter at @itsalwayskos.