Product workflows
Use this tutorial when one form spans multiple product screens. You will keep one "Form, Please" binding and add application-owned workflow state around it.
At the end, the workflow will provide:
- typed screen paths and conditional screens;
- current-screen validation and progress;
- navigation to the first invalid screen;
- review and confirmation screens;
- draft-safe navigation guards;
- validated server issue mapping;
- draft, publish, and save-and-close actions.
Keep each state with its owner
Use the Standard Schema for submitted domain input. Use React state for the current workflow screen. Pass the screen through form context when a definition must change field visibility.
| State | Owner |
|---|---|
| Editable domain input | React Hook Form and the Standard Schema |
| Current screen and navigation history | Application state or the router |
| Screen visibility | Form context and definition resolvers |
| Draft storage | form-please/persistence and the application adapter |
| Requests and confirmation receipts | Application request state |
Put a workflow value in the schema only when the server must store that value
as domain data. For example, an approval status can be domain data. A current
wizard screen is normally not domain data.
The makerspace example follows this boundary. It
stores screen in React state and supplies it through form context.
1. Add a typed workflow controller
Copy this complete controller and example into application code:
"use client"
import type {
,
,
,
,
,
} from "form-please"
import { } from "form-please/preset-native"
import { , , , } from "react"
import { type , } from "react-hook-form"
import { } from "zod"
export type <
extends ,
extends string,
> = <{
/** Stable application screen identifier. */
:
/** User-facing progress label. */
: string
/** Schema input paths validated before this screen can be left. */
: readonly <>[]
/** Includes the screen only while the current input satisfies this condition. */
?: (: <>) => boolean
}>
type < extends > = <
<>,
>
type <
extends ,
,
extends string,
> = <{
: <, >
:
(: <>): void
: readonly <<>, >[]
}>
/**
* Application-owned workflow navigation over one Form Please binding.
* The Standard Schema remains the only validation contract.
*/
export function <
extends ,
,
extends string,
>({
,
,
,
,
}: <, , >) {
type = <>
type = <>
const = ({ : .. }) as
const = <<{
:
:
}> | null>(null)
const = .(
() => .?.( as <>) ?? true,
)
const = .(() => . === )
const = []
(() => {
const = .
if ( === null || . !== ) return
. = null
..(.)
}, [, ])
function (: <, >, : ) {
if (. === ) {
..()
return
}
. = { , : . }
(.)
}
function (: readonly []) {
for (const of ) {
..(, ..(), { : true })
}
}
async function (
: <, >,
): <boolean> {
if (.. === 0) return true
(.)
const = await ..([....])
if () return true
const = ..(
() => ..().,
)
if ( !== ) (, )
return false
}
async function (): <boolean> {
if ( === ) return false
return ()
}
async function (): <boolean> {
if ( === || !(await ())) {
return false
}
const = [ + 1]
if ( === ) return false
(.)
return true
}
function (): boolean {
const = [ - 1]
if ( === ) return false
(.)
return true
}
async function (): <boolean> {
const = [...new (.(() => .))]
if (. === 0) return true
()
const = await ..()
if () return true
for (const of ) {
const = ..(
() => ..().,
)
if ( !== ) {
(, )
break
}
}
return false
}
const = .
let = 0
if ( >= 0) = + 1
let = 0
if ( > 0) = .(( / ) * 100)
return {
,
,
,
: {
,
,
,
},
,
,
,
} as
}
const =
.({
: .().(1, "Enter a name"),
: .("Enter a valid email"),
: .(),
: .(),
: .().(1, "Enter a department"),
})
.((, ) => {
if (. && ..() === "") {
.({
: "custom",
: "Enter the organization name",
: ["organizationName"],
})
}
})
type = <typeof >
type = "identity" | "organization" | "details" | "review"
type = { readonly : }
const = [
"name",
"email",
"organization",
] as satisfies readonly <>[]
const = [
"organizationName",
] as satisfies readonly <>[]
const = [
"department",
] as satisfies readonly <>[]
const = [
{ : "identity", : "Identity", : },
{
: "organization",
: "Organization",
: ,
: () => .,
},
{ : "details", : "Details", : },
{ : "review", : "Review", : [] },
] satisfies readonly <, >[]
const = .<>()
const = .(, {
: [
{
: "field",
: "name",
: "text",
: "Name",
: (, { }) =>
. === "identity" || . === "review",
},
{
: "field",
: "email",
: "text",
: "Email",
: { : "email" },
: (, { }) =>
. === "identity" || . === "review",
},
{
: "field",
: "organization",
: "checkbox",
: "I represent an organization",
: (, { }) =>
. === "identity" || . === "review",
},
{
: "field",
: "organizationName",
: "text",
: "Organization name",
: (, { }) =>
. &&
(. === "organization" || . === "review"),
},
{
: "field",
: "department",
: "text",
: "Department",
: (, { }) =>
. === "details" || . === "review",
},
],
})
export function () {
const [, ] = <>("identity")
const [, ] = ("Complete the current screen.")
const = (() => ({ }), [])
const = .(, {
,
: {
: "",
: "",
: true,
: "",
: "",
},
: === "review",
: ({ }) => {
(`Published ${.}.`)
},
})
const = ({
,
,
,
: ,
})
let = (
< ="button" ={() => void .()}>
Continue
</>
)
if ( === "details") {
= (
<>
< ="button" ={() => ..("name", "")}>
Clear identity name
</>
<
="button"
={async () => {
if (await .()) {
("review")
}
}}
>
Review
</>
</>
)
}
if ( === "review") {
= (
<. ="intent" ="publish">
Publish
</.>
)
}
return (
<
="Product workflow recipe preview"
="form-please-complex"
>
< ="form-please-complex__kicker">Product workflow</>
< ="form-please-complex__summary">
{.?. ?? "Unavailable screen"}. Step{" "}
{..} of {..}.
</>
<
="Workflow progress"
={100}
={..}
/>
<. ={}>
< ="form-please-complex__actions">
<
={.. <= 1}
="button"
={() => .()}
>
Back
</>
{}
</>
</.>
< ="polite">{}</>
</>
)
}useFormWorkflow accepts four inputs:
| Input | Purpose |
|---|---|
form | Uses the existing "Form, Please" binding and RHF API. |
screen | Reads the current application-owned screen. |
setScreen | Changes the external screen state. |
steps | Defines labels, typed paths, and optional conditions. |
Each paths value must be a FieldPath<FormInput<Schema>>. TypeScript rejects
a misspelled path before the application runs. The when function reads the
current input and includes or removes a conditional screen.
when changes navigation only. Express the same conditional requirement in
the Standard Schema so a hidden screen cannot block final submission. If a
condition can hide the active screen, let the application choose the next
external screen; that fallback is a product-navigation decision.
The hook returns these operations and values:
| Result | Behavior |
|---|---|
next() | Touches and validates the current screen before navigation. |
back() | Opens the previous visible screen without validation. |
validateCurrent() | Validates the current screen without navigation on success. |
validateAllAndFocusFirstInvalid() | Validates all visible paths, opens the first invalid screen, and focuses its first invalid field. |
visibleSteps | Contains the steps that satisfy their current conditions. |
progress | Contains current, total, and integer percent values. |
The controller uses form.api.trigger. It does not create another validator.
Keep conditional rules in the Standard Schema, and give navigable issues a
field path. A form-level issue without a path cannot identify a screen.
Try the workflow
Clear the organization checkbox to remove its conditional screen and recalculate progress. On the details screen, select Clear identity name and then Review. The controller opens the identity screen and focuses Name.
Product workflow
Identity. Step 1 of 4.
2. Reuse the binding for review
Review shows the current editable input. It is not a saved confirmation. Keep
the same binding and definition, set readOnly, and provide an Edit action.
After a successful request, replace review with a confirmation that uses the server receipt. The receipt can contain canonical values, generated IDs, and other authoritative data.
"use client"
import { } from "form-please/preset-native"
import { } from "react"
import { } from "zod"
const = .({
: .().(1, "Enter a title"),
: .().(20, "Write at least 20 characters"),
})
type = "edit" | "review"
type = <{ : string; : string }>
const = .(, {
: [
{ : "field", : "title", : "text", : "Title" },
{
: "field",
: "summary",
: "textarea",
: "Summary",
: { : 5 },
},
],
})
async function (
: .<typeof >,
): <> {
return { : "article-42", : ..() }
}
export function () {
const [, ] = <>("edit")
const [, ] = <>()
const = .(, {
: { : "", : "" },
: === "review",
: async ({ }) => {
(await ())
},
})
if ( !== ) {
return (
< ="confirmation-title">
< ="confirmation-title">Article published</>
<>Confirmation ID: {.}</>
<>Published title: {.}</>
</>
)
}
let = (
< ="button" ={() => ("review")}>
Review current input
</>
)
if ( === "review") {
= (
<>
< ="button" ={() => ("edit")}>
Edit
</>
<. ="intent" ="publish">
Publish
</.>
</>
)
}
return <. ={}>{}</.>
}Do not create a second binding for review. Two bindings can create two editable copies of the same document.
3. Flush a draft before router navigation
Install React Router when the application does not already use it:
npm install react-routerUse useBlocker in a React Router data or framework router. When the user
chooses Save draft and leave, wait for persistence.flush() before you call
proceed(). Pass useFormState({ control: form.api.control }).isDirty as the
guard's dirty value.
"use client"
import type { } from "form-please/persistence"
import { , } from "react"
import { , } from "react-router"
type = <{
: boolean
:
}>
/** Use inside a React Router data or framework router. */
export function ({
,
,
}: ) {
const [, ] = <string>()
const [, ] = (false)
const = ()
(
(
() => {
if (!) return
.()
. = ""
},
[],
),
)
if (. !== "blocked") return null
async function () {
if (. !== "blocked") return
const = .
()
(true)
try {
await .()
()
} catch {
("The draft could not be saved. Navigation is still blocked.")
(false)
}
}
let = "Save draft and leave"
if () = "Saving draft…"
return (
< ="leave-title" ="dialog" ="true">
< ="leave-title">Leave this form?</>
<>
Save the latest input before navigation, or leave without saving it.
</>
{ !== && < ="alert">{}</>}
< ={} ="button" ={() => .()}>
Stay
</>
< ={} ="button" ={() => .()}>
Leave without saving
</>
<
={}
="button"
={() => void ()}
>
{}
</>
</>
)
}Use the application's accessible modal component for this dialog. It must trap focus while open, support Escape, and restore focus after Stay.
If flush() fails, keep navigation blocked and show the storage error. The
Leave without saving action deliberately skips flush().
useBlocker handles SPA navigation. beforeunload covers reloads, tab closes,
and cross-origin navigation with a browser warning. A beforeunload callback
cannot wait for an asynchronous flush. Autosave before this event; do not claim
an unload save guarantee.
4. Map server issues at the request boundary
Do not cast an arbitrary server string to FieldPath. Validate the response,
then map each remote path to one known schema input path.
"use client"
import type { , , } from "form-please"
import { } from "form-please/preset-native"
import { , , , } from "react"
import { } from "zod"
const = .({
: .().(1),
: .(),
: .().(1),
})
type = <typeof >
type = "identity" | "work"
type = { readonly : }
type = <typeof , >
const = .({
: .(
.({
: .([
"profile.full_name",
"profile.email_address",
"employment.department",
]),
: .(),
}),
),
})
type = .<typeof >["issues"][number]
const = {
"profile.full_name": "name",
"profile.email_address": "email",
"employment.department": "department",
} satisfies <["path"], <>>
const = {
: "identity",
: "identity",
: "work",
} satisfies <<>, >
function (
: ,
: readonly [],
): <> | undefined {
..(.())
let : <> | undefined
for (const of ) {
const = [.]
??=
..(, { : ., : "server" })
}
return
}
const = .<>()
const = .(, {
: [
{
: "field",
: "name",
: "text",
: "Name",
: (, { }) => . === "identity",
},
{
: "field",
: "email",
: "text",
: "Email",
: { : "email" },
: (, { }) => . === "identity",
},
{
: "field",
: "department",
: "text",
: "Department",
: (, { }) => . === "work",
},
],
})
export function () {
const [, ] = <>("identity")
const [, ] = <string>()
const = <<{
: <>
:
}> | null>(null)
const = (() => ({ }), [])
const = .(, {
,
: { : "", : "", : "" },
: async ({ , }) => {
()
try {
const = await ("/api/profile", {
: "POST",
: { "content-type": "application/json" },
: .(),
})
const : unknown = await .()
const = .()
const = (, .)
if ( === ) return
const = []
if ( === ) {
..()
return
}
. = { : , : }
()
} catch {
("The profile could not be saved.")
}
},
})
(() => {
const = .
if ( === null || . !== ) return
. = null
..(.)
}, [, ])
return (
<. ={} ={}>
{ !== && < ="alert">{}</>}
<.>Save profile</.>
</.>
)
}The satisfies checks make both maps exhaustive. If the server adds a path or
the form renames a field, TypeScript requires a mapping update.
Apply all errors before focus navigation. Then open the screen for the first mapped path and focus after that screen renders. Keep network, authorization, and form-level failures in application request state.
5. Separate draft and validated actions
A draft can contain incomplete or invalid input. Use type="button" for Save
draft and call persistence.flush() directly. This action does not submit the
form and does not run complete schema validation.
Use kit.Submit for Publish and Save and close. Both actions validate the
complete Standard Schema before onSubmit runs.
"use client"
import type { } from "form-please"
import {
,
,
,
} from "form-please/persistence"
import { } from "form-please/preset-native"
import { , } from "react"
import { } from "zod"
const = .({
: .().(1, "Enter a title"),
: .().(20, "Write at least 20 characters"),
})
const = .(, {
: [
{ : "field", : "title", : "text", : "Title" },
{
: "field",
: "description",
: "textarea",
: "Description",
: { : 5 },
},
],
})
const = ({
: (() => ),
: "release-draft",
: 1,
})
type = "publish" | "save-and-close"
type = <typeof >["submitter"]
function (: ): {
if ( === null) return "publish"
if (. !== "intent") {
throw new (`Unknown submitter: ${.}`)
}
if (. === "publish" || . === "save-and-close") {
return .
}
throw new (`Unknown submit intent: ${.}`)
}
function (: ) {
return { : "intent", : } as
}
async function (
: .<typeof >,
: ,
): <void> {
const = await ("/api/releases", {
: "POST",
: { "content-type": "application/json" },
: .({ , : }),
})
if (!.) throw new ("The release could not be saved")
}
export function ({ }: { readonly : () => void }) {
const [, ] = ("Restoring the draft…")
const = .(, {
: { : "", : "" },
: [],
: async ({ , , , }) => {
const = ()
if ( === "publish") ("Publishing…")
else ("Saving…")
await (, )
await .().()
..()
if ( === "save-and-close") ()
else ("Published.")
},
})
const = (, )
const = .
const = . === "active"
(() => {
if (. === "active") ("Draft ready.")
else if (. === "failed") {
("The draft could not be restored.")
} else if (. === "conflict") {
("The form changed before the draft could be restored.")
}
}, [.])
async function () {
("Saving draft…")
try {
await .()
("Draft saved.")
} catch {
("The draft could not be saved.")
}
}
return (
<. ={}>
< ="polite">{}</>
<
={!}
="button"
={() => void ()}
>
Save draft
</>
<.
{...("publish")}
={!}
>
Publish
</.>
<.
{...("save-and-close")}
={!}
>
Save and close
</.>
</.>
)
}The helper constrains application intent values even though native button
value is a string. The handler treats only a null implicit submit as
Publish and rejects unexpected submitter names or values. Choose a different
explicit default if the product requires it.
Understand submitter
FormSubmitDetails<Schema, Context> includes this property:
import type { , } from "form-please"
type = { : string }
declare const : <>
type Submitter = <typeof >["submitter"]
The value is Readonly<{ name: string; value: string }> | null.
The submit sequence is:
kit.Formprevents the native submit.- It captures the editable input and native submit control.
- The Standard Schema validates the captured input.
- A successful
onSubmitreceivesvalue,input,form, andsubmitter.
The snapshot has these guarantees:
- It is captured before validation starts, including asynchronous validation.
- It contains only the submit control
nameandvaluestrings. - It is frozen and does not retain a live DOM element.
- It is
nullwhen the native event has no submit control, such as some Enter submissions or a manually dispatched submit event. - It is separate from
value,input, nativeFormData, and request data.
Changing or removing the button while validation is pending does not change the
snapshot. Direct form.api.handleSubmit calls remain raw React Hook Form calls
and do not invoke the "Form, Please" onSubmit wrapper.
Use submitter only to select a validated application action. Validate the
action again at the server boundary. Do not use a button value as authorization.
Production checklist
Before release, verify these behaviors:
- Change every condition and confirm that progress uses visible screens only.
- Put an error on each screen and confirm first-invalid navigation and focus.
- Confirm that review uses current input and confirmation uses a server receipt.
- Make draft storage fail and confirm that guarded navigation remains blocked.
- Return every supported server issue path and confirm its mapping.
- Submit each named action and an implicit submit.
- Test keyboard navigation, focus order, and the guard dialog.