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.
Prerequisites
Section titled “Prerequisites”- You’ve read Features and composition so
r.screen,r.crudand the feature body are familiar. - You’ve read Commands and queries for the
writeHandlershape used by the embedded-form example.
Multi-step forms with mode: "wizard"
Section titled “Multi-step forms with mode: "wizard"”The code
Section titled “The code”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.
What the framework does
Section titled “What the framework does”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.
Common gotchas
Section titled “Common gotchas”draft: truewithoutform-draftmounted 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
componentis a string name, not an import. Nothing infeature.tsimportsListingReviewSection— the two are linked only by the__componentname, 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.
Embedding a form without a screen
Section titled “Embedding a form without a screen”The code
Section titled “The code”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, );What the framework does
Section titled “What the framework does”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.
Common gotchas
Section titled “Common gotchas”entityId={null}plusinitialis the create-mode signature — there is no detail-fetch for the entity being created;initialcomes from whatever external source prefills the form (here, thesuggestionprop).- A
customSubmithandler must still return the shapeonSubmitexpects ({ 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
customSubmitonly 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.
Live example
Section titled “Live example”- Wizard form recipe — the full
mode: "wizard"feature above, plus the entity and e2e tests. - Embedded entity form recipe —
the full
RenderEdit-in-a-Drawer feature above, plus both entities.
See also
Section titled “See also”- Features and composition —
r.screen,r.crudand the feature body this builds on. - Commands and queries — the
writeHandlershape behindcustomSubmit. - UI widgets — the wider widget kit
RenderEdit’s host components are built from.