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.
| Need | Use |
|---|---|
| Adjust or cancel one form-local proposal | beforeUpdate |
| Observe one final transaction with its source and patches | afterUpdate |
| Compose independent value policies | Value middleware |
| Run an application action through the same rules | form.update(recipe) |
| Add grouped undo and redo | Managed value history |
| Observe values after a commit | An RHF subscription |
| Make an independent raw RHF change | form.api |
| Validate or transform submitted output | The 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.
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.
The managed change has this sequence:
- Form, Please creates patches for the proposed source change.
- The middleware calculates the total from
transaction.nextValues. - The middleware calls
nextwith the source patches and the total patch. - React Hook Form receives the final affected values before
nextreturns.
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.
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 point | transaction.source.type |
|---|---|
| Generated control change | control |
| Generated array add, remove, or move | array |
form.update(recipe) | update |
| History undo, redo, seek, or import | history |
| Persistence restore | persistence |
These operations bypass middleware and managed update hooks:
- initial
defaultValues; form.api.resetand native form reset;- direct
form.apimutations, includingsetValueandsetValues; - application-owned
useFieldArrayoperations.
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.
| Field | Meaning |
|---|---|
previousValues | Values before the managed proposal. |
nextValues | Values produced by the current patches. |
patches | Authoritative Immer add, remove, and replace operations. |
source | The generated control, generated array action, form.update, or optional feature restore. |
context | Current 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.
| Method | Result |
|---|---|
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 constIn 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
| Operation | Result |
|---|---|
Call next after await | Throws before stale patches can commit. |
Call next twice | Throws; an earlier commit is not rolled back. |
Call api.update during a transaction | Throws because nested managed updates are not allowed. |
| Remove a top-level key | Throws because RHF shallow-merges root values. |
Throw before next | Prevents the commit. |
Throw after next | Propagates 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.