Skip to content
Form, Please

Value middleware

Write value rules once, in the form definition. Form, Please applies them to each managed update before React Hook Form (RHF) stores the values.

Use value rules to:

  • keep a dependent value, such as an order total, in sync;
  • block a change that breaks a limit;
  • run code after a change, such as an audit log or an autosave.

Keep dependent values in sync

Add beforeUpdate to the definition options. The hook receives a draft of the proposed values. Change the draft to change the values that commit.

const orderDefinition = nativeFormKit.defineForm(
	orderSchema,
	(ui) => [
		ui.field("quantity", { control: "number", label: "Quantity" }),
		ui.field("unitPrice", { control: "number", label: "Unit price" }),
		ui.field("total", { control: "number", label: "Total", readOnly: true }),
	],
	{
		beforeUpdate(draft) {
			draft.total = Math.round(draft.quantity * draft.unitPrice * 100) / 100
		},
	},
)

Change the quantity or the unit price. The total changes in the same commit.

Live preview

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

Committed total: $30.00

Application code changes values with form.update. The same hook runs for these changes. The Apply bulk order button calls this function:

	const applyBulkOrder = () =>
		form.update((draft) => {
			draft.quantity = 10
			draft.unitPrice = 9
		})

Compare with an effect

A common alternative watches the values in a component and calls setValue from useEffect. The hook has these advantages:

ConcernuseEffect and setValuebeforeUpdate
RenderingReact renders the old total first. The effect then sets the new total and causes a second render.The total and its source values commit together.
Invalid valuesRHF stores the value before the effect can revert it.Return false to cancel the change before it commits.
Rule locationA component.The form definition.
TestsRender the component.Use the definition tester without React.

The hooks run for generated controls, generated arrays, form.update, history restores, and persistence restores. They do not run for defaultValues, resets, or direct form.api calls. See Changes that run the rules.

Block a change and report the result

Return false from beforeUpdate to cancel the complete change. No value commits. Use afterUpdate to run code after a commit.

const discountDefinition = discountKit.defineForm(
	discountSchema,
	(ui) => [
		ui.field("discount", { control: "number", label: "Discount percentage" }),
	],
	{
		beforeUpdate(draft, { context }) {
			if (draft.discount > context.maximum) {
				context.report(`Cancelled ${draft.discount}% discount.`)
				return false
			}
		},
		afterUpdate({ nextValues, context }) {
			context.report(`Committed ${nextValues.discount}% discount.`)
		},
	},
)
  • beforeUpdate reads the limit from the form context. It cancels a discount above the limit.
  • afterUpdate receives the committed transaction. It does not run for a cancelled change.

Live preview

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

Current value: 10%. No managed update yet.

The first button uses form.update, so the limit applies. The second button uses raw form.api.setValue, so it bypasses the hooks. Use raw RHF operations only when you intend to bypass the rules.

Both hooks are synchronous. To wait for asynchronous work after a commit, use middleware.

Split rules into middleware

One beforeUpdate hook is sufficient for most forms. Use middleware when:

  • several independent rules must run in a declared order;
  • several forms share one rule;
  • a rule must wait for asynchronous work after the commit.

A middleware has the shape api => next => transaction. It calls next with the Immer patches to commit. This middleware adds a patch for the total:

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 },
		])
	}

The next middleware receives a new transaction. Its nextValues contain the new total.

Pass the ordered list in the middleware option of kit.defineForm. Middleware runs in list order:

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

auditCommittedOrder receives the total patch from keepOrderTotalCurrent. Call next before the first await. The value commit is then complete before saveOrderAudit starts.

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

Reference

Ownership

Middleware policy belongs to a definition and initializes separately for every form binding. It does not create another form store. RHF remains the owner of the schema input and form state. See Ownership.

The middleware list is copied and fixed when defineForm creates the definition. Create another definition to use another policy. Each binding gets independent middleware instances. transaction.context follows the current context of that binding.

Choose a tool

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

Changes that run the rules

Hooks and middleware handle 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. The rules do not calculate dependent values for these entry points. Active persistence can observe the resulting RHF values for autosave. These changes 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.

Hook order and errors

beforeUpdate and afterUpdate provide one application callback on either side of the middleware chain. The order is:

  1. beforeUpdate.
  2. Middleware code before next, in list order.
  3. The commit to RHF.
  4. Middleware code after next, in reverse order.
  5. afterUpdate.

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 are fixed with the definition. Read current binding data from transaction.context. Copy data that you keep for audit or save operations.

Generated array constraints still apply. Return false to cancel a proposed structural action. Do not change its length or order through the draft.

Read a transaction

Hooks and middleware receive 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 a form binding or definition tester creates its 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

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.

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.

Generated arrays

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 the structure of another generated array in the same transaction. Use the generated action of that array 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.

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.

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.

See the API reference for the exported types.

Check editing in a larger form

This form renders 18 text inputs and sends each edit through one pass-through middleware. Type quickly and move focus between sections to check the editing response.

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.

Next steps