Skip to content
Form, Please

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.

StateOwner
Editable domain inputReact Hook Form and the Standard Schema
Current screen and navigation historyApplication state or the router
Screen visibilityForm context and definition resolvers
Draft storageform-please/persistence and the application adapter
Requests and confirmation receiptsApplication 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:

InputPurpose
formUses the existing "Form, Please" binding and RHF API.
screenReads the current application-owned screen.
setScreenChanges the external screen state.
stepsDefines 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:

ResultBehavior
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.
visibleStepsContains the steps that satisfy their current conditions.
progressContains 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.

Complete the current screen.

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-router

Use 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 SubmitterArrow
= <typeof >["submitter"]

The value is Readonly<{ name: string; value: string }> | null.

The submit sequence is:

  1. kit.Form prevents the native submit.
  2. It captures the editable input and native submit control.
  3. The Standard Schema validates the captured input.
  4. A successful onSubmit receives value, input, form, and submitter.

The snapshot has these guarantees:

  • It is captured before validation starts, including asynchronous validation.
  • It contains only the submit control name and value strings.
  • It is frozen and does not retain a live DOM element.
  • It is null when the native event has no submit control, such as some Enter submissions or a manually dispatched submit event.
  • It is separate from value, input, native FormData, 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:

  1. Change every condition and confirm that progress uses visible screens only.
  2. Put an error on each screen and confirm first-invalid navigation and focus.
  3. Confirm that review uses current input and confirmation uses a server receipt.
  4. Make draft storage fail and confirm that guarded navigation remains blocked.
  5. Return every supported server issue path and confirm its mapping.
  6. Submit each named action and an implicit submit.
  7. Test keyboard navigation, focus order, and the guard dialog.