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.
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 green build tells you the shapes line up, not that the arguments survive the trip.
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.