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.
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:
| Concern | useEffect and setValue | beforeUpdate |
|---|---|---|
| Rendering | React 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 values | RHF stores the value before the effect can revert it. | Return false to cancel the change before it commits. |
| Rule location | A component. | The form definition. |
| Tests | Render 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.`)
},
},
)beforeUpdatereads the limit from the form context. It cancels a discount above the limit.afterUpdatereceives 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.
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
| 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 |
Changes that run the rules
Hooks and middleware handle 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.
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:
beforeUpdate.- Middleware code before
next, in list order. - The commit to RHF.
- Middleware code after
next, in reverse order. 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.
| 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 a form binding or
definition tester creates its 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
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
| 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.
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.
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.