Skip to content
Form, Please

Value middleware

Value middleware intercepts managed value proposals before Form, Please commits them to React Hook Form. Use it to derive dependent values, cancel a proposal, or run ordered work after a commit.

Middleware is form-local. It does not create another form store. React Hook Form remains the owner of editable values and form state.

NeedUse
Adjust or cancel one form-local proposalbeforeUpdate
Observe one final transaction with its source and patchesafterUpdate
Compose independent value policiesValue middleware
Run an application action through the same rulesform.update(recipe)
Add grouped undo and redoManaged value history
Observe values after a commitAn RHF subscription
Make an independent raw RHF changeform.api
Validate or transform submitted outputThe Standard Schema

Use one managed update hook

beforeUpdate and afterUpdate provide one application callback on either side of the middleware chain. The first callback can mutate the proposed Immer draft or return false; the second receives the final committed transaction.

function ProfileUpdateHooks({ context }: { readonly context: ProfileContext }) {
	const form = profileKit.useForm(profileDefinition, {
		beforeUpdate(draft, transaction) {
			if (
				!context.canEditPlan &&
				transaction.source.type === "control" &&
				transaction.source.path === "plan"
			) {
				return false
			}
			if (draft.plan === "solo") draft.teamName = undefined
		},
		afterUpdate(transaction) {
			recordManagedValues(transaction.nextValues)
		},
		context,
		defaultValues,
	})
 
	return <profileKit.AutoForm form={form} />
}

The order is beforeUpdate, middleware before next, commit, middleware after next, and afterUpdate. Both hooks are synchronous. afterUpdate still runs when middleware throws after a commit, because the committed change remains observable. If both fail after commit, dispatch throws an AggregateError.

The callbacks use their latest React render versions. Their transactions are readonly views rather than archival snapshots. Copy retained audit or save data. Use middleware when several rules must compose in a declared order.

Generated array constraints still apply. Return false to cancel the proposed structural action; do not rewrite its length or order through the draft.

Check editing in a larger form

Use this larger form to check editing in your browser. It renders 18 text inputs and sends each edit through one pass-through middleware.

Edit fields quickly. Move focus between sections while you type.

Editing check

Type in any field. One pass-through middleware handles each generated change.

Contact

Organization

Project

This preview is a manual check, not a repeatable benchmark. Browser extensions, computer load, and development tools can change the result.

Use npm run bench:middleware to measure the coordinator without browser rendering. The automated browser test also checks that rapid edits do not lose input.

Derive a value in the same commit

This middleware calculates an order total. It appends one Immer patch to the patches for the source change.

const keepOrderTotalCurrent: FormMiddleware<OrderInput> =
	() => (next) => (transaction) => {
		const total =
			Math.round(
				transaction.nextValues.quantity *
					transaction.nextValues.unitPrice *
					100,
			) / 100
 
		return next([
			...transaction.patches,
			{ op: "replace", path: ["total"], value: total },
		])
	}

Pass the ordered middleware list to kit.useForm. Use form.update when an application action must enter the same managed boundary.

export function DerivedTotalMiddlewarePreview() {
	const form = nativeFormKit.useForm(orderDefinition, {
		defaultValues: initialOrder,
		middleware: [keepOrderTotalCurrent],
	})
	const total = useWatch({ control: form.api.control, name: "total" })
 
	return (
		<section
			aria-label="Derived total middleware preview"
			className="form-please-complex"
		>
			<p className="form-please-complex__kicker">Live preview</p>
			<p className="form-please-complex__summary">
				Change a source field. The read-only total changes in the same managed
				commit.
			</p>
			<nativeFormKit.Form className="form-please-complex__form" form={form}>
				<nativeFormKit.Fields />
				<div className="form-please-complex__actions">
					<button
						onClick={() =>
							form.update((draft) => {
								draft.quantity = 10
								draft.unitPrice = 9
							})
						}
						type="button"
					>
						Apply bulk order
					</button>
					<output aria-live="polite">
						Committed total: ${total.toFixed(2)}
					</output>
				</div>
			</nativeFormKit.Form>
		</section>
	)
}

Change the quantity or unit price. The read-only total changes in the same managed commit. The button changes both source fields with one Immer recipe.

Live preview

Change a source field. The read-only total changes in the same managed commit.

Committed total: $30.00

The managed change has this sequence:

  1. Form, Please creates patches for the proposed source change.
  2. The middleware calculates the total from transaction.nextValues.
  3. The middleware calls next with the source patches and the total patch.
  4. React Hook Form receives the final affected values before next returns.

The transaction received by this middleware contains the value before its new total patch. The next middleware receives a new transaction with the recalculated nextValues.

Cancel a managed change

Return without calling next to cancel the complete proposal. No source patch or dependent patch commits.

export function CancellationMiddlewarePreview() {
	const [decision, setDecision] = useState("No managed change yet.")
	const maximumDiscount = 30
	const guardDiscount: FormMiddleware<DiscountInput> =
		() => (next) => (transaction) => {
			if (transaction.nextValues.discount > maximumDiscount) {
				setDecision(`Cancelled ${transaction.nextValues.discount}% discount.`)
				return
			}
 
			const result = next(transaction.patches)
			setDecision(`Committed ${transaction.nextValues.discount}% discount.`)
			return result
		}
	const form = nativeFormKit.useForm(discountDefinition, {
		defaultValues: { discount: 10 },
		middleware: [guardDiscount],
	})
	const discount = useWatch({
		control: form.api.control,
		name: "discount",
	})
 
	return (
		<section
			aria-label="Cancellation middleware preview"
			className="form-please-complex"
		>
			<p className="form-please-complex__kicker">Live preview</p>
			<p className="form-please-complex__summary">
				Managed changes above 30% are cancelled. A raw RHF update bypasses the
				guard.
			</p>
			<nativeFormKit.Form className="form-please-complex__form" form={form}>
				<nativeFormKit.Fields />
				<div className="form-please-complex__actions">
					<button
						onClick={() =>
							form.update((draft) => {
								draft.discount = 40
							})
						}
						type="button"
					>
						Try 40% as a managed change
					</button>
					<button
						onClick={() => {
							form.api.setValue("discount", 40, { shouldDirty: true })
							setDecision("Raw form.api.setValue bypassed middleware.")
						}}
						type="button"
					>
						Set 40% through raw RHF
					</button>
				</div>
				<output aria-live="polite">
					Current value: {discount}%. {decision}
				</output>
			</nativeFormKit.Form>
		</section>
	)
}

The first button uses form.update, so the 30% limit applies. The second button uses raw form.api.setValue, so it bypasses the limit.

Live preview

Managed changes above 30% are cancelled. A raw RHF update bypasses the guard.

Current value: 10%. No managed change yet.

Use raw RHF operations only when bypassing middleware is intentional. A direct operation can violate an invariant that middleware enforces for managed changes.

Know which changes enter middleware

Middleware handles these entry points:

Entry pointtransaction.source.type
Generated control changecontrol
Generated array add, remove, or movearray
form.update(recipe)update
History undo, redo, seek, or importhistory
Persistence restorepersistence

These operations bypass middleware and managed update hooks:

  • initial defaultValues;
  • form.api.reset and native form reset;
  • direct form.api mutations, including setValue and setValues;
  • application-owned useFieldArray operations.

Supply consistent derived values in defaultValues and every reset input. Middleware does not calculate dependent values for these entry points. Active persistence can observe their resulting RHF values for autosave, but they do not become middleware transactions.

form.update accepts a synchronous Immer recipe. Mutate the draft or return a replacement value. Do not do both. A recipe that produces no patches does not create a transaction.

Read a transaction

Each middleware receives one ValueTransaction.

FieldMeaning
previousValuesValues before the managed proposal.
nextValuesValues produced by the current patches.
patchesAuthoritative Immer add, remove, and replace operations.
sourceThe generated control, generated array action, form.update, or optional feature restore.
contextCurrent deeply readonly runtime context for the form binding.

Control sources contain a typed path. Array sources contain a typed path, an action, and the affected index. Move sources contain fromIndex and toIndex. Optional history restores contain an undo, redo, seek, or import action. Persistence restore contains a restore action.

Patch paths are segment arrays such as ["items", 0, "price"]. They are not RHF dot paths such as items.0.price.

The value and context fields are deeply readonly TypeScript views. They are not frozen or cloned as archival snapshots. Read them synchronously. Copy data that must remain an independent snapshot.

Do not mutate the transaction, its patches, its values, or its context. Supply a new patch array to next when middleware changes a proposal.

Use the middleware API

The first function receives one FormMiddlewareApi when the form creates its fixed middleware chain.

MethodResult
api.getValues()Read the current RHF values as a deeply readonly TypeScript view.
api.update(recipe)Start a new managed update when no transaction is active.

Call api.getValues() after synchronous next to read the committed result from downstream middleware. The returned value is not an independent snapshot.

Do not call api.update while middleware configures or while a transaction is active. An asynchronous handler can call it after awaited post-commit work. That call starts a separate transaction.

Forward patches once

Middleware has the shape api => next => transaction. Call next(patches) synchronously at most once.

You can forward the original patches, append patches, or replace the patch list. Form, Please reapplies the supplied patches to previousValues. This operation creates the transaction for the next middleware.

Middleware runs in list order. With [deriveTotal, auditOrder], deriveTotal runs first. auditOrder receives the derived total. Code after next runs in reverse order and sees committed values.

The chain makes one ordered pass. It does not rerun middleware when a later middleware adds a patch. It does not calculate a dependency fixed point.

Any middleware can replace the value returned by next. Therefore, FormMiddlewareNext and form.update return unknown. Do not use that return value as a commit result unless the complete chain defines that convention.

Run asynchronous work after the commit

Call next before the first await. An asynchronous handler can return a promise after the synchronous commit.

const auditCommittedOrder: FormMiddleware<OrderInput> =
	() => (next) => async (transaction) => {
		const result = next(transaction.patches)
		await saveOrderAudit(transaction.nextValues)
		return result
	}
 
const orderMiddleware = [keepOrderTotalCurrent, auditCommittedOrder] as const

In this order, auditCommittedOrder receives the total patch from keepOrderTotalCurrent. The value commit is complete before saveOrderAudit starts.

Middleware completion does not mean validation or the asynchronous work is complete. Observe RHF validation state when later code requires valid values.

Handle generated arrays safely

Generated add, remove, and move actions enter middleware. Middleware can cancel the action or add dependent value patches. The source action still uses RHF useFieldArray so the row IDs remain stable.

Do not change the source array length or order beyond its proposed action. Do not change another generated array's structure in the same transaction. Use that array's generated action for its structural change.

Do not change generated array length or order from a generated control or form.update. RHF setValues cannot synchronize the private row IDs that useFieldArray owns.

Generated array actions promise one final React render with dependent values. They do not promise one raw RHF publication. A raw RHF subscriber can observe the native array operation before the dependent value commit.

Understand form state and validation

The value commit completes before next returns. Validation can finish later. Managed validation follows mode and reValidateMode through one RHF trigger call. This trigger does not preserve delayError timing.

RHF calculates isDirty from the complete current value and default values. dirtyFields is guaranteed only for mounted patched paths. A control becomes touched only after a real blur event.

Avoid invalid pipeline operations

OperationResult
Call next after awaitThrows before stale patches can commit.
Call next twiceThrows; an earlier commit is not rolled back.
Call api.update during a transactionThrows because nested managed updates are not allowed.
Remove a top-level keyThrows because RHF shallow-merges root values.
Throw before nextPrevents the commit.
Throw after nextPropagates the error without rolling back the commit.

Assign undefined instead of removing a top-level key when the schema permits it. Nested removal is supported because Form, Please commits the complete affected root.

The middleware list is fixed for the useForm hook lifetime. Change the React key to create a form with another list. The current transaction.context still follows the latest form context.

See the API reference for the exported types.