# React 19 contact form with useActionState

A React 19 contact form with useActionState that sends from a plain Vite app. We tested it 7 ways: the usual version wipes the fields even when sending fails.

Author: Formgong
Published: 2026-11-02
Language: en
Canonical: https://formgong.com/en/blog/react-19-useactionstate-contact-form/

In React 19 you pass a function to useActionState and give the form the action it returns: <form action={formAction}>. React calls your function with the previous state and the FormData, and gives you the returned state plus a pending flag for the button. In a Vite app there is no server, so the function posts the FormData with fetch() to a form endpoint and returns a success or error message. One catch: after the action finishes, React resets the form even when your function returned an error, so return the typed values and put them back with defaultValue.

## What useActionState does in a form

`useActionState` takes a function and a starting state and returns three things: the current state, an action you can pass to `<form action>`, and a flag that is true while the action runs. The React docs give the shape as `const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState)`. When the form is submitted, React calls your function with the previous state and the form's `FormData`. Whatever the function returns becomes the new state.

Most examples you find are written for Next.js, where that function is a Server Function that runs on the server. A Vite or Create React App project has no server to run it on, so the function runs in the browser and needs somewhere to send the data. A form endpoint is the simplest place: one `fetch()` POST, and the endpoint emails the message and answers with JSON. If you are on Next.js, our comparison of [Server Actions and a form backend](/en/blog/nextjs-server-actions-vs-form-backend/) covers that side.

## The form most tutorials show

This is the shape you will see in most React 19 tutorials, with the sending part filled in. The function never throws. It catches a network failure and turns every outcome into a message, because a thrown error would go to the nearest error boundary instead of showing next to the form.

```jsx
import { useActionState } from "react";

async function send(formData) {
  formData.set("access_key", "fk_your_access_key");
  try {
    const response = await fetch("https://formgong.com/submit", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: formData,
    });
    const result = await response.json();
    return result.success === true
      ? { ok: true, message: "Thanks! We'll reply soon." }
      : { ok: false, message: result.message || "Not sent. Please try again." };
  } catch {
    return { ok: false, message: "Not sent: check your connection and try again." };
  }
}

export function ContactForm() {
  const [state, formAction, pending] = useActionState(
    async (_previous, formData) => send(formData),
    null,
  );
  return (
    <form action={formAction}>
      <input name="email" type="email" required aria-label="Email" />
      <textarea name="message" required aria-label="Message" />
      <button type="submit" disabled={pending}>{pending ? "Sending…" : "Send"}</button>
      {state && <p role="status">{state.message}</p>}
    </form>
  );
}
```

## Seven tests, and where it goes wrong

We built it with Vite, served the production build, and drove it in Chromium against the real endpoint: a correct access key, a wrong one, the browser switched to offline, and a double click. Then we did the same with a fixed version.

Vite + React 19.2 production build in Chromium, 2 November 2026. Each row filled in the email and message fields and pressed Send once (one row double-clicked). Every row sent exactly one request.
VersionTestMessage the visitor sawEmail and message fields after
Tutorial versionCorrect access keyThanks! We'll reply soon.Cleared
Tutorial versionWrong access keyThat access key does not exist.Cleared
Tutorial versionBrowser offlineNot sent: check your connection and try again.Cleared
Tutorial versionDouble click, wrong keyThat access key does not exist.Cleared
Fixed versionWrong access keyThat access key does not exist.Kept
Fixed versionBrowser offlineNot sent: check your connection and try again.Kept
Fixed versionCorrect access keyThanks! We'll reply soon.Cleared
Sending works. The visitor sees “Sending…” while the request runs, the server's own error text comes through (“That access key does not exist.”), and the double click still sent one request, because the button is disabled while `pending` is true.

The trouble is the fourth column. With the tutorial version, the email and message were empty after every test, including the two where nothing was sent. A visitor who just lost their connection gets “check your connection and try again” over a form that no longer holds the message they wrote.

## Why React clears the fields

This is documented behaviour. The React reference for `<form>` says: “After the action function succeeds, all uncontrolled field elements in the form are reset.” For React, “succeeds” means your function returned without throwing. It does not look at what you returned, so a function that returns `{ ok: false }` has succeeded as far as React is concerned, and the fields are reset.

Throwing instead is not a good fix for a contact form, since the error then replaces the form with the error boundary's fallback. The fix we tested keeps the function as it is and, when sending fails, adds the typed values to the returned state. The fields read them back through `defaultValue`, and the `key` makes React create each field again with that value. In our test the fixed version kept both fields after the wrong key and after going offline, and still cleared them after a successful send.

```jsx
export function ContactForm() {
  const [state, formAction, pending] = useActionState(async (_previous, formData) => {
    const result = await send(formData);
    // On failure, hand back what the visitor typed so the fields can show it again.
    return result.ok ? result : { ...result, values: Object.fromEntries(formData) };
  }, null);
  const values = state?.values ?? {};

  return (
    <form action={formAction}>
      <input name="email" type="email" required aria-label="Email"
        defaultValue={values.email} key={"email" + values.email} />
      <textarea name="message" required aria-label="Message"
        defaultValue={values.message} key={"message" + values.message} />
      <button type="submit" disabled={pending}>{pending ? "Sending…" : "Send"}</button>
      {state && <p role="status">{state.message}</p>}
    </form>
  );
}
```

## The endpoint behind it

The code above posts to Formgong. With `Accept: application/json` the endpoint answers `{ "success": true }` or `success: false` with a message you can show as is, and it sends the CORS headers, so the request works from any site. Messages go to your email and a dashboard inbox, with a spam filter in front. The [React setup page](/en/for/react/) has the same form as a copy-paste component in JavaScript and TypeScript.

To try it, create a form on the [Formgong home page](/en/), copy its access key and paste it in place of `fk_your_access_key`. Then send one test message from the built site, not only from the dev server.

## Did your form behave differently?

If your React version keeps the fields without the fix, or you found a cleaner way to restore them, write to us at [support@formgong.com](mailto:support@formgong.com) with the React version and a short snippet. We will run it and update the table.

## What Formgong's free plan includes

- **Build the form or bring your own.** The [form builder](/en/tools/form-builder/) takes the colours and fonts from your site's address, with no sign-up; ready [form templates](/en/templates/) copy into your account; AI writes the fields from one sentence. Any hand-written HTML form works too.

- **Websites with the form already connected.** 28 free [website templates](/en/website-templates/) post to Formgong out of the box.

- **250 submissions a month on unlimited forms.** Spam does not count. Once a year, a busy month keeps delivering on credit until the 7th instead of stopping.

- **Everywhere you work.** Email to up to 3 people at once, [Telegram](/en/integrations/telegram/) with Reply and Handled buttons, [Google Sheets](/en/integrations/google-sheets/), [Notion](/en/integrations/notion/), and 3 signed [webhooks](/en/integrations/webhooks/) for Slack, Discord, Zapier, Make or n8n.

- **Emails to your customers in your business's name.** Formgong sends them under your business name and replies come back to you, with no email service of your own to set up: a review request to every lead, a reminder a visitor asked for, and review emails after an order with your logo and colours. People who agree can also go straight into your [Mailchimp, Klaviyo, Brevo, MailerLite or Kit](/en/docs/marketing-lists/) list. An automatic reply to each visitor is on Pro.

- **No captcha needed.** A server-side spam filter, a honeypot, allowed domains and a block list; add your own Turnstile key if you want one.

- **Files and voice.** Visitors can attach files, or record a [voice message](/en/docs/voice-field/) that arrives transcribed.

- **Made for AI-built sites.** A hosted form page and a contact button; step-by-step guides for sites built with [Lovable](/en/docs/lovable/), [Bolt](/en/docs/bolt/), [v0](/en/docs/v0/), [Replit](/en/docs/replit/) and [Base44](/en/docs/base44/); an [MCP server](/en/docs/mcp/) lets Cursor or Claude wire the form for you.

- **See what the form does.** [Analytics](/en/docs/analytics/) for views, starts, conversion and where each lead came from, and an alert when a form goes quiet.

- **One account for the rest.** Customer reviews and an [AI help chat](/en/docs/help-chat/) that answers visitors from your own pages, with every message in one inbox. Data is stored in the EU.

## Frequently asked questions

### Can I use useActionState without Next.js?

Yes. In a Vite or other client-only React 19 app the action runs in the browser. It can call fetch() to send the FormData to an API or a form endpoint and return a message as the new state.

### Why does my useActionState form clear itself after an error?

React resets uncontrolled fields after the action function returns without throwing, whatever it returned. Return the typed values on failure and pass them back with defaultValue (and a key) so the fields keep them.

### How do I show a loading state on the button?

Use the third value useActionState returns. It is true while the action runs, so disabled={pending} also stops a second request on a double click.

### Should the action throw on a failed send?

Not for a contact form. A thrown error goes to the nearest error boundary, which replaces the form. Catch the error and return a message instead.

## Sources and documentation

- [React: useActionState](https://react.dev/reference/react/useActionState)
- [React: <form>](https://react.dev/reference/react-dom/components/form)
- [React](https://formgong.com/en/for/react/)

[Get a form key](https://formgong.com/en/#top)
