Skip to content

Seven Compiler Errors Your React 19 Form Action Will Hit, Decoded

I fed useActionState and a form action five common mistakes and ran tsc 5.9.3 and 7.0.2: seven errors. Here's what each means and the fix that compiles.

· · 6 min read
Seven tsc errors from five React 19 form action mistakes, with identical codes and lines on TypeScript 7.0.2 and 5.9.3

Quick Take

React 19 types server actions through two overloads of useActionState and one narrow form prop, and most of the errors you'll hit come from three places: a missing previous-state parameter, a returned value where void is expected, and FormData.get() being nullable. Below is the real output of tsc 5.9.3 and 7.0.2 for five mistakes that produce seven errors, the fix for each, and two things the compiler will never tell you.

Five mistakes, seven errors. I put five common slips with server actions into one file and let the compiler read it. Two of the mistakes each produced two diagnostics.

The setup, so the numbers mean something: TypeScript 5.9.3 and 7.0.2, React, React DOM and their @types packages at 19.3.0, strict: true, on macOS arm64 with Node 22.23.2, 23 October 2026. The whole harness is in the repo, and npm test there asserts every code and line below on both compilers. The compilers agreed on every error code and every line. They didn't agree on the wording, and I'll point out where.

The rule behind most of the errors

useActionState has two overloads in @types/react. The one you want for forms takes an action shaped like this:

action: (state: Awaited<State>, payload: Payload) => State | Promise<State>

State first, payload second. The <form action> prop is a different animal:

action?: string | undefined | ((formData: FormData) => void | Promise<void>)

One parameter, no return value. Hold those two signatures in your head and most of the errors below stop being surprising.

The useActionState action signature and the form action signature side by side, with the error each one raises
Two signatures explain most of the seven errors; the codes come from examples/react-19-server-action-tsc-errors.

Mistake 1: you forgot the previous-state parameter

async function save1(formData: FormData): Promise<FormState> {
  return { ok: formData.get("name") !== null };
}

const [s1] = useActionState(save1, { ok: false });
error TS2769: No overload matches this call.
  ... Types of parameters 'formData' and 'state' are incompatible.
        Type 'FormState' is missing the following properties from type 'FormData': append, delete, get, getAll, and 3 more.

That wording is from 5.9.3. TypeScript 7.0.2 reports the same code and the same line, but it shows only the last overload's complaint, and it says "and 7 more" where 5.9.3 says "and 3 more". I didn't dig into why the count differs. If you're comparing logs from both versions, don't expect byte-identical messages.

The fix is one parameter: async function save(_prev: FormState, formData: FormData).

Mistake 2: the action returns a value and goes straight into a form

<form action={save2}>
error TS2322: Type '(formData: FormData) => Promise<FormState>' is not assignable to type 'string | ((formData: FormData) => void | Promise<void>) | undefined'.
  ...
        Type 'FormState' is not assignable to type 'void'.

The compiler is telling you something real. A bare form action has nowhere to put a return value. If you want the result, route it through useActionState and give the form the dispatcher it returns. If you don't, return Promise<void>.

Mistake 3: formData.get() is not a string

const name = formData.get("name").trim();
error TS2531: Object is possibly 'null'.
error TS2339: Property 'trim' does not exist on type 'FormDataEntryValue'.
  Property 'trim' does not exist on type 'File'.

Two errors from one line, and both are good news. The first says the field might be missing. The second says it might be a file upload. Casting with as string silences both and moves the crash to production, which is why I think the nullable return is the most useful decision in the whole DOM typing. Narrow it:

const raw = formData.get("name");
if (typeof raw !== "string" || raw.trim() === "") {
  return { ok: false, error: "Name is required" };
}

For anything bigger than one field, validate the whole object with a schema; form validation with Zod and TypeScript covers that side.

Mistake 4: the initial state disagrees with what the action returns

const [s4] = useActionState(save4, null);

Passing null as the initial state widens State to FormState | null. Then the action, which declares its previous state as plain FormState, no longer fits:

error TS2769: No overload matches this call.
  ... Type 'FormState | null' is not assignable to type 'FormState'.
        Type 'null' is not assignable to type 'FormState'.
error TS18047: 's4' is possibly 'null'.

Count it: one wrong argument, two diagnostics, and the second one only exists because the first one changed the inferred type. Give the hook a real initial value ({ ok: false }) and both disappear.

Mistake 5: the dispatcher takes the payload, not anything

const [s5, action5] = useActionState(save3, { ok: false });
action5("not form data");
error TS2345: Argument of type 'string' is not assignable to parameter of type 'FormData'.

Because the action's second parameter is FormData, the dispatcher's payload is FormData. That's Payload inferred from your function, which is the nicest part of the typing and the reason you shouldn't annotate it by hand.

Mistake 6: a hook on the wrong side of the boundary (zero errors)

useFormStatus returns pending, and React's docs say it reports on a form only when called from a component rendered inside that form. The type system can't enforce that. My broken file called it in the component that renders the form itself, and tsc raised nothing for that line. The type of pending is right and the position is wrong. The fix is a child component:

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>Save</button>;
}

In the clean version of the file, the form uses useActionState's dispatcher, a SubmitButton child and a second form with a Promise<void> action. It compiled with exit code 0 on both versions.

What tsc will never tell you

React's documentation lists what a server function may receive. Plain objects, Date, bigint and FormData are on the list. Classes, class instances and functions that aren't server functions are explicitly not.

So I wrote a "use server" file whose exported function takes a class instance, a plain callback, a Date and a bigint:

export async function checkout(cart: Cart, onDone: () => void, when: Date, big: bigint): Promise<Cart>

Exit code 0 on both compilers. The types describe your code, and the serialization rule describes the network. Treat every server action argument as untrusted input anyway, because the docs say the arguments are fully client-controlled. If you want the broader picture first, React Server Actions explained walks through the mutation flow these types sit on top of.

A use-server file with a class instance, a callback, a Date and a bigint compiles cleanly, though React rejects two
Observed with tsc 7.0.2 and 5.9.3; the supported list is from React's use server reference.

A green build tells you the shapes line up, not that the arguments survive the trip.

Share this Post on X Bluesky

The short list to pin above your desk

Previous state first, payload second. A bare form action returns nothing. Never cast formData.get(). Give useActionState a real initial value. And once the errors are gone, typing useActionState with inference covers the patterns that keep them from coming back.

Frequently Asked Questions

Why does useActionState reject my server action?
In @types/react 19.3.0, useActionState expects the action as (state, payload) => State | Promise<State>. An action written as (formData: FormData) is read as a function whose first parameter is the previous state, so tsc reports TS2769 and says FormState is missing the properties of FormData.
Can a server action return a value when used directly as a form action?
No. The form action prop is typed (formData: FormData) => void | Promise<void>, so returning Promise<FormState> fails with TS2322. To use the returned value, wrap the action in useActionState and pass the dispatcher it returns.
Does TypeScript check that server action arguments are serializable?
It doesn't. A file with 'use server' that takes a class instance and a plain function compiled with exit code 0 on tsc 5.9.3 and 7.0.2, although React's docs list classes and non-server functions as unsupported arguments.