Skip to content

Multi-step wizards and embedded forms

Two variations on RenderEdit that don’t fit the default “one entity, one screen, one CRUD write” shape: a form long enough to split into steps, and a form that lives inside something else instead of on its own page.

An entityEdit screen with layout.mode: "wizard" renders one section per step instead of all of them at once, with a progress indicator and per-step validation. draft: true persists the in-progress wizard so it can be resumed after the user navigates away:

r.screen({
id: "listing-wizard",
type: "entityEdit",
entity: "listing",
layout: {
mode: "wizard",
draft: true,
sections: [
{ title: "Basics", fields: ["title", "category"] },
{ title: "Pricing", fields: ["price", "condition"] },
{
kind: "extension",
title: "Review",
component: { react: { __component: "ListingReviewSection" } },
},
],
},
access: editorWrite.access,
});

The third section is kind: "extension" — a review step that mounts a client component by its registered __component name instead of another field section:

export function ListingReviewSection({ values }: ExtensionSectionProps): ReactNode {
return (
<DetailList
testId="listing-review"
rows={[
{ label: "Title", value: String(values?.["title"] ?? "") },
{ label: "Category", value: String(values?.["category"] ?? "") },
{ label: "Price", value: String(values?.["price"] ?? "") },
{ label: "Condition", value: String(values?.["condition"] ?? "") },
]}
/>
);
}

ExtensionSectionProps.values is the same live snapshot the other steps edit — the review step reads it directly, no second fetch and no duplicated state.

The boot-validator requires at least two sections for mode: "wizard", each with a non-empty title. draft: true requires mode: "wizard" and requires the bundled form-draft feature (which itself requires config for its retention-days setting) to be mounted alongside — both checked at boot, not at runtime. The actual save/resume/discard wiring for the draft happens automatically inside RenderEdit; the feature above never calls a form-draft handler itself.

  • draft: true without form-draft mounted fails at boot, not with a runtime error on first save — the missing feature is caught before the app ever serves a request.
  • The review step’s component is a string name, not an import. Nothing in feature.ts imports ListingReviewSection — the two are linked only by the __component name, which keeps the server file free of the renderer-web graph.
  • A "single"-mode layout is still the default choice. Reach for "wizard" when a form is long enough that one page overwhelms the user, or losing everything on an accidental tab close is a real cost. Per-step validation and draft persistence are overhead a short form doesn’t need.

RenderEdit doesn’t require a screen registered via r.screen — a plain EntityEditScreenDefinition literal is enough to host it anywhere, e.g. inside a Drawer next to a list row:

export const prospectAcceptScreen: EntityEditScreenDefinition = {
id: "prospect-accept-form",
type: "entityEdit",
entity: "prospect",
layout: {
sections: [{ fields: ["name", "email", "company", "notes"] }],
},
};

The host component prefills from an externally-sourced record (not the entity’s own detail query), runs RenderEdit in controlled mode to read live dirty/valid state, and saves through customSubmit instead of the built-in CRUD create:

<RenderEdit
screen={prospectAcceptScreen}
entity={prospectEntity}
featureName="prospects"
initial={initial}
entityId={null}
schema={acceptSchema}
onChange={({ dirty, valid }) => setLive({ dirty, valid })}
onControlsReady={(next) => {
controls.current = next;
}}
customSubmit={async (snapshot) => {
const result = await dispatcher.write("prospects:write:prospect:accept", {
suggestionId: suggestion.id,
changes: snapshot.changes,
});
return { validationBlocked: false, ...result };
}}
onSubmit={(result) => {
if (result.validationBlocked || !result.isSuccess) return;
onAccepted(extractProspectId(result.data));
onOpenChange(false);
}}
onCancel={() => onOpenChange(false)}
/>

customSubmit dispatches a custom write handler — prospect:accept merges the caller’s changes onto a suggestion the server already has, stamps fields the client must never set (source, acceptedBy, acceptedAt), and flips the suggestion to accepted, all in the one transaction the built-in CRUD create can’t span:

r.writeHandler(
"prospect:accept",
z.object({ suggestionId: z.uuid(), changes: acceptChangesSchema }),
async (event, ctx) => {
const suggestion = await suggestionExecutor.detail(
{ id: event.payload.suggestionId },
event.user,
ctx.db,
);
if (!suggestion) return failNotFound("suggestion", event.payload.suggestionId);
if (suggestion["status"] !== "pending") {
return failUnprocessable("suggestion_already_processed", { status: suggestion["status"] });
}
const created = await prospectExecutor.create(
{
name: suggestion["name"],
email: suggestion["email"],
company: suggestion["company"],
notes: suggestion["notes"],
...event.payload.changes,
source: `suggestion:${event.payload.suggestionId}`,
acceptedBy: `user:${event.user.id}`,
acceptedAt: getTemporal().Now.instant().toString(),
},
event.user,
ctx.db,
);
if (!created.isSuccess) return created;
// Same ctx.db as the create above — one transaction, so a suggestion
// never ends up "accepted" without a prospect or vice versa.
const flipped = await suggestionExecutor.update(
{
id: event.payload.suggestionId,
version: suggestion["version"] as number,
changes: { status: "accepted" },
},
event.user,
ctx.db,
);
if (!flipped.isSuccess) return flipped;
return created;
},
adminWrite,
);

RenderEdit only reads entity/layout off the screen object — nothing about it touches Nav or the router, so a literal never passed to r.screen works the same as a registered one. Controlled mode reports { dirty, valid } on every keystroke via onChange and hands back patch/validate through onControlsReady, so the host can restore or re-check values from outside without remounting the form. customSubmit replaces the built-in CRUD dispatch entirely — the client sends whatever snapshot.changes contains, and the handler decides what happens with it.

  • entityId={null} plus initial is the create-mode signature — there is no detail-fetch for the entity being created; initial comes from whatever external source prefills the form (here, the suggestion prop).
  • A customSubmit handler must still return the shape onSubmit expects ({ validationBlocked, ...result }) — it fully replaces the built-in submit path, so nothing downstream infers success/failure for you.
  • Use the built-in CRUD submit path instead when the form lives on its own screen and a single-entity write is enough — reach for customSubmit only when saving needs more than one write, data the client can’t be trusted to set, or a source record the entity itself doesn’t own.