Skip to content

How do I build a multi-step form with branching in React or Vue? ​

With Formhaus, the branching lives in the JSON definition: each step lists routes, and the first route whose show conditions match picks the next step. FormRenderer from @formhaus/react or @formhaus/vue renders the steps, validates each one on Continue, handles Back and Skip, and submits only the answers on the path the user took.

Definition ​

The service answer sends the user to a design or a development step. Both branches converge at an optional budget step, then contact details.

json
{
  "$schema": "https://formhaus.dev/schema/form-definition.json",
  "id": "project-inquiry",
  "title": "Start a project",
  "submit": { "label": "Send inquiry" },
  "steps": [
    {
      "id": "service",
      "title": "What do you need?",
      "fields": [
        {
          "key": "service",
          "type": "radio",
          "label": "Service",
          "validation": { "required": true },
          "options": [
            { "value": "design", "label": "Design" },
            { "value": "development", "label": "Development" }
          ]
        }
      ],
      "routes": [
        { "to": "design", "show": [{ "field": "service", "eq": "design" }] },
        { "to": "development", "show": [{ "field": "service", "eq": "development" }] }
      ]
    },
    {
      "id": "design",
      "title": "Design scope",
      "fields": [
        { "key": "pages", "type": "number", "label": "Number of screens", "validation": { "required": true, "min": 1 } },
        { "key": "hasBrand", "type": "switch", "label": "We have brand guidelines" }
      ],
      "routes": [{ "to": "budget" }]
    },
    {
      "id": "development",
      "title": "Development scope",
      "fields": [
        {
          "key": "platform",
          "type": "select",
          "label": "Platform",
          "validation": { "required": true },
          "options": [
            { "value": "web", "label": "Web" },
            { "value": "ios", "label": "iOS" },
            { "value": "android", "label": "Android" }
          ]
        },
        { "key": "repoUrl", "type": "text", "label": "Existing repository URL" }
      ],
      "routes": [{ "to": "budget" }]
    },
    {
      "id": "budget",
      "title": "Budget",
      "skip": { "label": "Not sure yet" },
      "fields": [
        {
          "key": "budget",
          "type": "radio",
          "label": "Budget range",
          "validation": { "required": true },
          "options": [
            { "value": "small", "label": "Under $10k" },
            { "value": "medium", "label": "$10k to $50k" },
            { "value": "large", "label": "Over $50k" }
          ]
        }
      ]
    },
    {
      "id": "contact",
      "title": "Contact details",
      "back": { "label": "Edit answers" },
      "fields": [
        { "key": "name", "type": "text", "label": "Name", "validation": { "required": true } },
        { "key": "email", "type": "email", "label": "Work email", "validation": { "required": true } }
      ]
    }
  ]
}

React ​

tsx
import { FormRenderer } from '@formhaus/react';
import type { FormDefinition } from '@formhaus/core';
import inquiry from './project-inquiry.json';

const definition = inquiry as FormDefinition;

export function ProjectInquiry() {
  return (
    <FormRenderer
      definition={definition}
      onStepChange={(stepId, direction) => console.log(stepId, direction)}
      onSubmit={async (values) => {
        await fetch('/api/inquiries', { method: 'POST', body: JSON.stringify(values) });
      }}
    />
  );
}

Vue ​

vue
<script setup lang="ts">
import { FormRenderer } from '@formhaus/vue';
import type { FormDefinition } from '@formhaus/core';
import inquiry from './project-inquiry.json';

const definition = inquiry as FormDefinition;

async function save(values: Record<string, unknown>) {
  await fetch('/api/inquiries', { method: 'POST', body: JSON.stringify(values) });
}
</script>

<template>
  <FormRenderer :definition="definition" :submit-handler="save" />
</template>

How it works ​

  • routes on the service step are checked in order. { "field": "service", "eq": "design" } sends the user to design; the development route matches the other answer.
  • design and development each end with { "to": "budget" }. Without that route, design would fall through to the next declared step, development, and both branches would run.
  • Route targets must be later declared steps. Backward targets and unknown step ids throw when the engine is created and are reported by validateDefinition().
  • skip on budget renders a Skip button labelled "Not sure yet". Skip resets the step to its defaults and leaves its fields out of validation and the submitted values.
  • back on contact relabels the Back button. Back follows the path the user took, so from contact it returns to budget, then to the branch step.
  • Progress shows "Step N of 4" for either branch. Before the service is answered the path is provisional.
  • Answers from a branch the user left stay in engine.values as a draft but are not validated or submitted. Choosing development, filling the platform and switching to design submits pages, not platform.