Skip to content

How do I connect a multi-step form to an existing flat API? ​

Formhaus keeps the answers of every step in one flat object, so a multi-step form can post to an endpoint that expects a single object. Write two plain functions next to your HTTP call: one renames the keys for the request, the other turns the response into a submit result. The backend stays as it is.

The complete app is the react-flat-api example. Its mock API rejects answers on the first and the last step in one response.

Definition ​

json
{
  "id": "flat-api",
  "title": "Open an account",
  "submit": {
    "label": "Open account"
  },
  "steps": [
    {
      "id": "about",
      "title": "About you",
      "fields": [
        {
          "key": "firstName",
          "type": "text",
          "label": "First name",
          "validation": {
            "required": true
          }
        },
        {
          "key": "lastName",
          "type": "text",
          "label": "Last name",
          "validation": {
            "required": true
          }
        },
        {
          "key": "email",
          "type": "email",
          "label": "Email",
          "validation": {
            "required": true
          }
        }
      ]
    },
    {
      "id": "account",
      "title": "Account",
      "fields": [
        {
          "key": "accountType",
          "type": "radio",
          "label": "Account type",
          "validation": {
            "required": true
          },
          "options": [
            {
              "value": "personal",
              "label": "Personal"
            },
            {
              "value": "business",
              "label": "Business"
            }
          ]
        },
        {
          "key": "companyName",
          "type": "text",
          "label": "Company name",
          "validation": {
            "required": true
          },
          "show": [
            {
              "field": "accountType",
              "eq": "business"
            }
          ]
        }
      ]
    },
    {
      "id": "address",
      "title": "Address",
      "fields": [
        {
          "key": "street",
          "type": "text",
          "label": "Street",
          "validation": {
            "required": true
          }
        },
        {
          "key": "postcode",
          "type": "text",
          "label": "Postcode",
          "validation": {
            "required": true,
            "pattern": "^[0-9]{5}$",
            "patternMessage": "Enter 5 digits"
          }
        }
      ]
    }
  ]
}

Adapter ​

The API names the fields first_name and postal_code and answers 422 with { errors: { first_name: ['Use letters only'] } }. The adapter maps the keys out and back:

ts
import type { SubmitResult } from '@formhaus/core';

const apiKeys: Record<string, string> = {
  firstName: 'first_name',
  lastName: 'last_name',
  email: 'email',
  accountType: 'account_type',
  companyName: 'company_name',
  street: 'street',
  postcode: 'postal_code',
};
const formKeys = Object.fromEntries(Object.entries(apiKeys).map(([form, api]) => [api, form]));

export function toRequest(values: Record<string, unknown>) {
  return Object.fromEntries(Object.entries(values).map(([key, value]) => [apiKeys[key] ?? key, value]));
}

export async function submitAccount(values: Record<string, unknown>): Promise<SubmitResult> {
  const response = await fetch('/api/accounts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(toRequest(values)),
  });
  if (response.ok) return { ok: true };
  const body = await response.json().catch(() => ({}));
  if (response.status !== 422 || !body.errors) return { ok: false, formErrors: [body.message ?? 'We could not open your account'] };
  const errors: Record<string, string> = {};
  for (const [key, messages] of Object.entries<string[]>(body.errors)) errors[formKeys[key] ?? key] = messages.join(' ');
  return { ok: false, errors };
}

toRequest() receives only the answers on the active path, so a hidden companyName is never sent. An error key with no mapping, such as account, passes through unchanged and shows above the buttons. When fetch itself fails, the function throws: the form keeps the answers and shows the failure.

React ​

tsx
import { FormRenderer } from '@formhaus/react';
import { parseDefinition } from '@formhaus/core';
import account from './account.json';
import { submitAccount } from './adapter';

const definition = parseDefinition(account);

export function AccountForm({ onOpened }: { onOpened: (email: string) => void }) {
  return (
    <FormRenderer
      definition={definition}
      onSubmit={submitAccount}
      onAfterSubmit={(values) => onOpened(String(values.email))}
      errorSummary
      stepErrorMarks
    />
  );
}

Vue ​

vue
<script setup lang="ts">
import { FormRenderer } from '@formhaus/vue';
import { parseDefinition } from '@formhaus/core';
import account from './account.json';
import { submitAccount } from './adapter';

const definition = parseDefinition(account);
const emit = defineEmits<{ opened: [email: string] }>();
</script>

<template>
  <FormRenderer
    :definition="definition"
    @submit="submitAccount"
    :on-after-submit="(values) => emit('opened', String(values.email))"
    error-summary
    step-error-marks
  />
</template>

What the user sees ​

  1. The user fills three steps and submits once. The API gets one flat object.
  2. The API rejects first_name and postal_code. The form opens the first step, focuses First name, and keeps every answer. The summary links to both fields and the step list marks both steps.
  3. The user fixes First name. Its error goes; the postcode error stays on the last step.
  4. The user fixes the postcode and submits again. onAfterSubmit runs once.

Errors that arrive for a field the user has edited since the request was sent are dropped. A second click while the request runs sends nothing. If the form unmounts first, the response is ignored. The full rules are in the specification.

Formhaus is MIT licensed. Legal