Skip to content
Docs · 11 pages

Docs / Patterns

Forms that send

One function turns a plain posting form into a fetch with a pending state, spoken status, a thanks-page record and the value back after a failure. The form still posts with JavaScript off.

8 min readThis page as MarkdownSource on GitHub

A form here is a real form first: method="post" and an action that works with JavaScript off. bindForms() is the enhancement. It takes over the submit, posts with fetch, keeps the button focused while it waits, speaks the result through a status line, and either stays on the page or moves on to a thanks page. It adds 2.9 kB raw to the runtime it binds through: 4.8 kB raw for both, measured on its own fixture page.

import { bindForms } from '@moonarc/core/form';

bindForms(); // every form[data-ma-form], now and after every ClientRouter navigation

Call it from the script of the section or layout that renders the form. A second call is a no-op, so two sections on one page cannot bind a form twice. Forms without data-ma-form, and marked forms whose method is not post, are left alone.

The markup

<form data-ma-form method="post" action="https://formspree.io/f/abcd1234"
      data-ma-form-status="join-status"
      data-success="/thanks/"
      data-msg-pending="Adding you…" data-msg-done="You're on the list."
      data-msg-exists="You're already on the list." data-msg-error="That didn't go through. Try again."
      data-msg-429="Too many tries. Wait a minute." data-msg-offline="You're offline. Try again when you're back.">
  <label for="join-email">Email</label>
  <input id="join-email" name="email" type="email" autocomplete="email" required>
  <button>Join</button>
</form>
<p id="join-status" role="status"></p>

The status line must be in the page when it loads, not hidden and without aria-busy: a live region that appears with its text, or is busy, is not announced. The same text twice in a row is emptied for two frames first, so it is announced again. It can sit inside the form or anywhere else; the form names it by id.

Attribute What it does
data-ma-form-status Required. The id of the role="status" element.
data-endpoint Where fetch posts. Unset, the form’s action.
data-encode multipart (default: the FormData itself), urlencoded, or json.
data-success A page to go to after success. Unset, the form stays.
data-carry Response keys to hand to that page, space-separated.
data-ref-param A URL parameter (ref) to send along (see Referrals).
data-ma-form-demo No request: success with this JSON as the response.
data-msg-pending, -done, -exists, -error, -429, -offline The status texts. A missing 429 or offline says the error text; a missing exists says done.

Every request carries Accept: application/json. With json, fields with the same name keep the last value, and files need multipart.

What a submit does

  1. The browser validates the form as usual. Only a valid form fires submit.
  2. The listener on the form itself calls preventDefault() and reads the FormData before anything asynchronous happens, and so before VanishInput’s listener on document has emptied the field.
  3. While waiting: data-pending on the form, aria-disabled="true" on its submit buttons (not disabled, which drops the focus to the page), novalidate on the form, and the pending text. Another submit is ignored: prevented and stopped at the form, so nothing listening on document sees it and nothing is sent twice.
  4. The response decides, then the finite animations inside the form finish (an endless shimmer on the button is not waited for). A tab in the background, where animations do not run, waits two --ma-duration plus one --ma-stagger-tight per character sent instead. Before leaving for data-success, it waits up to two --ma-duration more for what the done state animates. Each response sets:
Response data-state Text Then
2xx, and ok is not false in a JSON body done done ma:form-done; to data-success, or stay
409, or status: 'exists' in the body exists exists stay
429 error 429 value back, focus on the field
any other status, or ok: false error error value back, focus on the field
no response (offline, blocked) error offline value back, focus on the field

After done or exists without leaving, the form stays as it is (buttons aria-disabled, focus where it was, the status reading the result), and a second submit is ignored until a field changes. The first change clears data-state and the lock, and brings back the form’s own novalidate setting.

After a failure every text field the submit read is refilled, but only if it is still empty: whatever the visitor typed in the meantime stays. The focus goes to the first of them that held text and is on screen, so a hidden honeypot field before it is passed over. The text from the service itself (Formspree’s errors[].message, for instance) is never shown: it cannot be translated, and the status line is in the page’s language.

Two events bubble from the form: ma:form-done with the parsed response (or {} when it was not JSON), and ma:form-error with { status }, where status is 0 when there was no response. exists sends neither; read data-state.

The thanks page

On success with a data-success on the same origin, just before leaving, the lib writes one record:

sessionStorage['ma:form'] = JSON.stringify({ path: '/thanks/', at: 1758268800000, position: 2401, referral: 'k7Qp2' });

path is the page it is going to and at the time, so the page can tell a fresh arrival from a reload an hour later. The other keys are the ones named in data-carry, copied from the response only when the value is a number or a short token (A–Z a–z 0–9 _ -, 1 to 32 characters), as it came: a service answering "position": "12" leaves the string "12", and the ThankYou section reads a position of 1 to 15 digits as a number or as a string. An email address can never pass that test, so it never reaches storage, the URL or a log. The record is written even when nothing is carried, because the thanks page’s confetti needs to know that a form was just sent. Storage that throws (a private window, blocked site data) is skipped.

A native post writes no record, so a thanks page reached without JavaScript shows no position, no referral and no confetti. Write it to read well without them.

Referrals

data-ref-param="ref" reads ?ref=… from the page’s address. The value is checked like a carried one. If the form has a field with that name (a hidden input, as Netlify needs, because it only stores fields present in the HTML), the lib fills it in when it binds; otherwise the value is appended to the FormData at submit. Without JavaScript the referral is lost.

Services

The lib does not know service names. A template maps its form.adapter config onto these attributes:

Adapter Without JavaScript With bindForms()
demo (development only) method="dialog": outside a <dialog> a submit goes nowhere, and a static note says nothing is sent data-ma-form-demo: a fake success with a sample position and referral
formspree posts to https://formspree.io/f/{id} and shows Formspree’s page multipart; a 429 from Formspree says the 429 text
netlify data-netlify, a form-name input, action="/thanks/": Netlify stores it and shows your page data-endpoint="/", urlencoded with form-name
custom posts to your endpoint, which answers 303 to /thanks/ json by default; answer { ok, status?, position?, referral? } with status created or exists, or 409 for exists

A demo form is bound whatever its method, never sends a request, and succeeds once its animations have finished. An empty data-ma-form-demo responds {}.

What happens without JavaScript

Nothing is hidden behind the script. The form posts natively to its action, the browser validates it, the service answers with its own page or a redirect. The status line is empty and stays empty. Test your form with JavaScript off once: that is what a visitor on a slow connection gets before the script arrives.

Using it with VanishInput

VanishInput blurs the typed text away when a submit was prevented: it listens on document and reads defaultPrevented. The lib’s listener is on the form, so it always runs first: it prevents the default synchronously and reads the value before the field is emptied, and the vanish plays while the request is out. A failure waits for the vanish to finish, then puts the text back and focuses the field. An ignored submit (while waiting, or done without a change) is stopped at the form, so the vanish never empties a second value that was not sent.

Two exceptions to the form contract

The form controls follow one contract: native elements with your attributes passed through, native validation, labels, focus rings, 24 px targets. A form bound by bindForms() departs from it in two places, on purpose:

  • Hidden service inputs. A form-name input or Netlify’s referral field are not controls with a label; they are the form’s plumbing, which the contract’s first point (native controls, attributes passed through) does not govern.
  • novalidate while waiting and after success. The field VanishInput emptied is required, so without it a second click would pop the browser’s “please fill in this field” bubble over a form that was just sent. The lib sets novalidate only while it waits, and after done or exists until a field changes; your own value comes back afterwards.

Pages restored from the back/forward cache

A page restored from the back/forward cache (pageshow with persisted) starts over: no data-state, no lock, an empty status line, your own novalidate. A form that was submitted is also reset (form.reset()): every control goes back to its markup, the consent box included, and the field VanishInput emptied loses its :user-invalid error border, which would otherwise show with no message beside it. The referral from ?ref= is filled in again. A form that was never submitted, or that failed and got its values back, keeps what the visitor typed. With <ClientRouter /> the form is a new element on every visit and is bound fresh.