uidudevelopers
Recipes

Multi-step forms

Build a multi-step submission flow with createFormResponse + updateFormResponse, saving a draft at every step.

Long forms break into steps. Don't gate the visitor on a single final submit: save each step as a draft response, and complete it on the last one.

The pattern

  1. First step: call createFormResponse with the first answers and no event. The response is saved as a draft (status initialized). Keep the returned id.
  2. Next steps: call updateFormResponse with { id, attributes } and the next answers. An answer you already saved is changed by passing its field value id; one without an id is added.
  3. Last step: the same update with event: 'complete!'. The response becomes completed, and that is what announces it to the workspace. 'complete!' is the only event, and it fires once.

Both mutations are writes, so they need the client with the API key and run in server actions, never in the browser. See Authentication.

The server actions

// app/apply/actions.ts
'use server';

import { createFormResponse, updateFormResponse } from '@uidu/client';
import { uidu } from '@/lib/uidu'; // createClient({ workspace, publicToken, apiKey })

export type Answer = { fieldId: string; value: unknown };

export type StepResult =
  | { ok: true; responseId: string; fieldValueIds: Record<string, string> }
  | { ok: false; errors: Array<{ key?: string | null; name?: string | null; type?: string | null }> };

// field id → field value id, so the next save updates an answer instead of adding a second one.
function idsByField(
  fieldValues: Array<{ id: string; field: { id: string } }> | null | undefined,
): Record<string, string> {
  return Object.fromEntries((fieldValues ?? []).map((fv) => [fv.field.id, fv.id]));
}

export async function saveStep(
  formId: string,
  draft: { responseId: string; fieldValueIds: Record<string, string> } | null,
  answers: Answer[],
  last: boolean,
): Promise<StepResult> {
  const event = last ? 'complete!' : undefined; // no event = stays a draft

  const fieldValuesAttributes = answers.map((a) => ({
    id: draft?.fieldValueIds[a.fieldId], // set → update the saved answer; undefined → add one
    fieldId: a.fieldId,
    content: { value: a.value }, // always { value }, for every field kind
  }));

  const result = draft
    ? await updateFormResponse(uidu, {
        input: { id: draft.responseId, event, attributes: { formId, fieldValuesAttributes } },
      })
    : await createFormResponse(uidu, {
        input: { event, attributes: { formId, fieldValuesAttributes } },
      });

  if (result?.errors?.length) return { ok: false, errors: result.errors };
  const response = result?.formResponse;
  if (!response?.id) return { ok: false, errors: [{ name: 'No response returned' }] };

  return { ok: true, responseId: response.id, fieldValueIds: idsByField(response.fieldValues) };
}
  • formId goes inside attributes, next to fieldValuesAttributes; id and event sit beside attributes on the input.
  • Validation failures come back in errors, not as a throw — the draft is unchanged when they do. See Writes.
  • Every payload returns formResponse.fieldValues[] with each value's id and field.id, and formResponse.status (initialized while it's a draft, completed after 'complete!').

The client component

The browser only holds the draft's ids and calls the actions; the questions come from getForm (form.inputs), read with the public token in the page that renders this.

'use client';

import { useState } from 'react';
import { saveStep, type Answer } from './actions';

type Draft = { responseId: string; fieldValueIds: Record<string, string> };

export function MultiStepForm({ formId, steps }: { formId: string; steps: number }) {
  const [draft, setDraft] = useState<Draft | null>(null);
  const [step, setStep] = useState(1);
  const [done, setDone] = useState(false);

  async function submitStep(answers: Answer[]) {
    const last = step === steps;
    const result = await saveStep(formId, draft, answers, last);
    if (!result.ok) {
      // show result.errors next to the fields
      return;
    }
    setDraft({ responseId: result.responseId, fieldValueIds: result.fieldValueIds });
    if (last) setDone(true);
    else setStep(step + 1);
  }

  if (done) return <p>Thanks — your answers are in.</p>;
  return <Step n={step} onNext={submitStep} />; // your per-step fields
}

Split the questions into steps however suits the form — by position, or by groups you define — and pass each step's answers as { fieldId: question.field.id, value }.

Resuming a session

To let a visitor come back later, keep the draft (responseId and the fieldValueIds map) in a cookie keyed by formId, set from the server action, and start the component from it. @uidu/client has no function that reads a form response back, so keep the answers you want to prefill on your side too (the same cookie, or your own storage).

Validation per step

Validate each step on the client before calling saveStep — e.g. react-hook-form with mode: 'onBlur' and handleSubmit(onValid) — so the visitor doesn't wait for a round trip on every field. The server is still the source of truth: show the errors it returns.

The shape of every answer and the response lifecycle: Forms. The { value } rule in full: Pagination & field values.

On this page