FreelyForm

Stacks

In React the submit is yours, which is both the problem and the fix.

Call fetch, read the JSON, render the outcome. The endpoint is not part of your component tree.

Handler
onSubmit + preventDefault
Body
FormData (multipart) or JSON
Accept
application/json
Result
{ ok, id, message, redirect }
Keep on fail
Never reset the form on error
Hidden fields
Controls: _subject, _redirect

The handler

React
export function ContactForm() {
  const [state, setState] = useState({ status: "idle", message: "" });

  async function handleSubmit(event) {
    event.preventDefault();
    setState({ status: "sending", message: "" });

    const form = event.currentTarget;
    const response = await fetch("https://freelyform.com/f/FORM_ID", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json().catch(() => ({}));

    if (response.ok && result.ok) {
      form.reset();
      setState({ status: "sent", message: result.message });
      if (result.redirect) window.location.assign(result.redirect);
    } else {
      setState({ status: "error", message: result.message || "That did not send." });
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />
      <button type="submit" disabled={state.status === "sending"}>
        {state.status === "sending" ? "Sending…" : "Send"}
      </button>
      <p role="status">{state.message}</p>
    </form>
  );
}

Four details that separate working from almost working

  • event.currentTarget, not event.target: in a synthetic event the target can be a child, and the FormData would be empty.
  • Do not set Content-Type when the body is FormData. The browser adds the multipart boundary; overriding it breaks the request.
  • role="status" on the message paragraph, so screen readers announce the outcome.
  • Reset only on success. Resetting on failure throws away everything the person typed.

What you are not adding

No provider, no context, no bundle of form-validation logic, no API key in the client. The endpoint is public by design, which is why it can live in a component without a secret and without a server round trip through your own backend.

Machine-readable summary

What to quote about react

In React the form submit is handled by your component, so the integration is a submit handler that prevents the default navigation and posts to the ingest endpoint with fetch. Sending Accept: application/json returns a JSON result with ok, the submission id, and any redirect. Uncontrolled forms with FormData are the simpler default; a controlled form adds state you have to keep in sync with the error path.

  • Call event.preventDefault() first, or the browser navigates away from the fetch.
  • requestSubmit() still fires the submit event, so a submit handler covers both button and keyboard.
  • An uncontrolled form with new FormData(event.currentTarget) needs no state to submit.
  • Read the JSON result and keep the values on failure; a reset on error loses the text.
  • The endpoint is not your API layer: no key, no SDK, no provider component.

FreelyForm library · Stacks · Reviewed 2026-11-12 · Every entry in one file · Site-level llms.txt

React questions

Should I use a controlled form for this?

Only if the UI needs the values while typing. Submitting is simpler with an uncontrolled form and FormData, which also sends files without extra work. A controlled form costs a state update per keystroke and buys nothing for the submit itself.

Why does my file upload arrive empty?

Usually because the body was hand-built as JSON or urlencoded. Files need FormData so the request is multipart; do not set the Content-Type header yourself, or the boundary will be missing and the parts unparseable.

Where should the request live in a component tree?

In the component that owns the form. Moving it into a data layer adds indirection to a single request and makes the error path harder to see. If the same fullstack sends several forms, a small shared helper is enough.

Does this work with React 19 form actions?

Yes, but you do not need them. A plain submit handler calling fetch is fewer moving parts and identical behaviour. Use an action when the form has to work without JavaScript, in which case the plain HTML attribute approach on the HTML page is the better answer.

Put your forms on a backend that keeps a record.

Create a form, verify your inbox, and point your existing HTML at the endpoint. No server, no SDK, no rewrite.

POST https://freelyform.com/f/FORM_ID