Docs

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 (or usePage().fields) and buttons calling prevPage/nextPage; make Next type="submit" so Enter advances. Show stepIndex + 1 of pageCount.
  • Errors: errors[key] per field; submitError for form-level failures (network, rate_limited, agents_blocked…). Server field errors arrive in errors too.
  • Success: branch on status === 'success'; for redirect/schedule either let the hook navigate or pass navigate and use successUrl.

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.

Form, Field, Conditional logic, Agents, API