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 (RHF) 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 |
Read Ownership for the general rule. | 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"),
})
const = .("organization", [
.({ : .(false) }),
.({
: .(true),
:
.()
.()
.(1, "Enter the organization name")
.(""),
}),
])
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 = .(
,
() => [
.("name", {
: "text",
: "Name",
: (, { }) =>
. === "identity" || . === "review",
}),
.("email", {
: "text",
: "Email",
: { : "email" },
: (, { }) =>
. === "identity" || . === "review",
}),
.("organization", {
: "checkbox",
: "I represent an organization",
: (, { }) =>
. === "identity" || . === "review",
}),
.("organizationName", {
: "text",
: "Organization name",
: (, { }) =>
. &&
(. === "organization" || . === "review"),
}),
.("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 schema 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 = .(, () => [
.("title", { : "text", : "Title" }),
.("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 = .(, () => [
.("name", {
: "text",
: "Name",
: (, { }) => . === "identity",
}),
.("email", {
: "text",
: "Email",
: { : "email" },
: (, { }) => . === "identity",
}),
.("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 other failures in application state. See Server issues are not schema issues.
Share one wrapper for server issues
Write the issue mapping once as an onSubmit wrapper. Each form then calls its
request function without its own try and catch block.
"use client"
import {
type ,
type ,
type ,
type ,
type ,
,
} from "form-please"
import { } from "form-please/preset-native"
import { } from "react-hook-form"
import { } from "zod"
// The application HTTP client throws this error for a failed response.
class extends {
constructor(
readonly : number,
readonly : readonly { ?: string; : string }[],
) {
super(`Request failed with status ${}`)
}
}
// Write the wrapper once and use it in each form.
function < extends , >(
: <
<string, <<<>>>>
>,
: (: <, >) => <unknown>,
) {
const = new <
string | undefined,
<<>>
>(.())
return async (: <, >) => {
try {
await ()
} catch () {
if ( instanceof && . === 400) {
const { , } =
const = ..()
const : <>[] = []
for (const of .) {
const = .(.)
// Skip an issue when the user changed its field during the request.
const =
=== ||
.((, )) ===
.((, ))
if () .({ , : . })
}
// Keep the failure visible when no current issue remains.
if (. === 0) {
.({ : "The server rejected the form. Try again." })
}
.(, { : true })
}
throw // The runtime keeps the error in form.submission.
}
}
}
const = .({ : .().(1), : .() })
async function (: .<typeof >) {
const = await ("/api/profile", {
: "POST",
: { "content-type": "application/json" },
: .(),
})
if (!.) {
const = (await .()) as { ?: ["issues"] }
throw new (., . ?? [])
}
}
const = .(, () => [
.("name", { : "text", : "Name" }),
.("email", { : "text", : "Email" }),
])
export function () {
const = .(, {
: { : "", : "" },
: (
{ : "name", : "email" },
({ }) => (),
),
})
const { } = (.)
const =
!== null &&
!(. instanceof && .. === 400)
return (
<. ={}>
{ && < ="alert">The profile was not saved.</>}
<.>Save profile</.>
</.>
)
}The wrapper rethrows the error after setIssues. Form, Please catches a
rejection of onSubmit and keeps it in
useSnapshot(form.submission).error as { value }. Use this value for a request
failure banner. The next accepted submit and setIssues set error to null.
The user can edit a field while the request runs. A server issue for that path
then attaches to the new value. To prevent this, the wrapper compares the
current value at each path with the submitted input and skips changed paths.
The example uses JSON.stringify for this comparison. Use a deep equality
function for values such as Date or File.
If the wrapper skips every server issue, it adds one form issue. The error summary then shows that the save failed.
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 actions and kit.Submit action for Publish and Save and close.
Both actions validate the complete Standard Schema before their 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 = ({
: (() => ),
: "release-draft",
: 1,
})
const = .(
,
() => [
.("title", { : "text", : "Title" }),
.("description", {
: "textarea",
: "Description",
: { : 5 },
}),
],
{ : [] },
)
type = "publish" | "save-and-close"
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…")
async function (
{ , , }: <typeof >,
: ,
) {
if ( === "publish") ("Publishing…")
else ("Saving…")
await (, )
await .().()
..()
if ( === "save-and-close") ()
else ("Published.")
}
const = .(, {
: { : "", : "" },
: {
: {
: true,
: () => (, "publish"),
},
: {
: () => (, "save-and-close"),
},
},
})
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
</.>
<.
={..}
={!}
>
Save and close
</.>
</.>
)
}Each button selects its action object, so the handler does not parse a button
name or value. implicit: true makes Enter in a single-line field run
Publish. Remove it if Enter must not submit the form.
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 schema 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.
Prefer actions to select an application action. submitter remains for the
onSubmit shorthand and native button values. 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.