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 key={suggestion.id} 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:
verbs: { update: false }, }); r.entity("prospect", prospectEntity);
const { executor: suggestionExecutor } = createEntityExecutor("suggestion", suggestionEntity); const { executor: prospectExecutor } = createEntityExecutor("prospect", prospectEntity);
// Custom handler: the client only ever sends the fields the user actually // edited (RenderEdit's onChange `changes`, same delta the controlled mode // reports) — the handler owns merging that onto the suggestion it already // has, so an untouched field never has to round-trip through the browser. 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,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.