splitforms.com
← Back to the journal

React 19 Form Actions: Contact Form Tutorial

Build a React 19 contact form with form actions and useActionState — no backend needed. POST FormData to a hosted endpoint with spam filtering and email.

Start free — 500 submissions/moSee pricing →No credit card. Paid plans from $5/mo.

What React 19 actually changed for forms

Before React 19, a form was always a controlled component: useState for every field, an onSubmit handler that calls preventDefault(), and manual isLoading/error booleans you had to keep in sync by hand. React 19 adds a second, native way to handle forms — form actions — without removing the old pattern. Both still work.

The core idea: a <form>'s action prop can now be a function instead of only a URL string. When the user submits, React calls that function with the form's FormData, automatically wraps it in a transition, and exposes the in-flight state to the rest of your component tree. Concretely, what's new:

  • action={fn} on forms. Pass a function (sync or async) instead of a URL. React calls it with the FormData for that submission — no manual e.preventDefault(), no manual new FormData(e.currentTarget).
  • formAction on buttons/inputs. An individual <button> or <input type="submit"> inside a form can override the parent form's action with its own function — useful for "Save draft" vs "Publish" buttons in the same form.
  • useActionState replaces useFormState. useFormState was the experimental name (it lived in react-dom); React 19 stabilized it as useActionState in the react package, with the pending flag now returned directly as a third value.
  • useFormStatus for pending UI. A hook that reads the nearest parent <form>'s pending state from any nested child component — no prop drilling a disabled flag down through the tree.
  • Automatic reset for uncontrolled forms. If your inputs aren't bound to value/onChange, React clears them for you after a successful action — one less thing to wire up manually.

None of this requires Next.js Server Actions, a "use server" directive, or any server runtime. Those are a Next.js-specific extension of the same mechanism — this article is deliberately about the plain, client-only version that works in Vite, Create React App, or any React 19 SPA. Inside a plain client-side action function you can do anything an event handler could: call fetch(), update state, show a toast. The rest of this guide builds a complete contact form using exactly that pattern, sending its data to a hosted endpoint so there's nothing to deploy.

Step 1: Get a splitforms access key (1 minute)

The action function needs somewhere to send the FormData. Grab a free endpoint before writing any React:

  1. Open splitforms.com/login in a new tab.
  2. Enter your email and paste the 6-digit code — no password to create.
  3. The dashboard generates an access key automatically. Copy it.
  4. Optional: under Security, add your domain (and localhost) to Allowed Domains so only your app can submit through the key.

Free is 500 submissions/month forever, no card required. Pro is $5/month for 5,000 submissions plus signed webhooks and 13 integrations; Pro is $5/month for 5,000; the 3-year plan is $59 for 15,000/month. Keep the key handy — it goes into a hidden field in the component below.

Step 2: The full contact form with useActionState

Here's a complete, working client component. It uses useActionState to run an async action on submit, POSTs the form's FormData straight to splitforms, and lets useFormStatus drive the submit button from a separate child component:

"use client";

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

// The access key is a PUBLIC form ID — safe to hardcode in client code.
const ACCESS_KEY = "YOUR_ACCESS_KEY";

type FormState = {
  status: "idle" | "success" | "error";
  message: string;
};

const initialState: FormState = { status: "idle", message: "" };

async function submitContactForm(
  _prevState: FormState,
  formData: FormData,
): Promise<FormState> {
  // Honeypot: if a bot filled this hidden field, drop silently.
  if (formData.get("botcheck")) {
    return { status: "success", message: "" };
  }

  try {
    const res = await fetch("https://splitforms.com/api/submit", {
      method: "POST",
      body: formData,
    });
    const data = await res.json();

    if (res.ok && data.success) {
      return { status: "success", message: "Thanks — your message is on its way." };
    }
    return {
      status: "error",
      message: data.message || "Something went wrong. Please try again.",
    };
  } catch {
    return { status: "error", message: "Network error — check your connection." };
  }
}

function SubmitButton() {
  // useFormStatus reads the pending state of the parent <form> — this
  // component must be a CHILD of the form, not the component that
  // renders the form tag itself.
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Sending…" : "Send message"}
    </button>
  );
}

export default function ContactForm() {
  const [state, formAction] = useActionState(submitContactForm, initialState);

  if (state.status === "success" && state.message) {
    return (
      <div className="contact-success" role="status">
        <h3>{state.message}</h3>
        <p>We reply to every message within one business day.</p>
      </div>
    );
  }

  return (
    <form action={formAction} className="contact-form">
      {/* Public form ID — safe in the client bundle. */}
      <input type="hidden" name="access_key" value={ACCESS_KEY} />

      <label>
        Name
        <input type="text" name="name" autoComplete="name" required />
      </label>

      <label>
        Email
        <input type="email" name="email" autoComplete="email" required />
      </label>

      <label>
        Message
        <textarea name="message" rows={5} minLength={10} required />
      </label>

      {/* Honeypot: hidden from humans, bots fill it. Uncontrolled. */}
      <input
        type="checkbox"
        name="botcheck"
        style={{ display: "none" }}
        tabIndex={-1}
        autoComplete="off"
      />

      {state.status === "error" && (
        <p className="contact-error" role="alert">
          {state.message}
        </p>
      )}

      <SubmitButton />
    </form>
  );
}

Replace YOUR_ACCESS_KEY with the key from step 1 and the form works immediately. What's different from a useState-driven form:

  • The action function IS the submit handler. There's no onSubmit, no e.preventDefault(). Passing submitContactForm (wrapped by useActionState into formAction) to the form's action prop is enough — React intercepts the native submission, builds the FormData, and calls your function with it.
  • State comes back from the function's return value. useActionState(fn, initialState) returns [state, formAction]state is whatever the action last returned, so success/error handling is just a return statement instead of three separate setStatus/setError calls.
  • Pending UI lives in a child component. useFormStatus() inside SubmitButton reads the parent form's in-flight state automatically — no isPending prop passed down, no third state variable to manage.
  • Inputs are uncontrolled. No value/onChange on the text fields — FormData reads straight from the DOM, and because they're uncontrolled, React 19's automatic form reset clears them after a successful submission for free.

Step 3: Why useFormStatus needs its own component

A common mistake: calling useFormStatus() in the same component that renders the <form> tag. It won't work there — useFormStatus only reports the status of a form rendered above it in the tree, never the form it's declared alongside. That's why SubmitButton above is a separate function component nested inside the <form>, not inline JSX in ContactForm.

This constraint is exactly what makes the hook useful for shared UI: you can drop the same SubmitButton component into any form anywhere in your app, and it always reflects whichever form actually wraps it — no props, no context, no prop drilling a boolean five components deep.

Step 4: Error and success states

The whole state machine is the single state object returned by useActionState{ status, message }. There's no separate isSubmitting boolean to fall out of sync with status, because pending comes from useFormStatus (transition-derived) and the result comes from the action's return value (also transition-derived) — both update atomically when the submission resolves.

  • idlestate.status === "idle", the initial value. Nothing renders beyond the form itself.
  • pending — read via useFormStatus().pending inside SubmitButton, not via state. This is the one piece of status that isn't in the action's return value, because it's true while the action is running, before it has returned anything.
  • success — the action resolved with data.success true. The component swaps to a thank-you block instead of re-rendering the form, and (because inputs are uncontrolled) React has already cleared them.
  • error — either a non-2xx/validation rejection from splitforms, or a caught network exception. Both paths return the same shape so the JSX only needs one state.status === "error" branch.

Step 5: The honeypot field, and why it's in FormData for free

The hidden botcheck field is a honeypot: invisible to real visitors, but bots that parse the DOM fill in every input they find, including this one. Because form actions receive the whole FormData object — not a hand-picked subset of fields — the honeypot rides along automatically with zero extra plumbing:

<input
  type="checkbox"
  name="botcheck"
  style={{ display: "none" }}
  tabIndex={-1}
  autoComplete="off"
/>

Three details keep it effective: display: none as an inline style (some bots skip class-hidden elements but still fill inline-hidden ones), tabIndex={-1} so keyboard and screen-reader users never tab into it, and autoComplete="off" so a password manager doesn't "helpfully" fill it and flag a real user as a bot. The action function checks formData.get("botcheck") first and short-circuits to a fake success before ever hitting the network — though splitforms also re-checks server-side, so a client bypass still gets caught.

A honeypot alone stops roughly 80% of bot spam. splitforms layers time-trap detection, rate limiting, and heuristic/AI classification on top server-side, with optional reCAPTCHA v2 if you want a second layer — none of which needs any additional React code.

Step 6: Progressive enhancement — the no-JS fallback

A function passed to action only runs if React's JavaScript has loaded. If the bundle fails — a slow connection, an ad blocker, a script error elsewhere on the page — the form above does nothing when submitted. The fix is built into the HTML spec, not React: a form's action can also just be a URL string, and the browser will POST to it natively with zero JavaScript involved:

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
  <input type="text" name="name" required />
  <input type="email" name="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send message</button>
</form>

This is the same endpoint, the same access key, the same honeypot pattern — just without the pending UI, inline errors, or the success block swap, because none of that is possible without JS. splitforms handles both cases identically server-side: a plain URL-action form redirects the browser to a thank-you page (or back to the referring page) after submitting, while the JS-enhanced version gets a JSON response it can render inline.

You don't have to choose one or the other for your whole app. A common pattern is to render the plain <form action="https://splitforms.com/api/submit"> by default and only swap in the useActionState version once you know the component has hydrated — though for most marketing and product sites, shipping the JS-enhanced version alone is the right tradeoff, since the fallback mainly matters for JS-disabled crawlers and extremely low-end connections.

Form actions + hosted endpoint vs. rolling your own API route

What you needForm action + hosted endpointYour own API route / server action
Server to deployNoneYes — Node runtime, or a Next.js server
Email deliveryHandled by the endpointYou configure SMTP or a transactional email API
Spam filteringHoneypot + time-trap + rate limiting + heuristics built inYou write and maintain it
Storage / dashboardIncludedYou add a database and build a UI
Code you ownOne client componentRoute handler, mailer, validation, spam logic, DB schema
Works with plain client-only React (no Next.js)YesNo — API routes and Server Actions require a framework server

The form-action mechanism is framework-neutral — it's a React feature, not a Next.js one. What determines whether you need a server is what the action does, not that it exists. An action that only calls fetch() against an external endpoint never touches your infrastructure; an action that reads a database or sends email directly needs somewhere to run with server privileges, which is exactly what Next.js Server Actions ("use server") are for. For a contact form, there's nothing server-only to do — the hosted endpoint already does it — so the plain client version is the simpler, correct choice.

Troubleshooting

  • "useFormStatus must be used within a <form>" / it always returns pending: false. You called useFormStatus() in the same component that renders the <form> tag. Move it into a child component (like SubmitButton above) that's rendered inside the form's JSX.
  • Fields don't clear after a successful submission. You likely bound the inputs with value/onChange (controlled). Automatic form reset only applies to uncontrolled inputs — either remove the controlled bindings, or clear your own state manually after state.status === "success".
  • "Functions cannot be passed directly to Client Components" error. This fires if you try to pass a plain function as action from a Server Component without "use server". Add "use client" to the top of the file that renders the form — this whole pattern is client-side, not a Server Action.
  • Submission does nothing, no error shown. Open the Network tab and check the request to /api/submit. A 200 with success: false is a validation or honeypot rejection; a 401 means a bad key or a blocked origin (check Allowed Domains).
  • TypeScript complains about the action's return type. useActionState infers the state type from initialState and the action's return type — make sure your action function's Promise resolves to the exact same shape as initialState (see the FormState type above), including every branch (success and error).

Next steps

FAQ

Do React 19 form actions need a server?

No. A form action is just a function you pass to a form's action prop — React calls it on submit and manages the pending state for you. If that function is a plain async client-side function (not a Next.js Server Action marked "use server"), everything happens in the browser: no framework server, no API route, no Node runtime. Inside the function you can call fetch() to POST the FormData anywhere you want, including a hosted form endpoint like splitforms.com/api/submit. This works in Create React App, Vite, and any React 19 client component — Next.js is not required at all.

What replaced useFormState in React 19?

useActionState. useFormState lived in react-dom during the canary/experimental period; React 19 renamed it to useActionState and moved it into the react package itself (import { useActionState } from "react"). The signature is the same shape — useActionState(action, initialState) returns [state, formAction, isPending] — but useActionState also gives you the pending boolean directly as the third array item, so in many cases you no longer need a separate useFormStatus call just to know if a submission is in flight.

What's the difference between useActionState and useFormStatus?

useActionState wraps an action function and tracks its result and pending state — you call it in the component that owns the <form> and passes the returned formAction to the form's action prop. useFormStatus reads the pending state of the nearest parent <form> from any child component, without prop drilling — but it only works inside a component rendered underneath that form, not in the same component that renders the form tag. Use useActionState for the form's own state and result; use useFormStatus when you have a separate <SubmitButton /> component nested inside the form that needs to know if it's submitting.

Can I use React 19 form actions without any backend at all?

For storage, yes, but you still need somewhere for the data to land. A form action happily runs client-side with zero server, but if you don't POST the FormData anywhere it just disappears after the function returns. The practical zero-backend setup is: form action calls fetch() against a hosted endpoint (splitforms, Formspree, or similar), which handles storage, email delivery, and spam filtering for you. That's genuinely no backend you write or deploy — just an HTTP call.

Does the form still work if JavaScript fails to load?

Only if the action is a URL string, not a function. <form action={handleSubmit}> requires JavaScript — if the bundle fails to load, the button does nothing. For guaranteed progressive enhancement, use plain <form action="https://splitforms.com/api/submit" method="POST"> with no onSubmit and no function action; the browser's native form submission handles it with zero JS. You can ship both: render the native-URL fallback by default and layer the JS-enhanced useActionState version on top once hydration completes, or simply accept the tradeoff for an internal tool where JS is guaranteed.

Why does the form reset automatically after a successful action?

React 19 added automatic form reset: when a form's action is an uncontrolled form (inputs without a value prop bound to state) and the action function returns successfully, React clears the inputs for you — no manual setValues({}) call needed. This only applies to uncontrolled fields. If you're driving inputs with useState (controlled), you still own the reset and must clear that state yourself, same as in React 18.

Is the access_key safe to hardcode in a client component that uses form actions?

Yes — the access_key is a public form identifier, not a secret, the same way a Stripe publishable key or a GA measurement ID is safe in client code. It only lets someone submit to your form; it can't read submissions or touch your account. It's fine to render it as a hidden input inside the form and let FormData pick it up automatically. If you want to stop other sites from reusing your key, turn on Allowed Domains in the splitforms dashboard.

Do I need react-hook-form or Formik with React 19 form actions?

Not for a simple contact form. Form actions plus useActionState and useFormStatus cover submission state (idle/pending/success/error) without a library — that's the part form libraries used to help with most. You'd still reach for React Hook Form or Zod if you need complex client-side validation, field arrays, or cross-field rules, but for name/email/message, native HTML validation plus a form action is the whole solution.

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

FormData in JavaScript: Submit Forms with fetch (2026 Guide)

The complete FormData guide: reading form fields, appending files, sending multipart and url

9 min readRead →
Tutorials

Custom Auto-Responder Emails for HTML Forms (HTML + CSS, 2026)

Design fully branded auto-responder emails for your HTML forms: paste your own HTML/CSS temp

9 min readRead →
Tutorials

Send Form Emails From Your Own Domain (Custom SMTP, 2026)

Send form notification and auto-responder emails from your own domain with custom SMTP: encr

10 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewContact Forms for Every FrameworkStart here →GuideContact Form for Next.jsRead →GuideContact Form for ReactRead →GuideContact Form for VueRead →ReferenceContact form guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 submissions a month for free.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe