Improve Form for React actions and server errors
Motivation
Form currently always prevents native submission and routes the request through FormStore.submit(). This does not compose directly with React function actions, URL actions, submitter formAction overrides, or React action pending state.
The v0.5 release should let applications use those action paths while keeping Ariakit field state, existing async validation callbacks, registered submit callbacks, errors, focus behavior, and reset behavior.
This is a breaking v0.5 change. Forms that combine an explicit action with store submit callbacks will use the action path by default.
This body is the implementation specification. It supersedes the original proposal, which contained six premises that turned out to be false when checked against React 19.2.8, the HTML standard, and this repository. Those corrections are listed under Corrections to the original proposal, because a reader who remembers the first version needs to know exactly what changed.
Usage example
Form can create its own store when no explicit store or provider is available:
function Signup() {
const [result, action, isPending] = React.useActionState(save, {
serverErrors: {},
});
return (
<Ariakit.Form
action={action}
defaultValues={{ email: "" }}
serverErrors={result.serverErrors}
pending={isPending}
>
<Ariakit.FormLabel name="email">Email</Ariakit.FormLabel>
<Ariakit.FormInput name="email" type="email" required />
<Ariakit.FormError name="email" />
<Ariakit.FormSubmit>Save</Ariakit.FormSubmit>
</Ariakit.Form>
);
}Applications can keep an explicit store when parent code needs a handle:
const form = Ariakit.useFormStore({
defaultValues: { email: "" },
serverErrors: result.serverErrors,
pending: isPending,
});
Ariakit.useFormValidate(form, async () => {
const errors = await validateValues(form.getState().values);
form.setErrors(errors);
});
Ariakit.useFormSubmit(form, async ({ values }) => {
await saveDraft(values);
});
return <Ariakit.Form store={form} action={action}>{/* fields */}</Ariakit.Form>;React action status remains separate from store.submitting. The explicit pending option lets the owner supply the isPending value returned by useActionState(), or another caller-owned status such as an upload.
The application can replace the writable server-error snapshot through the generic store writer:
form.setState("serverErrors", {});There is no dedicated setServerErrors, defaultServerErrors, occurrence-key, schema, or FormData conversion API in this release.
Scope
- Add action-based submission ownership, existing async client validation before an action, registered submit callbacks before an action, a separate
serverErrorssource, pending and reset integration, and an optional Form-owned store. - Ship both halves together in one release. The server-error half and the action half are not split.
- Keep built-in schema support outside this release. Do not add a schema option, a Standard Schema adapter, or schema-derived output typing.
- Keep
FormValue,parseFormData, andtoFormDataoutside this release. - Keep the Solid Form port and no-JavaScript array editing outside this release.
- Keep first-class Select and Combobox value bridging in #7379. Do not make #7379 the canonical issue for this work.
Choosing the submission path
- Select the submission owner for each attempt. Use the original submitter's explicit
formActionwhen present. Otherwise use the Form's explicitaction. - Choose the action path from React's capability, never from the presence of a prop. An attempt takes the action path only when the running React version can dispatch function actions and an effective action exists. Otherwise it takes the store path.
- A function action or a URL string, including an empty string, is an explicit effective action.
- A submitter-only
formActionselects the action path even when the Form has noaction. - Use the store path when no explicit effective action exists, and on React 18 in every case.
Formmust not declareactionin its own options. The prop comes from the React DOM types.@types/react18 types a form'sactionasstring | undefined, so a React 18 user passing a function gets a compile error for free. DeclaringactiononFormOptionsoverrides that type and removes the guard.- Add no public override prop. Applications choose the store path by removing the explicit action or by using an application adapter.
- Keep the original form and any originally present submitter for replay. Resolve the current effective action again at each required boundary and at replay. The effective action cannot be read back from the DOM: the
actionandformActionIDL getters fall back to the document URL, and React does not reflect a function action as an attribute. Resolve it from React props, not from the element. - If the original form is unusable, the original submitter is unusable, or the explicit effective action is absent at a boundary, make the attempt terminal. Do not fall back to the store path, another submitter, or an implicit navigation target.
- Check these conditions after every awaited validator or submit callback and at every stage transition. A condition that disappears and returns entirely between checks does not require continuous observation.
Native constraint validation
- Constraint validation runs before the submit event, not after it. When a field fails, the browser abandons the submission and no submit event is dispatched, so no Ariakit rule can run. This is why the action path cannot simply enable native constraints.
- Render the form without
novalidateduring server rendering and on the first client render, then setnoValidatein a layout effect after mount. A submission made before hydration gets the browser's checks. Every submission after hydration is Ariakit's, exactly as today. - The flip must happen in an effect, not during render, or it is a hydration mismatch.
- Registered fields continue to report their own native validity through a validation callback, so
requiredand type checks still produce Ariakit errors after hydration. - An explicit
noValidate={false}from the application hands constraint validation to the browser and disables Ariakit's error rendering for constraint failures. Document this.
Submit events and replay
- Run the Form's own
onSubmitonly for the original submit event. - Let a synchronous
preventDefault()in that handler cancel the attempt before Ariakit starts validation. - After all Ariakit gates pass, redispatch through the original form and submitter with
requestSubmit(submitter). This creates a second DOM submit event that capture handlers, ancestors, and native listeners can observe and cancel. - The replay must reach a task. A form is locked for re-entrancy while its submit event is dispatching, and a
requestSubmit()inside that window is dropped silently. Neither a synchronous replay nor a microtask-deferred replay produces a second submit event. Today'svalidate()happens to clear that window because it awaits a frame; the implementation must not depend on that. - Do not call the Form's own
onSubmitagain for Ariakit's replay. - Treat replay as part of the admitted attempt. It must bypass Ariakit's touched update, server-error retirement, validation, submit-callback queue, and coordinator admission. It must not start a second Ariakit submission or loop.
- Only
preventDefault()cancels the replayed action.stopPropagation()does not: it prevents React's handler from running and leaves the form to navigate to React's internal sentinel URL. - Two submit events per user submission are a supported observable. Document it.
- A replay blocked by native constraint validation fires no event at all. Derive the release point from
requestSubmit()returning without a submit event having been dispatched. - Keep native constraints and replay cancellation as gates on the replay. A failed native constraint or canceled replay must not dispatch the action.
- Ignore repeated activations while one action-path request is active. Queue no later request. This differs from React, which queues actions and runs them sequentially. Document it.
Submitter semantics
- Preserve the original submitter's
formMethod,formEncType,formTarget, andformNoValidatebehavior. - The submitter's own name and value are absent whenever the submitter carries a
formAction. React sets the submitter tonullbefore building the form data in that case. This is React's behavior and cannot be worked around on the replay path. - An image button submitter loses its coordinates. React rebuilds the payload from the submitter's name and value, so a replayed
<input type="image" name="img">contributesimg=""rather than the nativeimg.xandimg.ypair. requestSubmit()accepts a disabled submitter, but the entry list drops it. Do not disable the submitter during an attempt.- Building form data fires a
formdataevent, so one user submission fires it twice under this design. - A control named
requestSubmitorresetshadows the method on the form element. Resolve both from the prototype, not from the element.
Validation and submit callbacks
- Before the action starts, await all applicable existing client validators, including async
useFormValidatecallbacks and native validation registered by Form controls. - After an uncanceled original event selects the action path, replace
touchedwith the union of the current store values shape and every registered field, mapped totrue, before the first required submission validator runs. Capture that path set at this boundary; paths added later by validators or submit callbacks are not marked automatically. - Deriving
touchedfrom the values shape alone marks nothing when the store was created withoutdefaultValues, which is exactly the case a Form-owned store creates. The union is required, not an optimization. - Keep that touched update after invalid validation, callback errors or throws, validation-stage edit cancellation, owner or condition loss, replay cancellation, and action success. An event canceled before Ariakit accepts it changes nothing. Later application writes and reset keep their normal authority.
- Retire the current
serverErrorssnapshot at the start of every accepted validated action attempt and store submission, before client validation. Standalonevalidate()calls and the internal action replay do not retire it. - Keep the existing custom-validator write model. Async validators that write through a captured store remain responsible for guarding obsolete writes.
- If a relevant store value changes during submission validation, cancel the attempt, release its field and Submit contribution immediately, skip work that has not started, and require another explicit submit.
- Do not abort already running validator promises. Old and new validation work may overlap after cancellation.
- After valid client validation, await registered
useFormSubmitcallbacks sequentially before replaying the action. - Run later submit callbacks even when an earlier callback writes errors. Stop the queue when a callback throws.
- Reuse the registered callback queue, not the complete
FormStore.submit()operation. Completing this pre-action stage must not incrementsubmitSucceed, count as a successful store submission, or triggerresetOnSubmit. A callback throw blocks replay and records no store-submit success. - Capture one accepted input object for the callback queue. Every callback in that queue receives the same accepted
valuesobject. The callback signature still receives the whole state, so the contract must state whether the rest of that state is snapshotted or live; the acceptedvaluesobject is the part that is fixed. - Apply the accepted-input rule to both normal store-submit callback queues and pre-action callback queues.
- Do not deep-copy or freeze the accepted callback input.
- Note that the accepted callback
valuesobject and the FormData the action receives are different shapes built from different sources. They are not one input. - Programmatic value writes remain available during caller-owned pending work, subject to the edit lock below.
- A value change during the submit-callback queue does not cancel or revalidate the attempt. Finish the queue with its accepted callback input, then use the current native control data for action replay.
- Do not join unrelated change or blur validation that starts during the callback queue. At the final callback boundary, use the current committed error state.
- Before replay, check total validity again. Client
errorsandserverErrorsboth make the form invalid. validate()returns total validity. It returnsfalsewhile an applicableserverErrorsentry remains.validate()clears the whole client error tree before running validators. A concurrent change or blur validation during the callback queue therefore erases errors a submit callback has just written. The implementation must prevent that from letting a replay through.
Server errors
- Add
serverErrorsas a separate Form store state tree with the same named-path shape as clienterrors. - Keep one scalar string message for each path in each source.
- Show server errors independently of touched. Keep client errors under the existing touched and validation rules.
- Let applications use a declared headless values path for a whole-form error. Do not add a separate
formErrorschannel or nameless root address. - Keep an Ariakit-owned writable snapshot. The supplied response tree is an occurrence input, not ordinary controlled state.
- Import a supplied response when an attempt that Form started completes, not by comparing references. Detect completion with a
useFormStatusbridge: a component rendered inside the form element that reads the status and reports the pending edge into the store. Thetruetofalseedge lands in the same commit as the owner'suseActionStateresult, so the newserverErrorsvalue is already current at that moment. - There is no receipt, no
Object.iscomparison, and no requirement that callers produce a fresh object for each response. A repeated identical response imports correctly. - A URL action produces no completion signal, because React contributes nothing to it. State this limit.
- The bridge does not exist on React 18 and must sit behind the same capability test that chooses the submission path.
- The bridge reports React's pending only. It reports "not pending" for the whole Ariakit validation and callback stage.
- Add no
serverErrorsKey,responseKey, orserverKeyprop. - Treat omission or
undefinedas no supplied occurrence. Treat{}as an explicit no-error occurrence. - Copy a recognized response into the writable snapshot so local writes do not mutate the caller's tree.
- Let the first supplied
serverErrorstree initialize the snapshot at store creation. Do not treat it as a reset default. Store creation is the only path that runs during server rendering, because layout effects do not. - Initialize that first snapshot atomically. Unchanged hydration must not import, focus, or announce the same response again.
- Keep the snapshot with the store. A surviving store retains it when a Form controller changes; a new store starts empty. Note that a derived store's
init()overwrites creation-time state when the store has a parent, which currently defeats a creation-time snapshot. - Let a later recognized response restore a message that a local write, a retry, or a reset cleared.
- Treat
serverErrorsas a creator-owned response input. Supply it touseFormStore, aFormProviderthat creates the store, or a Form that creates its own store. AFormProvideror Form that adopts an existing store must not supply it. - Keep response freshness under application control. Completion identifies an occurrence; it does not prove that the response still applies to current values.
- Add no dedicated server-error writer. Use
store.setState("serverErrors", nextOrUpdater)for explicit replacement or clearing. - Clear a server message for a path when that path's value changes, whoever changed it: a keystroke, a programmatic
setValue, or an array edit. Only the changed path is affected. A whole-treesetValuesmust diff rather than assume.removeValuepreserves indices, so a message attached totags.1stays attached totags.1. - This rule cannot fire mid-attempt, because the edit lock already refuses value writes while an attempt is in flight.
- Clear all server errors on an accepted reset and when an accepted validated attempt retires the previous response.
- Direct
store.reset()also clears the writableserverErrorssnapshot. - Keep
getError(name)paired with clienterrors. Do not change it to read the combined sources. - Make a matching registered field default to
aria-invalid="true"when it has an applicable server message, independently of touched. Client invalidity keeps its existing touched and validation rules. Caller-supplied ARIA props retain their normal precedence. - Because a server message clears when its value changes,
aria-invalidreturns to describing the field's current state after a correction. FormErrorshows the server message alone while it stands. There is no exact-string collapse rule, no single-space join, and no ordering rule.- Preserve the current explicit-children behavior. Explicit
FormErrorchildren override automatic message rendering. - Define what "applicable", "matching", and "eligible" mean for a server message before merge. Path-matching semantics are otherwise left to the implementer.
- A server error on a path with no rendered
FormErrorproduces an invalid field with no reachable message. Whole-form and headless messages have no focus target and no announcement path. Both need a stated outcome.
Validity
state.validbecomes total validity:falsewhile either error tree has a message. This is a breaking change to a published state key.validate()andstate.validmust agree. The store'ssyncover["validating", "errors"]has to includeserverErrors.- The
form-validsandbox asserts the current client-only meaning and must be updated.
Touched state and focus
- Make an invalid result from required client validation eligible for
autoFocusOnSubmit. - Make a total-invalid result after the submit-callback queue eligible for
autoFocusOnSubmit. - Make each accepted post-mount nonempty
serverErrorsoccurrence eligible forautoFocusOnSubmit. - Do not make the initial server-error render, a generic
store.setState("serverErrors", ...)write, retry retirement, or reset focus eligible. - Sample the current
autoFocusOnSubmitvalue when the eligible outcome is classified. - Keep at most one pending focus request for each Form. A later eligible outcome before delivery must not replace, duplicate, or postpone that request.
- Deliver the request after the relevant commit. Inspect the latest committed registered fields in current DOM order and focus the first target whose rendered control has
aria-invalid="true". - Select the text when the chosen target supports text selection, matching current Form behavior.
- Consume the focus request even when no eligible target exists. Do not let it steal focus after a later render.
- Do not infer a focus target for headless paths, unmounted or unregistered fields, or raw native controls that are not connected to the store.
- Server errors present at first render are focus-ineligible and, because the alert is already in the initial DOM, are not announced either. State the intended outcome.
- In a radio group,
aria-invalidlands on each radio and
Source: ariakit/ariakit