React Actions: Forms Without useState and Handlers
AI generated
</>
{ }
React Actions · Server Actions · useActionState · Forms
React Actions: Forms
without useState and without handlers

A React form with validation, loading state and error handling needed at least five useState hooks in React 18, an onSubmit handler, a fetch call and manual rollback logic. React Actions in React 19 do the same job with a single hook and one action function, and they work even without JavaScript in the browser.

16 min read useActionState · Server Actions · useFormStatus · useOptimistic · FormData React 19 · Next.js 15 · TypeScript

1. The form problem in React 18

Forms are disproportionately expensive in React 18. A simple contact form with validation, loading state and error handling typically requires: useState for every field value, useState for validation errors, useState for the loading state, useState for the success state, and an onSubmit handler that prevents the browser default, validates every field, makes a fetch call with try/catch, sets and resets the loading state, and resets the form on success. That is at least 40 to 60 lines of code for an operation that is conceptually simple: send data to the server and react to the result.

React Actions solve this problem structurally. Instead of managing state and handlers separately, the React Actions model bundles the entire lifecycle, submitting, waiting, error, success, into a single abstraction. The useActionState hook takes over state management and lifecycle coordination. Server Actions take over server-side execution without separate API routes. useFormStatus makes the pending state visible in child components. The result: less code, more consistent error handling, and progressive enhancement without extra effort.

2. What are React Actions?

React Actions are functions passed as the action prop of a form element or button element. React automatically manages the pending state while the action runs. A React Action can be synchronous or asynchronous. It receives a FormData object with all form fields and optionally returns a new state. That is the basic model: close to HTML, declarative, and without an explicit event handler.

The conceptual difference from onSubmit: onSubmit is a JavaScript event handler that only works with JavaScript and must manually call e.preventDefault(). A React Action is semantically closer to the HTML action attribute: it is a function that represents the form submission process, regardless of whether JavaScript is available. React 19 uses this model to build in progressive enhancement: without JavaScript, the browser sends the form via a standard POST. With JavaScript, React takes over the action and runs it without a page reload. React Actions are therefore the cleaner, more modern replacement for every previous form submission pattern in React.

3. useActionState: the central hook for React Actions

useActionState is the hook that connects React Actions to state. It accepts two arguments: the action function and an initial state. It returns three values: the current state (which updates after every action run), a wrapped version of the action (which is passed to the form), and an isPending boolean that is true while the action is running. The action function itself receives the previous state as its first argument and the FormData as its second. That enables accumulating patterns such as optimistic updates or state-dependent validation.

The state of useActionState is returned by the server when the action is a Server Action. That means the Server Action returns a serializable value, for example an object with { success: boolean; errors: Record<string, string>; data: ... }, and this value automatically becomes the new state in the component after execution. There are no manual setState calls, no separate error state, and no manual setting of the loading indicator. React Actions with useActionState are the most declarative form pattern React has ever had.


'use client';
import { useActionState } from 'react';
import { submitContact } from './actions'; // Server Action

// Define state shape for type safety
interface ContactState {
  success: boolean;
  message: string;
  errors: { name?: string; email?: string; message?: string };
}

const initialState: ContactState = {
  success: false,
  message: '',
  errors: {},
};

export function ContactForm() {
  // useActionState: connects the Server Action with local state
  const [state, action, isPending] = useActionState(submitContact, initialState);

  if (state.success) {
    return (
      <div className="bg-green-50 border border-green-200 rounded-xl p-6">
        <p className="text-green-800 font-semibold">{state.message}</p>
      </div>
    );
  }

  return (
    // action prop receives the wrapped Server Action, no onSubmit needed
    <form action={action} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" required />
        {/* Inline field errors from server-side validation */}
        {state.errors.name && (
          <p className="text-red-600 text-sm">{state.errors.name}</p>
        )}
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
        {state.errors.email && (
          <p className="text-red-600 text-sm">{state.errors.email}</p>
        )}
      </div>
      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" required />
        {state.errors.message && (
          <p className="text-red-600 text-sm">{state.errors.message}</p>
        )}
      </div>
      {state.message && !state.success && (
        <p className="text-red-600">{state.message}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send message'}
      </button>
    </form>
  );
}

4. Server Actions: running logic on the server

Server Actions are functions marked with the 'use server' directive that run on the server even though they are called from client code. In the context of React Actions, Server Actions are the server-side counterpart to useActionState: they receive FormData, run validation, database operations and business logic, and return a new state. The return value must be serializable, meaning a JSON-compatible value. The server sends this value back to the client, where React inserts it as the new state in useActionState.

The most important benefit of Server Actions as part of React Actions: there is no separate API route, no separate controller, no separate routing setup. The action is bound directly to the component, lives in a file that belongs semantically to the feature, and is still executed server-side. That improves code locality significantly: validation, database access, and UI state management for the same form live in the same files, one for server, one for client, instead of being spread across API routes, controllers and frontend services.

5. useFormStatus: submit state in child components

useFormStatus is a React 19 hook from react-dom that can read the pending state of the nearest parent form from within a child component. That sounds specific, but it solves a common problem with React Actions: the submit button should be disabled while the action is running, but the button lives in a separate component, and passing the pending state down as a prop means prop drilling.

useFormStatus returns an object with { pending, data, method, action }. The pending flag is true as long as the parent form is executing an action. data contains the form's current FormData object, useful for showing in the button what is currently being submitted. Important: useFormStatus must be used in a component that is a child of the form, not in the component that renders the form. That is a common source of errors when working with React Actions.


'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

// Zod schema for type-safe server-side validation
const ContactSchema = z.object({
  name: z.string().min(2, 'Name must have at least 2 characters'),
  email: z.string().email('Invalid email address'),
  message: z.string().min(10, 'Message must have at least 10 characters'),
});

interface ContactState {
  success: boolean;
  message: string;
  errors: { name?: string; email?: string; message?: string };
}

// Server Action, receives prevState from useActionState and FormData
export async function submitContact(
  prevState: ContactState,
  formData: FormData
): Promise<ContactState> {
  // Parse FormData into a plain object for Zod validation
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const parsed = ContactSchema.safeParse(raw);

  if (!parsed.success) {
    // Return field-level errors, client renders them next to each input
    return {
      success: false,
      message: 'Please fill in all fields correctly.',
      errors: parsed.error.flatten().fieldErrors as ContactState['errors'],
    };
  }

  try {
    await db.query(
      'INSERT INTO contacts (name, email, message) VALUES ($1, $2, $3)',
      [parsed.data.name, parsed.data.email, parsed.data.message]
    );
    revalidatePath('/contact');
    return { success: true, message: 'Message sent successfully!', errors: {} };
  } catch {
    return { success: false, message: 'Server error, please try again later.', errors: {} };
  }
}

6. Validation with Zod in React Actions

Server-side validation can be solved elegantly with Zod in React Actions. Zod's safeParse method returns either the validated data or a structured error object. With error.flatten().fieldErrors you get an object that maps each field name to an array of error messages, exactly the format the client component needs for field-level error display. The types are shared between the Server Action and the client component, so TypeScript checks the completeness of the error handling.

The pattern for two-tier validation with React Actions: client-side HTML5 validation with required, type="email" and minLength for instant feedback without a server round trip, combined with server-side Zod validation in the Server Action for security-relevant checks. The Server Action must never trust client-side validation, all data must be checked server-side, since an attacker can bypass the browser client. With React Actions and Zod, this is considerably less boilerplate than with the old onSubmit pattern, where validation logic often existed redundantly in both client and server code.

7. useOptimistic: instant feedback without waiting

useOptimistic is the third important hook in the React Actions ecosystem. It lets you immediately show an optimistic value before the Server Action has finished. The pattern: you call const [optimisticList, addOptimistic] = useOptimistic(serverList), display optimisticList in the UI, and call addOptimistic(newItem) right before the React Action is submitted. React immediately shows the list with the new item, without waiting for the server. If the Server Action succeeds, the server value is adopted. If it fails, React automatically restores the previous value.

The example use case for useOptimistic in the React Actions context: a todo list where new entries should appear immediately, without waiting for the database write. A like button that immediately shows the clicked state, even if the backend has not responded yet. A comment form that immediately displays the new comment in the list. In all these cases, useOptimistic significantly improves perceived performance, the UI feels instantly responsive, and errors are communicated transparently through automatic rollback.

8. Progressive enhancement with React Actions

Progressive enhancement is an architectural principle: an application should work with minimal JavaScript and be enhanced by JavaScript, not depend on JavaScript. React Actions support this principle natively. A form with a Server Action as its action prop sends a normal browser POST to the server when JavaScript is missing, and the server processes the request and delivers a new page with the updated state. With JavaScript, React takes over the action, runs it asynchronously, and updates only the affected part of the UI without a page reload.

In practice, this means: React Actions work without a JavaScript load time, without hydration, and without bundle size as a prerequisite. That is especially relevant for forms that should be indexed by search engines, for pages with many users on slow connections, and for forms in server-rendered pages. The previous onSubmit pattern offered no progressive enhancement, without JavaScript the form was completely non-functional. React Actions reverse this relationship: it works without JavaScript, and performs better with JavaScript.


'use client';
import { useActionState, useOptimistic } from 'react';
import { addTodo, toggleTodo } from './actions';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
  pending?: boolean; // optimistic flag
}

interface TodoState {
  todos: Todo[];
  error: string | null;
}

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [state, action, isPending] = useActionState(addTodo, {
    todos: initialTodos,
    error: null,
  });

  // useOptimistic: show immediate feedback before server responds
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    state.todos,
    // Reducer: how to merge optimistic update with current state
    (currentTodos, newTodoText: string) => [
      ...currentTodos,
      { id: crypto.randomUUID(), text: newTodoText, completed: false, pending: true },
    ]
  );

  return (
    <div>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            {todo.pending && <span> (saving...)</span>}
          </li>
        ))}
      </ul>

      {/* Form with action, works without JavaScript too */}
      <form
        action={async (formData) => {
          const text = formData.get('todo') as string;
          // Immediately show optimistic update before server responds
          addOptimisticTodo(text);
          await action(formData);
        }}
      >
        <input name="todo" required placeholder="New todo..." />
        <button type="submit" disabled={isPending}>Add</button>
      </form>

      {state.error && <p className="text-red-600">{state.error}</p>}
    </div>
  );
}

9. React 18 form pattern vs. React Actions compared

The direct comparison shows how much boilerplate React Actions eliminate and where the new patterns clearly win.

Aspect React 18 pattern React Actions (React 19) Benefit
Loading state useState(false) + manual setLoading isPending from useActionState No manual state management
Error state useState(null) + try/catch in the handler Return value of the Server Action Server-side, type-safe, no race condition
Form submission onSubmit + e.preventDefault() action prop, no preventDefault Progressive enhancement built in
API communication fetch + dedicated route + serialization Server Action, no API route Fewer files, better locality
Optimistic UI Manual setState + rollback logic useOptimistic with auto rollback Automatic rollback on error
No JavaScript Form completely broken Browser POST works More accessible, more resilient

The table makes it clear: React Actions are not a cosmetic update. They fundamentally change the form paradigm. The code savings are measurable: the example contact form from section 1 would need roughly 80 lines of code in React 18. With React Actions and useActionState, it is around 40, with more functionality, better error handling, and progressive enhancement.

Mironsoft

React development, Server Actions, and modern form architecture

Modernizing your React forms?

We migrate existing React 18 forms to React Actions, introduce Server Actions, integrate Zod validation, and implement progressive enhancement for better accessibility and resilience.

Forms audit

Analysis of existing forms for boilerplate, race conditions, and missing validation

Server Actions

Introducing Server Actions with Zod validation and type-safe states

Optimistic UI

Implementing useOptimistic for instant feedback with automatic rollback

10. Summary

React Actions are the fundamental innovation for forms in React 19. The old pattern of five useState hooks, an onSubmit handler, a fetch call, and manual rollback logic is replaced by useActionState with a Server Action. useFormStatus makes the pending state visible in child components, without prop drilling. useOptimistic delivers instant feedback with automatic rollback. Progressive enhancement is no longer extra effort, it is a built-in property of the React Actions model.

The practical consequence: anyone using React Actions in a React 19 project with Next.js 15 or Remix writes forms with roughly half the previous amount of code, with better type safety, more robust error handling, and without race conditions between competing state updates. Introducing Zod for server-side validation is the natural next step and fits seamlessly into the React Actions pattern. Anyone wanting to modernize existing React 18 forms should start by replacing the onSubmit handler with a Server Action, the rest follows structurally.

React Actions, the essentials at a glance

useActionState

Connects an action function to state. Returns currentState, wrappedAction, and isPending. Fully replaces useState for loading and error state.

Server Actions

Function marked with 'use server', runs on the server, receives FormData, returns serializable state. No API route needed.

useFormStatus + useOptimistic

useFormStatus reads pending in child components. useOptimistic shows instant values with automatic rollback on error.

Progressive enhancement

A form with a Server Action as its action prop works without JavaScript via browser POST. With JavaScript: no page reload, full interactivity.

11. FAQ: React Actions, Forms Without useState and Handlers

1React Action vs. onSubmit handler?
onSubmit needs JavaScript and e.preventDefault(). React Action also works without JS via browser POST. Semantically closer to the HTML action attribute.
2Always need a server?
No. Client actions run in the browser, no server support needed. Server Actions require Next.js 15 or later, Remix, or TanStack Start.
3How does useActionState work?
Receives a Server Action and an initial state. Returns currentState, wrappedAction, and isPending. The action's return value becomes the new state.
4Usable without Next.js?
Client actions, yes. Server Actions need framework support. Next.js 15, Remix, and TanStack Start support Server Actions.
5Server-side validation with Zod?
Convert FormData into an object, Zod safeParse, return fieldErrors on failure. Client renders the errors field by field from the state.
6Where to use useFormStatus?
In a child component of the form, not in the form component itself. Typical: a separate SubmitButton component.
7useOptimistic on error?
Automatic rollback. The optimistic value disappears and the server value (unchanged) is restored. No manual rollback needed.
8Progressive enhancement automatic?
Yes, when a Server Action is used as the action prop and the framework supports progressive enhancement. Without JS: browser POST works.
9Multiple actions in one component?
Yes. Every useActionState call is independent. Multiple forms with different actions are easy to combine.
10Testing Server Actions?
Directly testable with Jest or Vitest, normal async functions. Create FormData in the test: new FormData(). Check the return value against the state shape.