Forms & booking
Submission states, delivering enquiries, and handing off to a scheduler.
MKT UI provides the form UI and one integration point. It does not send email, store submissions, or run a booking backend — those are yours, and the library is explicit about that rather than pretending otherwise.
The submission callback
Every form takes an onSubmit that returns a promise. Resolve it and the form shows
its success state; throw and it shows failure with a retry.
<QuoteRequestForm
services={services}
onSubmit={async (values) => {
const response = await fetch("/api/quote", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) {
// The message is shown to the visitor, so make it useful.
throw new Error("We couldn't send your request. Please try again.");
}
}}
/>Validate on the server
Client-side validation is a convenience for the visitor. It is trivially bypassed. Validate the payload again in your handler, and add your own rate limiting, spam protection, and storage — MKT UI does none of those.
Submission states
Every form covers all of these. They are requirements, not variants: a form that cannot tell a visitor their enquiry failed will silently lose leads.
| State | Behavior |
|---|---|
| Empty | Fields blank, nothing marked invalid |
| Pre-filled | defaultValues populate fields, e.g. from a campaign URL |
| Invalid | Inline errors tied to fields with aria-describedby, focus moves to the first error |
| Submitting | Button disabled, status announced politely, repeat submits blocked |
| Success | Confirmation replaces the form, with an option to send another |
| Failure | role="alert" announcement, retry button, all input preserved |
| Disabled | Whole form off with a visible explanation |
Seeing the failure state
The demos use a deterministic trigger rather than a random failure, so you can inspect
the error path on demand: put fail anywhere in the email address.
Duplicate submissions
useFormSubmission guards re-entry with a ref rather than state:
const inFlight = React.useRef(false);
const submit = async (values) => {
if (inFlight.current) return;
inFlight.current = true;
// …
};Two clicks dispatched in the same tick both read the same rendered state, so a state-based guard lets the second one through. The disabled button is the visible defense; this is the one that actually holds.
Input is never cleared on failure
Nothing in the failure path touches field values. Someone who just typed six lines about their boiler still has them after a dropped request.
Building your own form
Reuse the three pieces the built-in forms are made of rather than starting over:
import { Field, FieldGroup } from "@/components/field";
import { SubmissionStatus } from "@/components/submission-status";
import { useFormSubmission } from "@/hooks/use-form-submission";
function NewsletterForm({ onSubmit }) {
const submission = useFormSubmission({ onSubmit });
return (
<form onSubmit={handleSubmit((v) => submission.submit(v))} noValidate>
<Field label="Email" required error={errors.email?.message}>
{(field) => (
<Input type="email" autoComplete="email" {...register("email")} {...field} />
)}
</Field>
<SubmissionStatus state={submission.state} />
<Button type="submit" disabled={submission.isSubmitting}>
Subscribe
</Button>
</form>
);
}Field owns the aria-describedby / aria-invalid / id plumbing between a label, its
hint, and its error. That is the part most hand-rolled forms get subtly wrong, so it is
worth reusing even for a two-field form.
Accessibility notes
- Labels are always visible. A placeholder is not a label — it disappears the moment someone starts typing, which is exactly when they need it.
- Required fields get a visual
*andaria-required, with the word "required" available to screen readers. - Validation runs on blur, then live once a field has been touched. Validating from the first keystroke tells someone their email is invalid while they are still typing it.
- Failures use
role="alert"; successes userole="status". The live region is always in the DOM and only its contents change, because a region inserted at the same moment as its text is frequently missed.
Booking
For appointments, link out to a scheduler first. It is the honest option: MKT UI has no way to know a business's real availability.
<Button asChild>
<a href="https://cal.com/your-handle/consultation">Book a consultation</a>
</Button>If you embed a scheduler instead, give the iframe a descriptive title and always keep
a plain link as a fallback for when the embed is blocked.
MKT UI does not ship an availability picker. A calendar UI that looks like it books a real appointment but does not is worse than a plain link — the visitor believes they have an appointment and nobody turns up.
What MKT UI will never do
- Collect or persist visitor data. The demos on this site send nothing anywhere.
- Ship a CRM, email delivery, or analytics integration.
- Claim a form converts better than any other form.