Headless
Behavior and accessibility with no markup: useMirioForm, FormProvider, useForm, useField, usePage, useFormStatus, Honeypot, Turnstile.
@trymirio/mirio/headless is what the styled components are built on. Use it to render a form with your own elements and classes while keeping Mirio's validation, focus management, conditional logic, partial saves, retries and spam signals.
Preview
No styled preview: the output is whatever markup you write. The example site's /custom page is a complete headless form.
Usage
import { useMirioForm, FormProvider, useField, useForm, Honeypot } from '@trymirio/mirio/headless';
import { mirio } from '@/mirio/client';
import { feedback } from '@/mirio/forms/feedback';
function TextField({ name }: { name: string }) {
const { field, visible, value, setValue, error, inputProps, errorId } = useField(name);
if (!visible) return null;
return (
<div>
<label htmlFor={inputProps.id}>{field.label}</label>
<input {...inputProps} value={(value as string) ?? ''} onChange={(e) => setValue(e.target.value)} />
{error ? <p id={errorId}>{error}</p> : null}
</div>
);
}
function SubmitButton() {
const { status } = useForm();
return <button type="submit" disabled={status === 'submitting'}>Send</button>;
}
export function Feedback() {
const form = useMirioForm(feedback, { client: mirio });
if (form.status === 'success') return <p>Thanks! Submission {form.result?.id} is in.</p>;
return (
<FormProvider form={form}>
<form {...form.formProps}>
<Honeypot />
<TextField name="comments" />
<TextField name="email" />
<SubmitButton />
</form>
</FormProvider>
);
}
Spread form.formProps on a real <form>: it wires onSubmit (Enter submits a single-page form or advances a multi-step one) and noValidate.
Props
useMirioForm(schema, options)
| Option | Type | Default | Description |
|---|---|---|---|
client |
MirioClient |
required | From createClient. |
onSuccess |
(result: SubmissionResponse) => void |
— | After a successful submit. |
onError |
(error: MirioError) => void |
— | After a failed submit (code, message, hint, fields, status). |
initialValues |
Record<string, unknown> |
— | Programmatic prefill; wins over prefill.query and defaultValue. |
sessionId |
string |
generated, kept in sessionStorage |
Fixed session id for partial saves. |
actor |
Actor |
the client's actor |
{ type: 'human' | 'agent', name?, onBehalfOf? }, sent as meta.actor. |
navigate |
(url: string) => void |
window.location.assign |
How to follow a redirect/schedule success. |
Everything the hook returns:
| Key | Type | Description |
|---|---|---|
schema, client |
What you passed in. | |
formId |
string |
Stable, SSR-safe prefix for element ids. |
values |
Record<string, unknown> |
Current values of visible fields (hidden fields are pruned). |
errors |
Record<string, string> |
Message per invalid field key. |
touched |
Record<string, boolean> |
Fields that were blurred or validated. |
pageIndex |
number |
Index into schema.pages of the page being shown. |
pageCount |
number |
Number of visible pages. |
stepIndex |
number |
0-based position of the current page among visible pages ("Step stepIndex + 1 of pageCount"). |
visiblePages |
number[] |
Indexes of the pages that are visible right now. |
visibleKeys |
Set<string> |
Keys of the fields that are visible right now. |
status |
'idle' | 'validating' | 'submitting' | 'success' | 'error' |
|
result |
SubmissionResponse | null |
{ id, status, success?, flags? } after success. |
submitError |
MirioError | null |
The last submit failure. |
startedAt |
string |
ISO time the form mounted (time-to-submit signal). |
sessionId |
string | null |
Partial-save session id; null until a multi-step form needs one. |
fields |
Field[] |
All fields, flattened. |
currentPage |
Page |
schema.pages[pageIndex]. |
isMultiStep |
boolean |
More than one page in the schema. |
isFirstPage, isLastPage |
boolean |
Relative to visible pages. |
turnstileToken |
string | null |
Set by Turnstile. |
success |
SuccessBehavior | undefined |
The schema's settings.success (the code the visitor runs); the server's echo only when the schema has none. |
successUrl |
string | null |
Resolved redirect/schedule URL after success (prefill + utm_*), for rendering a link instead of navigating. |
fieldId(key) |
(key) => string |
The control id for a field. |
pageHeadingId(i) |
(i) => string |
The heading id focus moves to on page change. |
isFieldVisible(key), isPageVisible(i) |
=> boolean |
Conditional logic results. |
setValue(key, value) |
Sets a value and clears that field's error. | |
setTouched(key) |
Marks blurred; shows format errors for non-empty values. | |
validatePage(i) |
=> boolean |
Validates one page's visible fields, sets errors, focuses the first invalid control. |
nextPage() |
=> Promise<boolean> |
Validates the current page, moves to the next visible page, fires a partial save. |
prevPage() |
Moves to the previous visible page. | |
goToPage(i) |
Jumps to a page (clamped). | |
submit() |
=> Promise<void> |
Validates every visible field, jumps to the first failing page, otherwise posts. Ignored while submitting or after success. |
reset() |
Back to initial values and page 0; rotates the session id. | |
focusField(key) |
Focuses a control by key (after render). | |
setHoneypot(value), setTurnstileToken(token) |
Used by Honeypot and Turnstile. |
|
resolveSuccessUrl(success?, values?) |
=> string | null |
Same resolution as the automatic redirect, for any success/values. |
formProps |
{ onSubmit, noValidate: true } |
Spread on your <form>. |
FormProvider and useForm()
<FormProvider form={form}> makes the hook result available; useForm() returns it (throws outside a provider; useOptionalForm() returns null instead). The styled Form is a FormProvider too, so the hooks below work inside it.
useField(key)
| Key | Type | Description |
|---|---|---|
field |
Field |
The schema field (throws if key is unknown). |
visible |
boolean |
False when a showWhen hides it; render nothing. |
value, setValue(v) |
||
onBlur() |
Calls setTouched. |
|
error |
string | undefined |
|
touched |
boolean |
|
id, labelId, descriptionId, errorId |
string |
Ids to use on the control, label, help and error elements. |
inputProps |
{ id, name, 'aria-invalid'?, 'aria-describedby'?, 'aria-required'?, onBlur } |
Spread on the control. aria-required instead of required, so Mirio's messages show, not the browser's. For a group (radios, rating), put id, aria-labelledby={labelId} and the aria-* props on the group element with tabIndex={-1}. |
usePage(index?)
Returns { page, index, step, count, fields (visible), allFields, headingId, isVisible, isFirst, isLast, next, prev } for the given or current page.
useFormStatus()
Returns { status, isSubmitting, isSuccess, result, error }.
Honeypot
<Honeypot name="_mirio_website" label="Website" /> renders a visually hidden text input (data-mirio="honeypot", aria-hidden, tabIndex={-1}, autoComplete="off", positioned off-screen with inline styles) whose value is sent as meta.honeypot. Both props are optional; label is the hidden label text. Needs no CSS. Put it inside your <form>.
Turnstile
<Turnstile siteKey="…" className? /> loads Cloudflare Turnstile once, renders the widget in <div data-mirio="turnstile"> and feeds the token into the form. The styled Form renders it when settings.turnstileSiteKey or createClient({ turnstileSiteKey }) is set. Mirio requires and checks the token only on forms whose schema sets the site key, once it has that widget's secret key (see Turnstile).
Also exported
prefillFromQuery(fields, search?), evaluateCondition, visibleFieldKeys, visiblePageIndexes, pruneHiddenValues, resolveSuccessUrl, getOrCreateSessionId, clearSessionId, MirioError, and the types MirioFormState, UseMirioFormOptions, UseFieldResult, Condition, Actor.
Variants and states
- Multi-step: render
currentPage.fields(orusePage().fields) and buttons callingprevPage/nextPage; make Nexttype="submit"so Enter advances. ShowstepIndex + 1ofpageCount. - Errors:
errors[key]per field;submitErrorfor form-level failures (network,rate_limited,agents_blocked…). Server field errors arrive inerrorstoo. - Success: branch on
status === 'success'; for redirect/schedule either let the hook navigate or passnavigateand usesuccessUrl.
Customization
Everything. The headless layer has no classes, no tokens and no data-mirio except on Honeypot and Turnstile. The styled components in @trymirio/mirio are a worked example of building on these hooks.
Accessibility
The hooks give you the pieces; your markup decides. Minimums: spread inputProps on each control, put id={labelId} on the label and htmlFor={id} (or aria-labelledby on a group), give help and error elements the descriptionId/errorId, give page headings id={headingId} tabIndex={-1} so focus can land there, and keep the Honeypot inside the form.