Skip to content

Async submission

Return a promise from onSubmit when your submit flow is asynchronous.

async function onSubmit(values: LoginValues) {
const response = await api.login(values);
if (!response.ok) {
throw new Error('Invalid credentials');
}
}

While the promise is pending, the form sets isProcessing to true and submit buttons mark themselves aria-disabled automatically, which blocks activation without dropping the focus of the user who pressed the button. The button also shows a spinner, positioned inside its own padding so the button does not change size mid-submit; it collapses to a static dot under prefers-reduced-motion.

The form also announces the in-flight state to assistive technology through a polite live region, so pressing submit is not met with silence. It is screen-reader-only — the dimmed button is already the visual signal — and it says Submitting… by default. Reword it when that is not the action, or pass an empty string to stay silent:

<Form onSubmit={onSubmit} processingLabel='Signing in…'>

If the handler throws or its returned promise rejects, isProcessing is reset in a finally block. The error message is added to form.state.errors for display.

Each new submit attempt clears existing form.state.errors before calling the handler, so stale server errors do not linger after a retry begins.

Fields stay editable while a submit is in flight — locking the form would take focus away from whatever the user is typing in. With a schema, that creates a race: validation runs against the values captured when submit was pressed, so if any of them change before it resolves, the result describes a form that no longer exists. That submit is discarded rather than sent, and form.state.errors reports The form changed while it was being submitted. Please submit again. so the discard is visible instead of looking like a button that did nothing.

Without a schema there is no async gap to race, and the handler receives the values captured at press time.

Everything above happens automatically for work awaited by onSubmit. When the async work is somewhere else — a router action, a mutation, a resource loading the form’s initial values — tell the form about it with isProcessing and isLoading:

<Form onSubmit={onSubmit} isLoading={user.loading} isProcessing={saveUser.pending}>

isLoading disables every registered field and gives every submit button the same unavailable treatment a real submit gets — the spinner, aria-disabled, the blocked activation — so partially loaded values can never reach onSubmit. It announces Loading… in the form’s status region; override that with loadingLabel or pass '' to stay silent. isProcessing gives you that same treatment while an actual submit is in flight, with its own processingLabel announcement. Because no button initiated either kind of work, every submit button shows the spinner — a real submit shows it only on the button that was pressed.

Both are additional sources rather than overrides — the form is processing when you say so or when it is running a submit of its own. Passing isProcessing={false} therefore cannot cut a real submit short. That is deliberate: the same flag guards against a second submit starting on top of one already in flight, so a false that won outright would let a double submit through.

Use useForm when you want to show state outside the form.

const form = useForm<LoginValues>();
<Show when={form.state.isProcessing}>Signing in...</Show>;
<form.Form onSubmit={onSubmit}>
<InputField name='email' type='email' label='Email' required />
<PasswordField name='password' label='Password' required />
<SubmitButton>Log in</SubmitButton>
</form.Form>;

Naming submit buttons for more than one action

Section titled “Naming submit buttons for more than one action”

onSubmit can be an object instead of a single function — a map from a button’s name to the handler that should run when that button submits the form. Give each SubmitButton a matching name and the right handler runs, whichever one the user pressed.

Press either button below and watch which handler reports back — both are async, so there is a moment to see which one is actually running.

import { SubmitButton, type SubmitResponseMapping, createForm } from 'solid4m';
interface PostValues {
title: string;
}
const { Form, InputField } = createForm<PostValues>();
async function saveDraft(values: PostValues) {
await api.saveDraft(values);
}
async function publish(values: PostValues) {
await api.publish(values);
}
function PostForm() {
return (
<Form<PostValues, SubmitResponseMapping<PostValues>> onSubmit={{ saveDraft, publish }}>
<InputField name='title' label='Title' required />
<SubmitButton name='saveDraft'>Save draft</SubmitButton>
<SubmitButton name='publish'>Publish</SubmitButton>
</Form>
);
}

The second type argument, SubmitResponseMapping<PostValues>, is required for a map: TypeScript cannot work out from the object literal alone that onSubmit is a map rather than a single handler. With useForm, pass it there instead: useForm<PostValues, SubmitResponseMapping<PostValues>>().

Each handler is optionally passed a second argument: the name of the button that triggered it.

type OnSubmitHandler<P, R> = (values: P, buttonName: string) => Promise<R> | void;

That second argument matters when several buttons intentionally share one handler — it is how the handler tells them apart without needing a separate function per button.

A map with exactly one handler needs no matching name on its button at all. With nothing to disambiguate, that single handler runs regardless of whether the button that submitted has a name, or any name at all — silently doing nothing would be worse than running the one handler there is.

With two or more handlers, a button that submits without a matching name — unnamed, or named something not in the map — can’t be resolved. Nothing runs: no handler is called, no error is set, and the form looks like it did nothing. That silence is exactly what this library tries to avoid everywhere else, so it is not silent here either — a console.warn names the problem and lists the handlers that were available, so the fix (add or correct a name) is visible in development instead of discovered from a support ticket.

The in-flight treatment from the top of this page still applies per button: while either handler is running, every SubmitButton in the form goes aria-disabled — a second submit really is unavailable while one is in flight — but only the button that was actually pressed shows its spinner, so the form never claims two actions are running when only one is.

See Accessibility for how the in-flight and invalid-submit treatment fits into the rest of the form’s focus and live-region behavior.