All docs
3 min read

React recipe

For Next.js specifically see the Next.js recipe. For Remix see Remix. This page covers plain React (Vite or CRA-style apps).

Uncontrolled form (the simple one)

Let the DOM hold the values. Read them with FormData on submit. Less state, less re-rendering, fewer bugs.

import { useState } from 'react';

export function Contact() {
  const [status, setStatus] = useState<'idle' | 'sending' | 'ok' | 'error'>('idle');

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus('sending');

    const fd = new FormData(e.currentTarget);
    const r = await fetch(import.meta.env.VITE_FORMSPRING_URL, {
      method: 'POST',
      body: fd,
    });

    setStatus(r.ok ? 'ok' : 'error');
    if (r.ok) {
      e.currentTarget.reset();
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button disabled={status === 'sending'}>
        {status === 'sending' ? 'Sending…' : 'Send'}
      </button>
      {status === 'ok' && <p>Thanks.</p>}
      {status === 'error' && <p className="err">Something went wrong.</p>}
    </form>
  );
}

Controlled form (when you need it)

Use controlled inputs only when you need to react to keystrokes — character counters, validation as you type, dependent fields.

import { useState } from 'react';

export function Contact() {
  const [email, setEmail] = useState('');
  const [message, setMessage] = useState('');
  const [sending, setSending] = useState(false);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setSending(true);
    await fetch(import.meta.env.VITE_FORMSPRING_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, message }),
    });
    setSending(false);
    setEmail('');
    setMessage('');
  }

  return (
    <form onSubmit={onSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
      <textarea value={message} onChange={(e) => setMessage(e.target.value)} required />
      <p>{message.length} / 500</p>
      <button disabled={sending}>Send</button>
    </form>
  );
}

With useActionState (React 19+)

If you're on React 19, the new useActionState cuts boilerplate further. You write an async action, React tracks pending state.

import { useActionState } from 'react';

async function submitContact(_prev: any, formData: FormData) {
  const r = await fetch(import.meta.env.VITE_FORMSPRING_URL, {
    method: 'POST',
    body: formData,
  });
  if (!r.ok) {
    return { error: 'Submission failed.' };
  }
  return { ok: true };
}

export function Contact() {
  const [state, action, pending] = useActionState(submitContact, null);

  return (
    <form action={action}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button disabled={pending}>{pending ? 'Sending…' : 'Send'}</button>
      {state?.ok && <p>Thanks.</p>}
      {state?.error && <p className="err">{state.error}</p>}
    </form>
  );
}

Reusable hook

// src/hooks/useFormspring.ts
import { useState } from 'react';

export function useFormspring(endpoint: string) {
  const [status, setStatus] = useState<'idle' | 'sending' | 'ok' | 'error'>('idle');
  const [error, setError] = useState<string | null>(null);

  async function submit(payload: FormData | Record<string, unknown>) {
    setStatus('sending');
    setError(null);
    try {
      const init: RequestInit = { method: 'POST' };
      if (payload instanceof FormData) {
        init.body = payload;
      } else {
        init.headers = { 'Content-Type': 'application/json' };
        init.body = JSON.stringify(payload);
      }
      const r = await fetch(endpoint, init);
      if (!r.ok) {
        throw new Error(`HTTP ${r.status}`);
      }
      setStatus('ok');
    } catch (e: any) {
      setStatus('error');
      setError(e.message);
    }
  }

  return { status, error, submit };
}

What's next