| |

React 55 ⚛️ Handling Async Forms with useActionState

⏱️ Estimated read: 29 min | |

A form that submits data to a server is one of the oldest patterns in web development, and one of the most error-prone. The submission is asynchronous, the server can reject the input, the user needs feedback while the request is in flight, and the form must be reset or preserved depending on the outcome. The traditional React approach combines useState for the form values, useState for the error, useState for the loading flag, and an onSubmit handler that orchestrates them all. React 19 introduces useActionState, a hook that consolidates this pattern into a single, declarative API. It ties a form action to a state value, tracks pending status automatically, and returns the server’s response as the new state.

Key point: useActionState is not a form library. It is a hook that connects an async function to a form and surfaces the function’s return value as state, with a built-in pending flag. It replaces the manual useState + onSubmit + isSubmitting pattern with a single hook.


Why useActionState exists

The state orchestration problem. A typical async form needs at least three pieces of state: the current error or success message, the pending flag, and often the previous form values for repopulation. Managing these with separate useState calls produces verbose handlers and easy-to-miss edge cases. If the component unmounts mid-request, the error state is set on a stale component. If two submissions overlap, the pending flag becomes inaccurate. useActionState centralizes this state and ties its lifecycle to the form submission itself .

The action integration problem. React 19 introduced Actions as first-class async functions that can be passed directly to a form’s action prop. When a form uses an Action, React handles the submission, the pending state, and the form reset semantics. But Actions on their own do not provide a place for the Action to return a value that the component can display. useActionState fills this gap: it wraps the Action and returns the Action’s result as state, so the component can render errors, success messages, or updated data .

The progressive enhancement problem. A form that works without JavaScript is a form that submits to a server endpoint and reloads the page. React’s Actions system supports this: the same function that handles the client-side submission can also handle the server-side request when JavaScript is unavailable. useActionState is designed to work in both contexts, providing the state management on the client while remaining compatible with the progressive enhancement model .

The pending state problem. Every async form needs a loading indicator. Computing that indicator manually requires tracking when the request starts and when it ends, and handling the case where the component unmounts before the request completes. useActionState returns an isPending boolean that is derived from React’s internal transition state, so it is always accurate and always cleaned up .

The previous state problem. Some forms need access to the previous state, either to show a diff, to preserve user input on error, or to implement undo. The hook passes the previous state as the first argument to the Action function, so the Action can decide how to merge or compare with the new submission .

The error handling problem. Errors in async forms take two forms: validation errors returned by the server as data, and network or server errors thrown as exceptions. The hook supports both: the Action returns a value that becomes the new state (for validation errors), and thrown errors propagate to the nearest error boundary (for exceptional cases). This separation is deliberate: validation errors are expected and handled in the UI; network failures are exceptional and handled at the app level.


a. How useActionState works

useActionState accepts an Action function and an initial state, and returns a tuple of the current state, a wrapped Action to pass to the form, and a pending flag .

The basic signature

const [state, formAction, isPending] = useActionState(
  async (previousState, formData) => {
    // Process the form data.
    // Return the new state.
  },
  initialState
);

The first argument is the Action. It receives two parameters: the previous state (initially initialState, then whatever the previous invocation returned) and the FormData object from the form submission. It can be synchronous or async. Whatever it returns becomes the new state. The second argument is the initial state, used on the first render before any submission .

The hook returns three values. The first is the current state, which is the last value the Action returned. The second is a wrapped version of the Action, which you pass to the form’s action prop. The third is a boolean that is true while the Action is running .

The Action function

The Action function is where the work happens. It receives the previous state and the form data, performs validation, calls the server, and returns the new state. The return value is not the server’s response directly; it is whatever the component needs to render. Usually this is an object with an error message, a success flag, or the updated data.

async function updateNameAction(prevState, formData) {
  const name = formData.get('name');

  if (!name || name.length < 2) {
    return { error: 'Name must be at least 2 characters.' };
  }

  try {
    const updatedName = await updateNameOnServer(name);
    return { data: updatedName, error: null };
  } catch (err) {
    return { error: 'Failed to update name. Please try again.' };
  }
}

function NameForm() {
  const [state, formAction, isPending] = useActionState(
    updateNameAction,
    { data: null, error: null }
  );

  return (
    <form action={formAction}>
      <input name="name" />
      <button disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.data && <p>Saved: {state.data}</p>}
    </form>
  );
}

The Action returns a new state object on every invocation. The component re-renders with the new state. The pending flag toggles while the Action is running .

The form action prop

The second value returned by the hook is passed directly to the form’s action prop. This is what connects the form to the Action. When the form is submitted, React calls the Action with the form’s data. If JavaScript is available, the submission happens client-side. If not, the form submits to the server normally, and the server handles the request .

<form action={formAction}>
  {/* form fields */}
</form>

Passing the wrapped Action to the action prop is what tells React to manage the submission, the pending state, and the progressive enhancement behavior.

The pending flag

The third value is a boolean that reflects whether the Action is currently running. It is derived from React’s transition state, so it is always accurate. If the component unmounts while the Action is running, the flag is not updated, but the Action continues and its result is discarded.

<button type="submit" disabled={isPending}>
  {isPending ? 'Submitting...' : 'Submit'}
</button>

The flag should be used to disable the submit button, show a spinner, or hide other interactive elements that should not be used during submission .

The previous state

The Action receives the previous state as its first argument. On the first invocation, this is the initial state passed to the hook. On subsequent invocations, it is the value returned by the last invocation. This allows the Action to accumulate information, preserve form values across submissions, or implement patterns like counting attempts .

async function action(prevState, formData) {
  const newValue = formData.get('value');
  return {
    values: [...prevState.values, newValue],
    error: null,
  };
}

In this example, the state accumulates every value the user has submitted, because each invocation spreads the previous values array.


b. Common patterns

useActionState supports a range of form patterns, each suited to a different kind of interaction.

The validation error pattern

The most common pattern is returning a validation error that the UI displays. The Action returns an object with an error field. The component checks the field and renders the message when it is non-null .

async function loginAction(prevState, formData) {
  const email = formData.get('email');
  const password = formData.get('password');

  if (!email.includes('@')) {
    return { error: 'Please enter a valid email.' };
  }
  if (password.length < 8) {
    return { error: 'Password must be at least 8 characters.' };
  }

  try {
    await login(email, password);
    return { error: null, success: true };
  } catch {
    return { error: 'Invalid credentials.' };
  }
}

The error is returned as data, not thrown as an exception. This is deliberate: validation errors are expected outcomes, and the UI should handle them without triggering an error boundary.

The success state pattern

Some forms need to show a success message or transition to a different view after submission. The Action can return a success flag or a redirect target .

async function createPostAction(prevState, formData) {
  try {
    const post = await createPost({
      title: formData.get('title'),
      body: formData.get('body'),
    });
    return { post, error: null };
  } catch (err) {
    return { post: null, error: err.message };
  }
}

function CreatePost() {
  const [state, formAction, isPending] = useActionState(
    createPostAction,
    { post: null, error: null }
  );

  if (state.post) {
    return <PostView post={state.post} />;
  }

  return (
    <form action={formAction}>
      <input name="title" />
      <textarea name="body" />
      <button disabled={isPending}>Publish</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

The form is replaced by the post view once the submission succeeds. The state drives the rendering.

The persistent form values pattern

When a submission fails, the user should not lose the data they entered. The Action can return the submitted values along with the error, and the form fields can use those values as default values .

async function action(prevState, formData) {
  const values = {
    email: formData.get('email'),
    name: formData.get('name'),
  };

  if (!values.email.includes('@')) {
    return { error: 'Invalid email.', values };
  }

  try {
    await save(values);
    return { error: null, values: {} };
  } catch {
    return { error: 'Save failed.', values };
  }
}

function Form() {
  const [state, formAction, isPending] = useActionState(action, {
    error: null,
    values: {},
  });

  return (
    <form action={formAction}>
      <input name="email" defaultValue={state.values.email ?? ''} />
      <input name="name" defaultValue={state.values.name ?? ''} />
      <button disabled={isPending}>Submit</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

The defaultValue prop is populated from the state, so the user’s input survives the failed submission.

The optimistic integration pattern

useActionState is often combined with useOptimistic to show immediate feedback while the server processes the request. The Action dispatches the optimistic update, awaits the server, and returns the result. useActionState manages the error state and pending flag, while useOptimistic manages the immediate visual feedback.

function CommentForm({ comments, postComment }) {
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (current, newComment) => [...current, { ...newComment, pending: true }]
  );

  const [state, formAction, isPending] = useActionState(
    async (prevState, formData) => {
      const text = formData.get('text');
      addOptimistic({ id: crypto.randomUUID(), text });
      try {
        await postComment(text);
        return { error: null };
      } catch {
        return { error: 'Failed to post comment.' };
      }
    },
    { error: null }
  );

  return (
    <div>
      <form action={formAction}>
        <input name="text" />
        <button disabled={isPending}>Post</button>
      </form>
      {state.error && <p role="alert">{state.error}</p>}
      <ul>
        {optimisticComments.map(c => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            {c.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

The two hooks are complementary: useOptimistic handles the visual immediacy, useActionState handles the error and pending state.

The multi-step form pattern

useActionState can drive multi-step forms by storing the current step in the state. Each submission advances or retreats the step based on the Action’s return value.

async function wizardAction(prevState, formData) {
  const step = prevState.step;
  const values = { ...prevState.values, ...Object.fromEntries(formData) };

  if (formData.get('direction') === 'back') {
    return { ...prevState, step: Math.max(0, step - 1), values };
  }

  if (step === 0) {
    return { ...prevState, step: 1, values, error: null };
  }

  if (step === 1) {
    try {
      await submitWizard(values);
      return { ...prevState, step: 2, values, error: null };
    } catch {
      return { ...prevState, error: 'Submission failed.', values };
    }
  }

  return prevState;
}

The state holds the step number, the accumulated values, and any error. The form renders different fields based on the current step.

The server action pattern

In applications with server components, the Action can be a server function marked with 'use server'. The client-side useActionState wraps this server function, and the submission is sent to the server as a network request. The server function runs on the backend, returns a value, and that value becomes the new state on the client .

// actions.js
'use server';

export async function createTodoAction(prevState, formData) {
  const text = formData.get('text');
  if (!text) {
    return { error: 'Todo text is required.' };
  }
  await db.todos.create({ text });
  return { error: null, success: true };
}

// TodoForm.jsx (client component)
'use client';
import { useActionState } from 'react';
import { createTodoAction } from './actions';

function TodoForm() {
  const [state, formAction, isPending] = useActionState(
    createTodoAction,
    { error: null, success: false }
  );

  return (
    <form action={formAction}>
      <input name="text" />
      <button disabled={isPending}>Add</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

The same hook works with client-side Actions and server Actions. The difference is where the code runs, not how the hook is used.


c. Caveats and limitations

useActionState is a focused tool. It handles a specific pattern, and it is important to understand what it does and does not do.

The formData requirement

The Action receives a FormData object, not a plain object. If the form uses controlled inputs with useState, the form’s action prop will not receive those values unless the inputs have name attributes. The hook is designed for uncontrolled forms, where the browser manages the input values and submits them as FormData .

// Works: uncontrolled inputs with name attributes.
<form action={formAction}>
  <input name="email" />
  <input name="password" type="password" />
</form>

// Does not work with useActionState without name attributes.
// Controlled inputs are not required, but name attributes are.

The action prop replaces onSubmit

When a form uses the action prop with formAction, the onSubmit handler is bypassed. The form submission goes through React’s Action system, not the traditional onSubmit callback. Mixing the two is not supported and can produce unexpected behavior .

// Do not mix:
<form action={formAction} onSubmit={handleSubmit}>
  {/* Conflicting submission handlers. */}
</form>

The pending flag is form-scoped

The isPending flag reflects the state of the Action associated with the specific useActionState call. It does not reflect the state of other Actions or other forms on the page. If a component has multiple forms, each has its own pending flag.

The state is per-hook-instance

Each call to useActionState manages its own state. If the same Action function is used in two different components, each call has its own state, pending flag, and previous state. The Action function itself is stateless; the state lives in the hook.

The error boundary boundary

If the Action throws an error, the error propagates to the nearest error boundary, not to the state. This is the correct behavior for exceptional errors, but it means that the component cannot catch and display a thrown error as state. Errors that should be displayed as messages must be returned, not thrown .

// Returns an error: displayed in the UI.
try {
  await save();
  return { error: null };
} catch (err) {
  return { error: err.message };  // Becomes state.
}

// Throws an error: propagates to error boundary.
throw new Error('Something went wrong');  // Not caught by useActionState.

The lack of controlled input integration

useActionState does not provide a mechanism for controlled inputs. If the form needs to react to input changes, such as live validation or character counts, the component must use useState for the input values in addition to useActionState for the submission state. The two hooks coexist, but they manage different concerns.

The serialization constraint for server actions

When the Action is a server function, its arguments and return value must be serializable. The FormData object is serializable, and the return value must be a plain object, array, string, number, or boolean. Functions, class instances, and complex objects cannot cross the network boundary. The server action’s return value must be designed with this constraint in mind .

// Valid return from a server action:
return { error: null, data: { id: 1, name: 'Alice' } };

// Invalid return from a server action:
return { error: null, callback: () => {} };

Complete Example Session

// ============================================
// PART 1: THE PROBLEM — MANUAL ASYNC FORM STATE
// ============================================

// A login form using the traditional pattern.

function ManualLoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setError(null);
    setIsPending(true);
    try {
      await login(email, password);
    } catch (err) {
      setError(err.message);
    } finally {
      setIsPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={email}
        onChange={e => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
      />
      <button disabled={isPending}>
        {isPending ? 'Logging in...' : 'Log in'}
      </button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

// Four pieces of state. Manual pending tracking.
// Manual error handling. onSubmit handler.
// Works, but verbose and easy to get wrong.


// ============================================
// PART 2: THE useActionState APPROACH
// ============================================

import { useActionState } from 'react';

async function loginAction(prevState, formData) {
  const email = formData.get('email');
  const password = formData.get('password');

  if (!email || !password) {
    return { error: 'Both fields are required.' };
  }

  try {
    await login(email, password);
    return { error: null, success: true };
  } catch (err) {
    return { error: err.message };
  }
}

function LoginForm() {
  const [state, formAction, isPending] = useActionState(
    loginAction,
    { error: null, success: false }
  );

  return (
    <form action={formAction}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button disabled={isPending}>
        {isPending ? 'Logging in...' : 'Log in'}
      </button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

// One hook. Uncontrolled inputs. Automatic pending.
// The Action returns state; the component renders it.


// ============================================
// PART 3: PRESERVING INPUT ON ERROR
// ============================================

async function signupAction(prevState, formData) {
  const values = {
    email: formData.get('email') ?? '',
    name: formData.get('name') ?? '',
  };

  if (!values.email.includes('@')) {
    return { error: 'Invalid email.', values };
  }

  if (values.name.length < 2) {
    return { error: 'Name too short.', values };
  }

  try {
    await signup(values);
    return { error: null, values: {} };
  } catch {
    return { error: 'Signup failed.', values };
  }
}

function SignupForm() {
  const [state, formAction, isPending] = useActionState(signupAction, {
    error: null,
    values: {},
  });

  return (
    <form action={formAction}>
      <input name="email" defaultValue={state.values.email} />
      <input name="name" defaultValue={state.values.name} />
      <button disabled={isPending}>Sign up</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

// The state carries the submitted values.
// The inputs use defaultValue from state.
// On error, the user's input is preserved.


// ============================================
// PART 4: THE SUCCESS TRANSITION
// ============================================

async function createPostAction(prevState, formData) {
  const title = formData.get('title');
  const body = formData.get('body');

  if (!title.trim()) {
    return { error: 'Title is required.', post: null };
  }

  try {
    const post = await createPost({ title, body });
    return { error: null, post };
  } catch {
    return { error: 'Failed to create post.', post: null };
  }
}

function CreatePost() {
  const [state, formAction, isPending] = useActionState(
    createPostAction,
    { error: null, post: null }
  );

  if (state.post) {
    return (
      <article>
        <h1>{state.post.title}</h1>
        <p>{state.post.body}</p>
      </article>
    );
  }

  return (
    <form action={formAction}>
      <input name="title" placeholder="Title" />
      <textarea name="body" placeholder="Body" />
      <button disabled={isPending}>Publish</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

// On success, the state contains the new post.
// The component renders the post instead of the form.
// The transition happens through state, not navigation.


// ============================================
// PART 5: COMBINING WITH useOptimistic
// ============================================

import { useOptimistic } from 'react';

function CommentSection({ comments, postComment }) {
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (current, newComment) => [
      ...current,
      { ...newComment, pending: true }
    ]
  );

  const [state, formAction, isPending] = useActionState(
    async (prevState, formData) => {
      const text = formData.get('text');
      const tempId = crypto.randomUUID();

      addOptimistic({ id: tempId, text });

      try {
        await postComment(text);
        return { error: null };
      } catch {
        return { error: 'Failed to post comment.' };
      }
    },
    { error: null }
  );

  return (
    <div>
      <form action={formAction}>
        <input name="text" placeholder="Add a comment..." />
        <button disabled={isPending}>
          {isPending ? 'Posting...' : 'Post'}
        </button>
      </form>
      {state.error && <p role="alert">{state.error}</p>}
      <ul>
        {optimisticComments.map(c => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            {c.text}
            {c.pending && ' (posting...)'}
          </li>
        ))}
      </ul>
    </div>
  );
}

// useOptimistic handles the immediate visual feedback.
// useActionState handles the error and pending state.
// The two hooks work together seamlessly.


// ============================================
// PART 6: SERVER ACTIONS
// ============================================

// actions.js
'use server';

export async function updateProfileAction(prevState, formData) {
  const name = formData.get('name');
  const bio = formData.get('bio');

  if (!name || name.length < 2) {
    return { error: 'Name must be at least 2 characters.' };
  }

  try {
    await db.users.update({ name, bio });
    return { error: null, success: true };
  } catch {
    return { error: 'Failed to update profile.' };
  }
}

// ProfileForm.jsx
'use client';
import { useActionState } from 'react';
import { updateProfileAction } from './actions';

function ProfileForm() {
  const [state, formAction, isPending] = useActionState(
    updateProfileAction,
    { error: null, success: false }
  );

  return (
    <form action={formAction}>
      <input name="name" />
      <textarea name="bio" />
      <button disabled={isPending}>Save</button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.success && <p>Profile updated.</p>}
    </form>
  );
}

// The same hook works with server Actions.
// The Action runs on the server.
// The return value is serialized and sent back.


// ============================================
// PART 7: MULTI-STEP FORM
// ============================================

async function wizardAction(prevState, formData) {
  const { step, values } = prevState;
  const newValues = { ...values, ...Object.fromEntries(formData) };

  if (formData.get('direction') === 'back') {
    return { step: Math.max(0, step - 1), values: newValues, error: null };
  }

  if (step === 0) {
    if (!newValues.email?.includes('@')) {
      return { step: 0, values: newValues, error: 'Invalid email.' };
    }
    return { step: 1, values: newValues, error: null };
  }

  if (step === 1) {
    try {
      await submitRegistration(newValues);
      return { step: 2, values: newValues, error: null };
    } catch {
      return { step: 1, values: newValues, error: 'Submission failed.' };
    }
  }

  return { step: 2, values: newValues, error: null };
}

function Wizard() {
  const [state, formAction, isPending] = useActionState(wizardAction, {
    step: 0,
    values: {},
    error: null,
  });

  return (
    <form action={formAction}>
      {state.step === 0 && (
        <>
          <h2>Step 1: Email</h2>
          <input name="email" defaultValue={state.values.email} />
        </>
      )}
      {state.step === 1 && (
        <>
          <h2>Step 2: Name</h2>
          <input name="name" defaultValue={state.values.name} />
          <button name="direction" value="back">Back</button>
        </>
      )}
      {state.step === 2 && <h2>Registration complete!</h2>}
      {state.error && <p role="alert">{state.error}</p>}
      {state.step < 2 && (
        <button disabled={isPending}>
          {isPending ? 'Processing...' : 'Next'}
        </button>
      )}
    </form>
  );
}

// The state holds the current step and accumulated values.
// The form renders different fields per step.
// All state management is in one hook.


// ============================================
// PART 8: ERROR HANDLING — RETURN vs THROW
// ============================================

async function action(prevState, formData) {
  const email = formData.get('email');

  // Validation errors: return them.
  if (!email) {
    return { error: 'Email is required.' };  // Displayed in UI.
  }

  try {
    await save(email);
    return { error: null };
  } catch (err) {
    // Network errors: return them too, for user feedback.
    return { error: 'Network error. Please try again.' };
  }
}

// Unhandled errors: throw them.
async function strictAction(prevState, formData) {
  const result = await process(formData);
  if (!result.ok) {
    throw new Error('Unexpected failure');  // Goes to error boundary.
  }
  return { error: null };
}

// Rule: expected failures (validation, server rejection) are returned.
// Truly unexpected failures are thrown and caught by an error boundary.


// ============================================
// PART 9: COMMON MISTAKES
// ============================================

// Mistake 1: Forgetting name attributes.
<form action={formAction}>
  <input value={email} onChange={e => setEmail(e.target.value)} />
  {/* No name attribute → formData.get('email') is null. */}
</form>

// Mistake 2: Mixing action and onSubmit.
<form action={formAction} onSubmit={handleSubmit}>
  {/* Conflicting handlers. */}
</form>

// Mistake 3: Trying to catch thrown errors in the component.
const [state, formAction] = useActionState(action, {});
try {
  await action();  // Not how it works. Errors go to boundaries.
} catch {}

// Mistake 4: Expecting controlled input behavior.
// useActionState is for uncontrolled forms.
// Controlled inputs require useState for values.

// Mistake 5: Returning non-serializable values from server actions.
return { data, callback: () => {} };  // Functions cannot be serialized.

// Mistake 6: Forgetting the previous state parameter.
async function action(formData) {  // Missing prevState.
  // The signature must be (prevState, formData).
}


// ============================================
// PART 10: WHEN TO USE useActionState
// ============================================

// USE useActionState FOR:
// - Forms that submit data to a server (client or server Actions)
// - Async operations that need pending and error state
// - Multi-step wizards with accumulated state
// - Any form where the submission result drives UI changes
// - Progressive enhancement scenarios

// DO NOT USE useActionState FOR:
// - Simple controlled inputs without submission
// - Forms that do not need async error or pending state
// - Replacing general state management
// - Client-only operations that do not involve form submission
// - Complex forms with field-level validation that runs on every keystroke

The ten parts progressed from the manual async form pattern through the basic useActionState approach, input preservation, success transitions, optimistic integration, server actions, multi-step forms, error handling strategy, common mistakes, and a decision guide. The same login, signup, and comment scenarios ran through every part, showing how the hook consolidates form submission state into a single API.


Quick Reference

useActionState API

ParameterTypeDescription
action(prevState, formData) => newStateThe function that processes the form
initialStateAnyThe state before the first submission
Returns[0]stateCurrent state (last return from action)
Returns[1]formActionWrapped action for the form’s action prop
Returns[2]isPendingBoolean, true while action is running

State Shape Patterns

PatternState ShapeUse Case
Error only{ error: string | null }Simple validation
Error + success{ error, success }Forms with confirmation
Error + values{ error, values }Preserving input on error
Error + data{ error, data }Forms returning results
Step + values{ step, values, error }Multi-step wizards
Accumulated{ items: [...] }Lists that grow

Error Handling

Error TypeHow to HandleExample
Validation errorReturn in statereturn { error: 'Invalid' }
Server rejectionReturn in statereturn { error: 'Taken' }
Network failureReturn in statereturn { error: 'Try again' }
Unexpected errorThrowthrow new Error(...)

useActionState vs Alternatives

NeedTool
Async form submission with stateuseActionState
Immediate optimistic feedbackuseOptimistic
Form field values and validationuseState
Server function integrationuseActionState + 'use server'
Navigation after submituseActionState + redirect

Best Practices

✅ Do This:

// Use name attributes on all form inputs.
<input name="email" type="email" />  // ✅

// Return validation errors as state.
if (!email) return { error: 'Email required' };  // ✅

// Preserve input values on error.
return { error: 'Invalid', values: { email, name } };  // ✅

// Use isPending to disable the submit button.
<button disabled={isPending}>Submit</button>  // ✅

// Combine with useOptimistic for immediate feedback.
addOptimistic(item); await save(item);  // ✅

// Use server actions for server-side processing.
'use server';
export async function action(prev, formData) { ... }  // ✅

// Keep the state shape consistent.
{ error: null, data: null, success: false }  // ✅

// Catch network errors and return user-friendly messages.
try { await save(); } catch { return { error: 'Failed' }; }  // ✅

❌ Don’t Do This:

// Don't forget name attributes.
<input value={email} onChange={...} />  // ❌ No formData entry

// Don't mix action and onSubmit.
<form action={formAction} onSubmit={handleSubmit}>  // ❌

// Don't throw validation errors.
throw new Error('Invalid email');  // ❌ Goes to error boundary

// Don't expect controlled input behavior.
// useActionState is for uncontrolled forms with name attributes.  // ❌

// Don't return non-serializable values from server actions.
return { callback: () => {} };  // ❌ Cannot serialize functions

// Don't use useActionState for simple state.
const [state, action] = useActionState(...);  // ❌ Use useState for non-form

// Don't forget the prevState parameter in the action signature.
async function action(formData) { ... }  // ❌ Must be (prevState, formData)

Common Pitfalls

PitfallWhy It HappensFix
formData.get() returns nullInput missing name attributeAdd name to every input
Form submits and reloads pageUsing onSubmit instead of actionPass formAction to action prop
Input values lost on errorState not preserving valuesReturn values in state and use defaultValue
Error not displayedThrowing instead of returningReturn { error } as state
Pending flag never trueWrong hook or missing transitionUse the third return value
Server action fails silentlyNon-serializable return valueReturn plain objects only
Previous state undefinedAction signature wrongUse (prevState, formData)
Double submissionButton not disabledUse isPending to disable

Real-World Examples

1. Basic Login Form

const [state, formAction, isPending] = useActionState(loginAction, { error: null });
<form action={formAction}>
  <input name="email" />
  <input name="password" type="password" />
  <button disabled={isPending}>Log in</button>
  {state.error && <p role="alert">{state.error}</p>}
</form>

2. Signup with Input Preservation

return { error: 'Email taken', values: { email, name } };
<input name="email" defaultValue={state.values.email} />

3. Post Creation with Success View

if (state.post) return <PostView post={state.post} />;

4. Comment with Optimistic Update

const [state, formAction] = useActionState(async (prev, formData) => {
  addOptimistic(formData.get('text'));
  try { await post(formData.get('text')); return { error: null }; }
  catch { return { error: 'Failed' }; }
}, { error: null });

5. Server Action for Profile Update

'use server';
export async function updateProfile(prev, formData) {
  await db.update(formData.get('name'));
  return { error: null };
}

6. Multi-Step Wizard

const [state, formAction] = useActionState(wizardAction, { step: 0, values: {} });

7. Search with Action

const [results, formAction, isPending] = useActionState(
  async (prev, formData) => search(formData.get('q')),
  []
);

8. Newsletter Signup

const [state, formAction, isPending] = useActionState(
  async (prev, formData) => {
    try { await subscribe(formData.get('email')); return { success: true }; }
    catch { return { error: 'Subscription failed' }; }
  },
  { success: false, error: null }
);

9. File Upload Form

const [state, formAction, isPending] = useActionState(
  async (prev, formData) => {
    const file = formData.get('file');
    await upload(file);
    return { error: null };
  },
  { error: null }
);

10. Error with Retry

const [state, formAction] = useActionState(
  async (prev, formData) => {
    try { await submit(formData); return { error: null }; }
    catch { return { error: 'Failed. Try again.' }; }
  },
  { error: null }
);

Visual

The useActionState Lifecycle

┌────────────────────────────────────────────────────────────┐
│  useActionState LIFECYCLE                                  │
│                                                            │
│  INITIAL RENDER                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  state = initialState                             │     │
│  │  isPending = false                                │     │
│  │  Form renders with initial values.                │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        │  User submits form               │
│                        v                                   │
│  PENDING                                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  isPending = true                                 │     │
│  │  Button disabled. Spinner shown.                  │     │
│  │  Action(prevState, formData) runs.                │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│              ┌─────────┴─────────┐                         │
│              │                   │                         │
│              v                   v                         │
│  SUCCESS                    ERROR (returned)               │
│  ┌──────────────────┐      ┌──────────────────────┐       │
│  │ state = result   │      │ state = { error }    │       │
│  │ isPending = false│      │ isPending = false    │       │
│  │ UI updates.      │      │ Error displayed.     │       │
│  └──────────────────┘      └──────────────────────┘       │
│                                    │                       │
│                                    │  Unexpected error     │
│                                    v                       │
│                            ┌──────────────────────┐       │
│                            │ Error boundary       │       │
│                            │ catches thrown error │       │
│                            └──────────────────────┘       │
│                                                            │
└────────────────────────────────────────────────────────────┘

Action Signature and Return

┌────────────────────────────────────────────────────────────┐
│  THE ACTION FUNCTION SIGNATURE                             │
│                                                            │
│  async function action(prevState, formData) {              │
│    // prevState: initialState on first call,               │
│    //            previous return value on later calls      │
│    // formData:  the form's data, accessed via .get()      │
│                                                            │
│    const email = formData.get('email');                    │
│    const password = formData.get('password');              │
│                                                            │
│    // Validate.                                            │
│    if (!email) return { error: 'Email required' };         │
│                                                            │
│    // Submit to server.                                    │
│    try {                                                   │
│      await submit(email, password);                        │
│      return { error: null, success: true };                │
│    } catch (err) {                                         │
│      return { error: 'Submission failed' };                │
│    }                                                       │
│  }                                                         │
│                                                            │
│  The return value becomes the new state.                   │
│  Thrown errors go to the nearest error boundary.           │
│                                                            │
└────────────────────────────────────────────────────────────┘

Preserving Input on Error

┌────────────────────────────────────────────────────────────┐
│  INPUT PRESERVATION PATTERN                                │
│                                                            │
│  User types: email = "alice", name = "A"                   │
│  Submits.                                                   │
│                                                            │
│  Action validates: name too short.                         │
│  Returns: { error: 'Name too short', values: { email, name } }│
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  state.values.email = "alice"                     │     │
│  │  state.values.name = "A"                          │     │
│  │  state.error = "Name too short"                   │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        v                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <input name="email"                              │     │
│  │         defaultValue={state.values.email} />      │     │
│  │  <input name="name"                               │     │
│  │         defaultValue={state.values.name} />       │     │
│  │  <p role="alert">{state.error}</p>                │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The user sees their input preserved and the error.        │
│  They fix the name and resubmit without re-typing.         │
│                                                            │
└────────────────────────────────────────────────────────────┘

Combined useActionState and useOptimistic

┌────────────────────────────────────────────────────────────┐
│  useActionState + useOptimistic                            │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  useOptimistic                                     │     │
│  │    Manages: immediate visual feedback              │     │
│  │    Shows: new item with pending flag               │     │
│  │    Reverts: automatically on error                 │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        │  Both work together              │
│                        v                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  useActionState                                    │     │
│  │    Manages: error state, pending flag,             │     │
│  │             previous state for preservation        │     │
│  │    Shows: error messages, success views            │     │
│  │    Returns: server response as state               │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  In the Action:                                            │
│    1. addOptimistic(newItem)  ← immediate feedback         │
│    2. await serverCall()      ← network request            │
│    3. return { error: null }  ← new state                  │
│                                                            │
│  On error: optimistic reverts, error state displays.       │
│                                                            │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
useActionStateHook for async form submission state management
Parametersaction function, initialState
Returns[state, formAction, isPending]
Action signature(prevState, formData) => newState
Pending flagAutomatic, derived from transition state
Error handlingReturn validation errors; throw unexpected errors
Input preservationReturn values in state, use as defaultValue
Server actionsCompatible with 'use server' functions
Optimistic integrationPairs with useOptimistic for immediate feedback
Progressive enhancementWorks with or without JavaScript

Key takeaways:

  • useActionState replaces the manual async form pattern. One hook manages the pending flag, the error state, the success state, and the previous values. The component renders the state, and the Action returns it.
  • The Action receives the previous state and the form data. The signature is (prevState, formData) => newState. The previous state starts as the initial state and becomes the last return value on subsequent invocations.
  • Validation errors are returned, not thrown. Returning { error } puts the error in state, where the component can display it. Throwing sends the error to the nearest error boundary, which is appropriate only for unexpected failures.
  • Input preservation requires returning values in state. To keep the user’s input after a failed submission, the Action returns the submitted values, and the inputs use defaultValue from the state.
  • The pending flag is automatic. isPending is true while the Action is running, and it is derived from React’s transition state. Use it to disable the submit button and show a loading indicator.
  • Server actions work with the same hook. Mark a function with 'use server', import it into a client component, and pass it to useActionState. The return value must be serializable.
  • useOptimistic complements useActionState. useOptimistic handles immediate visual feedback; useActionState handles error and pending state. Together they produce a form that feels instant but recovers gracefully from failure.
  • Name attributes are required. The Action reads values from FormData, which is populated by inputs with name attributes. Controlled inputs without name attributes do not appear in the form data.

Remember: useActionState is the React 19 solution for async form submission. It consolidates the state, the pending flag, and the Action binding into a single hook, replacing the traditional useState + onSubmit + isPending pattern. The Action function receives the previous state and the form data, performs validation and submission, and returns the new state. Validation errors are returned; unexpected errors are thrown. Input preservation is achieved by returning the submitted values and using them as defaultValue. The hook works with client-side actions, server actions, and progressive enhancement. Combined with useOptimistic, it produces forms that feel immediate while remaining correct in the face of failure. The result is less code, fewer bugs, and a consistent pattern for every async form in the application.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!