React
useActionState
React 19
Forms
Zod
TypeScript
Server Actions

useActionState: Validation Errors, Resets and Typed State

useActionState with real validation: typed Zod field errors, inputs that survive React 19's form reset, pending UI, and Vitest tests with real output.

11 min read
Chamikara Nayanajith

useActionState is the React 19 hook that runs a form action and keeps whatever the action returns as state. You pass it an action and an initial state, and it hands back the current state, a function to put on <form action>, and an isPending flag. For validation, the action returns field errors instead of throwing, and the form renders them.

That part fits in ten lines. The parts that trip people up are typing the state properly, and the fact that React 19 wipes every input after a submit, including the failed ones. This post builds one signup form with Zod validation, fixes the reset, and tests it with Vitest. All of it was run against React 19.3.0, Zod 4.6.5 and Vitest 5.0.1, and the output quoted below is what those versions printed.

What does useActionState return?

It returns a tuple of three values: the current state, a dispatch function you pass to a form's action prop, and a boolean that is true while an action is running. The action you give it receives the previous state first and the form's FormData second, and whatever it returns becomes the next state.

tsx
const [state, formAction, isPending] = useActionState(action, initialState);

async function action(previousState: State, formData: FormData): Promise<State> {
  // validate, save, and return the next state
}

The useActionState reference lists a few details that matter later. initialState is only read on the first render. Dispatches queue and run one after another, each receiving the state the previous one returned. And if the action throws, React cancels the queued actions and hands the error to the nearest error boundary. That last one decides the whole design: validation failures are expected, so they go in the return value, never in a throw.

If you are reading older tutorials, this hook was called useFormState and lived in react-dom in React's Canary releases. It was renamed and moved to react before React 19 shipped, and it gained the third isPending value on the way.

Typing the state as a discriminated union

Most examples type the state as one loose object, something like { message?: string; errors?: Record<string, string[]> }. Every field is optional, so every render has to guess which ones are set. A union with a status tag is shorter to read and lets TypeScript tell you what exists in each case.

typescript
// actions.ts
import { z } from 'zod';

export const signupSchema = z.object({
  name: z.string().trim().min(2, 'Name must be at least 2 characters'),
  email: z.email('Enter a valid email address'),
});

type SignupInput = z.infer<typeof signupSchema>;

export type SignupState =
  | { status: 'idle' }
  | {
      status: 'error';
      fieldErrors: Partial<Record<keyof SignupInput, string[]>>;
      values: SignupInput;
    }
  | { status: 'success'; message: string };

export const initialState: SignupState = { status: 'idle' };

Deriving SignupInput from the schema means the error keys can only be real field names. A typo such as fieldErrors.emial is a compile error, not an empty paragraph you notice in review. The values field in the error case looks redundant right now. It is the fix for the reset problem further down, so leave it in. If React with TypeScript is new to you, discriminated unions are the pattern worth learning first, and this is a good place to see why.

Returning Zod field errors from the action

The action reads the FormData, runs safeParse, and returns one of the three states. Nothing in it is React specific, so you can unit test it by calling it with a FormData you build by hand.

typescript
// Stand-in for your database call. The delay lets the pending test see the disabled button.
async function saveUser(_user: z.infer<typeof signupSchema>) {
  await new Promise((resolve) => setTimeout(resolve, 200));
}

export async function signup(
  _prev: SignupState,
  formData: FormData,
): Promise<SignupState> {
  const values = {
    name: String(formData.get('name') ?? ''),
    email: String(formData.get('email') ?? ''),
  };

  const result = signupSchema.safeParse(values);
  if (!result.success) {
    return {
      status: 'error',
      fieldErrors: z.flattenError(result.error).fieldErrors,
      values,
    };
  }

  await saveUser(result.data);
  return { status: 'success', message: `Welcome, ${result.data.name}` };
}

For an input of name: 'a' and email: 'nope' with a schema that uses Zod's default messages, z.flattenError produces this:

json
{
  "formErrors": [],
  "fieldErrors": {
    "name": ["Too small: expected string to have >=2 characters"],
    "email": ["Invalid email address"]
  }
}

Those defaults are fine for logs and poor in a form, which is why the schema above passes its own messages. Note the function, too. Many examples, including the current Next.js forms guide, still call error.flatten() (and pass invalid_type_error, which Zod 4 removed). The flatten() method is marked @deprecated in the type definitions. It still works, but z.flattenError(error) is the replacement. The rest of Zod 4's changes, and why you validate on the server at all, are covered in the post on Zod validation.

Only echo back values that are safe to render again. Name and email are fine. A password field should not go into values: leave it out, and let the user type it again.

Rendering the errors in the form

With a union, the component narrows once at the top and the JSX stays flat. Each error paragraph has an id that its input points at through aria-describedby, so a screen reader announces the message with the field.

tsx
'use client';

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { signup, initialState } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>{pending ? 'Signing up...' : 'Sign up'}</button>
  );
}

export function SignupForm() {
  const [state, formAction] = useActionState(signup, initialState);
  const errors = state.status === 'error' ? state.fieldErrors : {};
  const values = state.status === 'error' ? state.values : undefined;

  return (
    <form action={formAction} noValidate>
      <label>
        Name{' '}
        <input
          name="name"
          defaultValue={values?.name}
          aria-invalid={!!errors.name}
          aria-describedby="name-error"
        />
      </label>
      <p id="name-error">{errors.name?.[0]}</p>

      <label>
        Email{' '}
        <input
          name="email"
          type="email"
          defaultValue={values?.email}
          aria-invalid={!!errors.email}
          aria-describedby="email-error"
        />
      </label>
      <p id="email-error">{errors.email?.[0]}</p>

      {state.status === 'success' && <p role="status">{state.message}</p>}
      <SubmitButton />
    </form>
  );
}

noValidate turns off the browser's own email check so the server's messages are the ones users see. Remove it if you prefer the native bubble as a first pass. The server still validates either way.

Why does the form clear after a failed submit?

Because React 19 resets every uncontrolled field when a form action finishes. The form component docs describe it as a reset after the action succeeds, but returning an error state is a success as far as React is concerned. Nothing threw. So the user sees the error, and an empty field under it.

This is easy to prove with a test. Here is the same form without defaultValue on the inputs, and a test that types an invalid name and checks it is still there after submitting:

tsx
test('keeps what the user typed after a failed submit', async () => {
  render(<NaiveForm />);
  await fillAndSubmit('A', 'not-an-email');

  expect(
    await screen.findByText('Name must be at least 2 characters'),
  ).toBeTruthy();
  expect(screen.getByLabelText<HTMLInputElement>(/name/i).value).toBe('A');
});
bash
× src/form.test.tsx > NaiveForm > keeps what the user typed after a failed submit 304ms
 expected '' to be 'A' // Object.is equality

The fix is the values field from earlier. The action returns what was submitted, and each input takes it as its defaultValue. A form reset puts every field back to its default value, and the new state has already rendered by the time the reset happens, so the default is now the text the user typed. The same test against SignupForm passes. On success, the state has no values, the defaults are empty again, and the form clears, which is what you want after a signup.

There are two other ways out, each with a cost:

  • Controlled inputs. Give each field value and onChange backed by useState. The reset no longer touches them, but you have rebuilt the per-field state that FormData was supposed to make unnecessary, and a re-render on every keystroke with it.
  • Prevent the default submit. Handle onSubmit, call preventDefault, and dispatch inside startTransition. No automatic reset happens, but you now own the submit path.

For checkboxes and selects, the same trick uses defaultChecked and defaultValue on the <select>. For file inputs there is no practical fix: the server cannot hand the file back, so the user picks it again.

useFormStatus or isPending for the submit button?

Both report the same pending submission. The difference is where they can be read. isPending belongs to the component that called useActionState. useFormStatus reads the nearest parent <form>, so it only works in a component rendered inside that form, never in the component that renders the form itself, as the useFormStatus reference spells out.

isPendinguseFormStatus
Import fromreact, via useActionStatereact-dom
Where it worksThe component that owns the actionAny component inside the form
Also gives youNothing elsedata, method, action
Works without useActionStateNoYes, with any form action

Use isPending when the button sits in the same component as the form and nowhere else. Use a useFormStatus button when it is shared across forms. Some older posts claim useFormStatus was deprecated in Next.js 15. It was not, and the Next 16 forms guide still uses it.

Calling the action without a form

The dispatch function does not need a form. It takes any payload, so you can call it from a click handler with an object or a FormData you build yourself. The catch is that it has to run inside a Transition. A form's action prop does that for you; a plain onClick does not. Call it directly and React 19.3 logs this in development:

bash
An async function with useActionState was called outside of a transition.
This is likely not what you intended (for example, isPending will not update
correctly). Either call the returned function inside startTransition, or pass
it to an `action` or `formAction` prop.

The action still runs, but isPending never turns true, and the component suspends while the action is in flight, so the nearest <Suspense> fallback replaces it until the action returns. A form that blinks out for a loading skeleton on every click is the usual symptom. Wrap the call:

tsx
import { startTransition } from 'react';

<button onClick={() => startTransition(() => dispatch(payload))}>Retry</button>

Using it with a Next.js Server Action

The action above has no browser APIs in it, so it can run on the server almost as written. The signature stays (prevState, formData), and the form component, which needs 'use client' because it calls hooks, imports it unchanged. The one change is the file layout. A 'use server' file may only export async functions, so the schema and initialState have to move out of it. Put signupSchema, the SignupState type and initialState in a schema.ts next to it, exactly as written earlier, and import from there in both files.

typescript
// app/signup/actions.ts
'use server';

import { z } from 'zod';
import { signupSchema, type SignupState } from './schema';

// saveUser stays here too: it is not exported, so the rule does not apply.

export async function signup(
  _prev: SignupState,
  formData: FormData,
): Promise<SignupState> {
  // identical body: safeParse, return errors or save and return success
}

Three things change once the action runs on the server:

  • State must be serializable. It crosses the network in both directions, so it has to be something React can serialize: plain objects, arrays, primitives, and a few built-ins such as Date, Map and Set. The union above already qualifies. A class instance, or a function that is not itself a Server Function, does not.
  • The form can work before hydration. A Server Action on a form is a real POST, so a user on a slow connection can submit before your JavaScript arrives. The optional third argument to useActionState, a permalink URL, tells the browser where to navigate in that case.
  • The action is a public endpoint. Anyone can call it with any FormData, which is exactly why the Zod check lives inside it and not in the component. The Server Components and Server Actions post covers authentication inside actions and the rest of the security model.

Testing the form with Vitest

The tests in this post run in jsdom with React Testing Library 16.3.3 and user-event 14.6.7. Form actions fire in jsdom when user-event clicks the submit button, so there is no need for a real browser to test this. Install the pieces:

bash
bun add -d vitest jsdom @vitejs/plugin-react @testing-library/react @testing-library/dom @testing-library/user-event

The config is two lines of test settings:

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: { environment: 'jsdom', globals: true },
});

globals: true is there so Testing Library can register its automatic cleanup between tests. Set it to false and the second render lands in a document that still holds the first form, so every test after the first fails with Found multiple elements with the text of: /name/i. The tests cover the three behaviors a user would notice: errors show and input survives, the button disables while pending, and the form clears after success.

tsx
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupForm } from './SignupForm';

async function fillAndSubmit(name: string, email: string) {
  const user = userEvent.setup();
  await user.type(screen.getByLabelText(/name/i), name);
  await user.type(screen.getByLabelText(/email/i), email);
  await user.click(screen.getByRole('button'));
}

describe('SignupForm', () => {
  test('shows field errors and keeps input', async () => {
    render(<SignupForm />);
    await fillAndSubmit('A', 'not-an-email');

    expect(
      await screen.findByText('Name must be at least 2 characters'),
    ).toBeTruthy();
    expect(screen.getByText('Enter a valid email address')).toBeTruthy();
    expect(screen.getByLabelText<HTMLInputElement>(/name/i).value).toBe('A');
    expect(screen.getByLabelText<HTMLInputElement>(/email/i).value).toBe(
      'not-an-email',
    );
  });

  test('disables the button while the action is pending', async () => {
    render(<SignupForm />);
    await fillAndSubmit('Ada', 'ada@example.com');

    const button = await screen.findByRole('button', { name: 'Signing up...' });
    expect((button as HTMLButtonElement).disabled).toBe(true);

    expect(await screen.findByRole('status')).toHaveProperty(
      'textContent',
      'Welcome, Ada',
    );
    expect(screen.getByLabelText<HTMLInputElement>(/name/i).value).toBe('');
  });
});
bash
 src/form.test.tsx > SignupForm > shows field errors and keeps input 90ms
 src/form.test.tsx > SignupForm > disables the button while the action is pending 299ms

The pending test works because the stand-in saveUser waits 200 ms, long enough for findByRole to catch the disabled button. If your action resolves instantly in tests, the pending state may never render, and that assertion will time out. Give the fake save a small delay instead of deleting the assertion.

When React Hook Form is still the better choice

For a form of up to a dozen fields that submits once and validates on the server, reach for useActionState with a Zod schema and the defaultValue pattern above. It adds no dependency, the validation lives in one place the client cannot skip, and with a Server Action the form works before hydration.

It is the wrong tool when the form needs to react while the user types. Field-by-field validation on blur, dependent fields, dynamic arrays of rows and multi-step wizards all need client state per field, and building that on top of useActionState means rewriting most of React Hook Form badly. In that case use React Hook Form with the same Zod schema through its resolver, and keep the server-side check in the action anyway. The schema is shared, so the two never disagree about what valid means.

Frequently asked questions

What replaced useFormState in React 19?

useActionState replaced it. During React Canary releases the hook was called useFormState and was imported from react-dom. Before React 19 became stable it was renamed to useActionState, moved to the react package, and gained a third return value, isPending, which is true while an action is running. The arguments did not change: an action function that receives the previous state and the submitted FormData, and an initial state. If you are upgrading old code, change the import to come from react, rename the call, and optionally read the third tuple value instead of adding a separate useFormStatus button. Tutorials written before mid-2024 often still show the old name, so check the import before copying an example.

What is the difference between useActionState and useTransition?

useTransition gives you a pending flag and a way to mark an update as non-urgent, but it keeps no result. useActionState is built on Transitions and adds state: whatever the action returns is stored and handed to the next call as the previous state. Reach for useActionState when the action produces something you need to render, such as validation errors or a success message. Reach for useTransition when you only need to know that some work is in progress, for example while navigating or refreshing data. Both require the work to run inside a Transition, which a form action prop handles automatically. If you call a useActionState dispatch function from a click handler, wrap it in startTransition, or React logs a warning and isPending never becomes true, and the component can suspend into the nearest Suspense fallback while the action runs.

How do I reset useActionState state?

There is no built-in reset function. The React docs suggest two approaches. The first is to teach the action a reset payload: if it receives null, or an object with a reset type, it returns the initial state, and you dispatch that payload inside startTransition. The second is to put a key on the component that calls useActionState and change the key when you want a fresh form, which remounts it with the initial state. The key approach is simpler and also clears any uncontrolled inputs, while the payload approach keeps the component mounted, which matters if it holds other state you want to keep. Note that the form fields themselves are a separate question: React resets uncontrolled inputs automatically after every form action.

Can I use useActionState without Server Actions?

Yes. The action can be any async function that runs in the browser, such as one that calls fetch against your own API, or a pure validation function with no network call at all. Server Actions are one option, not a requirement, and the hook is exported from react, not from Next.js. A client-side action is also the easiest way to test the form, because it runs in jsdom with Vitest and React Testing Library without any server. What you give up is progressive enhancement: a client-side action needs JavaScript loaded before the form can submit, while a Server Action is a real HTTP POST that works as soon as the HTML arrives. You can start with a client action and move it to the server later without changing the component.

Does a useActionState form work before JavaScript loads?

Only when the action is a Server Action rendered by a framework that supports it, such as Next.js. In that case the form posts to the server like a normal HTML form, the action runs, and the page renders with the returned state. The optional third argument to useActionState, a permalink, tells the browser which URL to navigate to when the form is submitted before hydration, which matters when the same form appears on several pages. A client-side action cannot work without JavaScript, because there is no server endpoint behind it. To keep the pre-hydration path working, avoid controls that only function with JavaScript, such as a submit button that is disabled until an effect runs.

Related Articles