Skip to main content

Modern Form Actions

React 19+ introduces a more declarative approach to form handling with form actions and the useActionState hook.
Form actions work seamlessly with React Server Components and provide built-in handling for async operations.

Basic Form Action

Instead of using onSubmit, you can pass an action function directly to the form:
function Signup() {
  function signupAction(formData) {
    const email = formData.get('email');
    const password = formData.get('password');
    
    console.log({ email, password });
  }

  return (
    <form action={signupAction}>
      <div className="control">
        <label htmlFor="email">Email</label>
        <input id="email" type="email" name="email" />
      </div>

      <div className="control">
        <label htmlFor="password">Password</label>
        <input id="password" type="password" name="password" />
      </div>

      <button>Sign up</button>
    </form>
  );
}
Form actions automatically prevent the default form submission behavior - no need for event.preventDefault().

Using useActionState

The useActionState hook manages form state and provides feedback to users:
import { useActionState } from 'react';
import {
  isEmail,
  isNotEmpty,
  isEqualToOtherValue,
  hasMinLength,
} from '../util/validation';

export default function Signup() {
  function signupAction(prevFormState, formData) {
    const email = formData.get('email');
    const password = formData.get('password');
    const confirmPassword = formData.get('confirm-password');
    const firstName = formData.get('first-name');
    const lastName = formData.get('last-name');
    const role = formData.get('role');
    const terms = formData.get('terms');
    const acquisitionChannel = formData.getAll('acquisition');

    let errors = [];

    if (!isEmail(email)) {
      errors.push('Invalid email address.');
    }

    if (!isNotEmpty(password) || !hasMinLength(password, 6)) {
      errors.push('You must provide a password with at least six characters.');
    }

    if (!isEqualToOtherValue(password, confirmPassword)) {
      errors.push('Passwords do not match.');
    }

    if (!isNotEmpty(firstName) || !isNotEmpty(lastName)) {
      errors.push('Please provide both your first and last name.');
    }

    if (!isNotEmpty(role)) {
      errors.push('Please select a role.');
    }

    if (!terms) {
      errors.push('You must agree to the terms and conditions.');
    }

    if (acquisitionChannel.length === 0) {
      errors.push('Please select at least one acquisition channel.');
    }

    if (errors.length > 0) {
      return { errors };
    }

    return { errors: null };
  }

  const [formState, formAction] = useActionState(signupAction, {
    errors: null,
  });

  return (
    <form action={formAction}>
      <h2>Welcome on board!</h2>
      <p>We just need a little bit of data from you to get you started</p>

      <div className="control">
        <label htmlFor="email">Email</label>
        <input id="email" type="email" name="email" />
      </div>

      <div className="control-row">
        <div className="control">
          <label htmlFor="password">Password</label>
          <input id="password" type="password" name="password" />
        </div>

        <div className="control">
          <label htmlFor="confirm-password">Confirm Password</label>
          <input
            id="confirm-password"
            type="password"
            name="confirm-password"
          />
        </div>
      </div>

      <hr />

      <div className="control-row">
        <div className="control">
          <label htmlFor="first-name">First Name</label>
          <input type="text" id="first-name" name="first-name" />
        </div>

        <div className="control">
          <label htmlFor="last-name">Last Name</label>
          <input type="text" id="last-name" name="last-name" />
        </div>
      </div>

      <div className="control">
        <label htmlFor="phone">What best describes your role?</label>
        <select id="role" name="role">
          <option value="student">Student</option>
          <option value="teacher">Teacher</option>
          <option value="employee">Employee</option>
          <option value="founder">Founder</option>
          <option value="other">Other</option>
        </select>
      </div>

      <fieldset>
        <legend>How did you find us?</legend>
        <div className="control">
          <input
            type="checkbox"
            id="google"
            name="acquisition"
            value="google"
          />
          <label htmlFor="google">Google</label>
        </div>

        <div className="control">
          <input
            type="checkbox"
            id="friend"
            name="acquisition"
            value="friend"
          />
          <label htmlFor="friend">Referred by friend</label>
        </div>

        <div className="control">
          <input type="checkbox" id="other" name="acquisition" value="other" />
          <label htmlFor="other">Other</label>
        </div>
      </fieldset>

      <div className="control">
        <label htmlFor="terms-and-conditions">
          <input type="checkbox" id="terms-and-conditions" name="terms" />I
          agree to the terms and conditions
        </label>
      </div>

      {formState.errors && (
        <ul className="error">
          {formState.errors.map((error) => (
            <li key={error}>{error}</li>
          ))}
        </ul>
      )}

      <p className="form-actions">
        <button type="reset" className="button button-flat">
          Reset
        </button>
        <button className="button">Sign up</button>
      </p>
    </form>
  );
}

How useActionState Works

1

Initialize the hook

Call useActionState with your action function and initial state:
const [formState, formAction] = useActionState(signupAction, { errors: null });
2

Connect to the form

Pass formAction to your form’s action prop:
<form action={formAction}>
3

Access previous state

Your action receives the previous state as the first parameter:
function signupAction(prevFormState, formData) {
  // prevFormState contains the previous return value
}
4

Return new state

Return the new state from your action:
if (errors.length > 0) {
  return { errors };
}
return { errors: null };
5

Use the state

Access the current state in your component:
{formState.errors && (
  <ul className="error">
    {formState.errors.map((error) => (
      <li key={error}>{error}</li>
    ))}
  </ul>
)}

Extracting Form Data

The FormData API provides convenient methods for extracting values:
const email = formData.get('email');
const password = formData.get('password');

Validation Patterns

Collecting Validation Errors

function validateForm(formData) {
  const errors = [];
  
  const email = formData.get('email');
  if (!isEmail(email)) {
    errors.push('Invalid email address.');
  }
  
  const password = formData.get('password');
  if (!hasMinLength(password, 6)) {
    errors.push('Password must be at least 6 characters.');
  }
  
  return errors;
}

Field-Specific Errors

function signupAction(prevFormState, formData) {
  const fieldErrors = {};
  
  const email = formData.get('email');
  if (!isEmail(email)) {
    fieldErrors.email = 'Invalid email address.';
  }
  
  const password = formData.get('password');
  if (!hasMinLength(password, 6)) {
    fieldErrors.password = 'Password too short.';
  }
  
  if (Object.keys(fieldErrors).length > 0) {
    return { fieldErrors };
  }
  
  return { fieldErrors: null };
}

Advantages of Form Actions

Declarative

Pass functions directly to forms without managing event handlers manually

Built-in State

useActionState manages form state and previous values automatically

Server Integration

Works seamlessly with React Server Components and Server Actions

Progressive Enhancement

Forms can work without JavaScript when using Server Actions
Form actions and useActionState require React 19 or later. For older versions, use traditional onSubmit handlers.
Source Code: Section 17b - Form Actions

Build docs developers (and LLMs) love