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
- First step: call
createFormResponsewith the first answers and noevent. The response is saved as a draft (statusinitialized). Keep the returnedid. - Next steps: call
updateFormResponsewith{ id, attributes }and the next answers. An answer you already saved is changed by passing its field valueid; one without anidis added. - Last step: the same update with
event: 'complete!'. The response becomescompleted, 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) };
}formIdgoes insideattributes, next tofieldValuesAttributes;idandeventsit besideattributeson 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'sidandfield.id, andformResponse.status(initializedwhile it's a draft,completedafter'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.