Skip to content

Typing useActionState in React 19: Patterns That Survive the Compiler

I broke React 19 form actions ten ways and ran tsc 7.0.2 and 5.9.3 on each, then ran the fixes in jsdom. These typing patterns held up.

· · 12 min read
useActionState typing patterns checked by TypeScript 7.0.2 and 5.9.3: ten broken files, ten expected error codes

Quick Take

React 19's types infer a form action's state and payload for you, but only when the action hands them something precise. I wrote ten files that break that inference and compiled them with tsc 7.0.2 and 5.9.3 against @types/react 19.3.0. Both compilers gave the same error code on the same line every time. The fixes come down to four habits: a status union, a schema at the FormData boundary, an annotated action return, and bind for extra arguments.

Most writing about typing React actions is a catalogue of errors. I wanted the other list: what you write so the errors never show up.

So I built one small action layer and attacked it. A status union for the form state, a factory that turns a Zod schema into an action, a bind for a leading id, a reducer-style action with a number payload. Then ten files that each break one thing. The setup, so the numbers mean something: typescript 7.0.2 and 5.9.3, @types/react and @types/react-dom 19.3.0 (the types package was published on 9 September 2026), zod 4.6.7, strict: true. The emitted code ran in jsdom with react-dom 19.3.1 on Node 22.23.3, 24.21.0 and 26.10.0. The whole harness is in the repo, and npm test there backs every result below.

If you'd rather start from the compiler's messages, seven form action errors decoded goes error by error.

Two honest limits first. The actions are plain functions, so there's no 'use server' boundary, no Next.js and no network, which means nothing here says whether an argument survives serialization. And I only tested Zod. If you validate with something else, the factory changes shape but the pattern doesn't.

What Do the React 19.3 Types Actually Promise?

Three signatures do most of the work, and I read them straight out of node_modules instead of trusting memory.

// @types/react 19.3.0
function useActionState<State, Payload>(
  action: (state: Awaited<State>, payload: Payload) => State | Promise<State>,
  initialState: Awaited<State>,
  permalink?: string,
): [state: Awaited<State>, dispatch: (payload: Payload) => void, isPending: boolean];

There's a first overload too, useActionState<State>, where the action takes only the state and the dispatcher takes nothing. Keep that in mind; it comes back in the generics section. Then the form side: the action prop on <form> is typed string | undefined | ((formData: FormData) => void | Promise<void>), one parameter and no return value. And useFormStatus in @types/react-dom 19.3.0 returns a union on pending: when it's true, data is a FormData; when it's false, data is null.

Per React's reference page, the action receives the previous state first and the submitted payload second, and initialState is ignored once the first dispatch happens. It also says the action's return type has to match the initial state's type, and that you may need to annotate the state type if TypeScript infers a mismatch. That sentence is the whole article in miniature.

Why Does the Action State Need Four Members?

Because a form has more ways to end than "worked" and "didn't". The state I settled on has four: idle, invalid (the user's input failed validation), failed (the handler threw) and done.

export type ActionState<Input, Data> =
  | { status: 'idle' }
  | {
      status: 'invalid';
      fieldErrors: { [K in keyof Input]?: string[] };
      values: { [K in keyof Input]?: string };
    }
  | { status: 'failed'; message: string }
  | { status: 'done'; data: Data };

The basic case for a union over { error?: string; success?: boolean } is in the server components TypeScript guide, and the discriminated unions guide covers the language feature itself. I'll only add what those don't. Reading state.data before checking status fails with TS2339, and the compiler names the { status: "idle" } member as the one that has no data. Both compilers said exactly that.

The member worth a second look is values. I added it after watching jsdom do something the types can't show. When the action finished, React reset an uncontrolled form: with no defaultValue, the email input I'd typed nope into was empty again. With defaultValue={values.email} fed from the invalid state, it still read nope. If your invalid state doesn't carry the user's input back, your users retype everything on every validation error.

How Do You Get From FormData to a Typed Value?

You don't, not by assertion. Object.fromEntries(formData) is { [k: string]: FormDataEntryValue }, a pile of strings and Files. Assigning it to { email: string } fails with TS2741, "Property 'email' is missing", which is the compiler being right about a form that can submit any field names it likes.

The only honest route from that pile to your type is a parse, and I'd rather write it once. Here's the factory:

export function createAction<S extends z.ZodObject, Data>(
  schema: S,
  handler: (input: z.output<S>) => Promise<Data>,
) {
  type State = ActionState<z.output<S>, Data>;

  return async function action(_prev: State, formData: FormData): Promise<State> {
    const raw = Object.fromEntries(formData);
    const parsed = schema.safeParse(raw);
    if (!parsed.success) {
      const { fieldErrors } = z.flattenError(parsed.error);
      return { status: 'invalid', fieldErrors, values: echo(raw) };
    }
    try {
      return { status: 'done', data: await handler(parsed.data) };
    } catch (error) {
      return { status: 'failed', message: error instanceof Error ? error.message : 'Unknown error' };
    }
  };
}

My first version keyed the state by z.input<S>, and it didn't compile. Both compilers reported TS2322, the tail of the message being Type 'keyof input<S>' is not assignable to type 'keyof output<S>'. z.flattenError reports its keys from the output type, so I switched the state's key set to z.output<S>. Only the keys matter there, and they're the same object keys either way.

The result is that a call site writes nothing. signup = createAction(SignupSchema, async (input) => ...) gives you input.age as a number, and Awaited<ReturnType<typeof signup>> is the full union. In jsdom the handler saw typeof age === 'number' for a form field that, as a FormData entry, is always the string "21". The schema did that with z.coerce.number(), and the type is true only because it did.

It also dropped an unknown field. I submitted a third input called extra and the handler's keys were age and email. Worth knowing because the Next.js forms guide warns that Object.fromEntries(formData) includes extra properties prefixed with $ACTION_. I didn't test those keys, so what I can say is only this: Zod's default object parsing strips what the schema doesn't name, in my run.

One as survives in the file, inside echo(), on a record I built myself from strings. It's the single assertion I'd defend, because it sits on a value the code owns.

When Should You Write the Generics Yourself?

Less often than you'd think. I compiled four calls with no type arguments: an action whose previous-state parameter is wider than what it returns with null as the initial state, an inline async arrow with [] as initial state, an inline arrow typed to accept null, and an annotated union. All four compiled on both compilers. Inference is good here.

It breaks in three places, and each one has a code.

An action with no return annotation. I wrote archive(_prev: ArchiveState, formData) and let it return { status: 'done' as const, data: { archived: 1 } }. TypeScript sees only that object, so State becomes the done member alone, and idle as the initial state fails with TS2769: Property 'data' is missing in type '{ readonly status: 'idle'; }' but required in the done member. That's React's "annotate your state type" advice made concrete. There are two fixes. Annotate the action's return as Promise<ArchiveState>, or write useActionState<ArchiveState, FormData>(archive, idle) at the call site.

I'd annotate the action. Do it once at the definition and every call site works with no ceremony. Generics at the call site are a tax you pay in every component that uses the action.

One type argument. useActionState<SignupState>(signup, idle) selects the first overload, the one with no payload, and tsc says Target signature provides too few arguments. Expected 2 or more, but got 1. That's TS2345. Pass both type arguments or neither.

A hoisted initial state. This one catches careful people. Move { status: 'idle' } into a const initial at the top of the file and status widens to string. useActionState(signup, initial) then fails with TS2769, and TypeScript 7.0.2 ends the message with Property 'data' is missing in type '{ status: string; }', which names the done member instead of the real cause, a widened string. The fix is export const idle = { status: 'idle' } as const, which is what the example exports and every form imports.

How Do You Pass an Id Without a Hidden Input?

With bind. Put the id first, and the remaining signature is the (previousState, formData) pair the hook wants:

export async function renameTodo(id: string, _prev: RenameState, formData: FormData): Promise<RenameState> {
  // ...
}

const [state, formAction] = useActionState(renameTodo.bind(null, id), idle);

The Next.js forms guide shows this pattern with updateUser.bind(null, userId) and says it works in both Server and Client Components and supports progressive enhancement. It also notes a hidden input would put the value in the rendered HTML unencoded. The React reference pages I read don't mention bind at all, so treat the Next.js page as the source, not as something the React docs promise.

The types are checked by strictBindCallApply, which strict switches on. renameTodo.bind(null, 42) fails with TS2345, Argument of type 'number' is not assignable to parameter of type 'string', pointed at the 42. Turn the flag off and the same file compiles, because bind returns any. I asserted that in check.mjs on both compilers. If a project somewhere has strict: true with strictBindCallApply: false, every bound action in it is untyped.

Forget the bind and you get TS2769, and the message is a bad guide. TypeScript 7.0.2 says Expected 3 or more, but got 2 about a function whose id: string was read as the previous state. Count the parameters yourself.

Does a bound value get validated? It should. React's 'use server' page says server function arguments are fully client-controlled and should be treated as untrusted input. I tested the bound id arriving first in jsdom, and nothing about the network, so that's a rule I'm reporting, not one I've exercised.

Can the State Type Come From the Action?

Not if the action takes it as a parameter. This looks reasonable:

export const save = async (_prev: Awaited<ReturnType<typeof save>>, formData: FormData) => {
  // ...
};

Both compilers answer with TS7022: 'save' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. The parameter needs the return type, and the return type needs the function.

The types only get in your way where inference has nothing precise to work with, and every one of these ten errors is that situation.

Share this Post on X Bluesky

Break the loop by writing the union first and deriving everything else from it. In createAction the returned function is annotated, so type SignupState = Awaited<ReturnType<typeof signup>> is safe downstream. The state came from the union, and the action came from the state.

What Does useFormStatus Give You That the Docs Skip?

A narrowing opportunity. Because pending discriminates the union, this compiles on both compilers:

const { pending, data } = useFormStatus();
const submitting = pending ? String(data.get('email') ?? '') : '';

Drop the check and String(data.get('email')) fails with TS18047, 'data' is possibly 'null'. In jsdom the button disabled itself while a gated action was awaiting and read slow@example.com out of data, then re-enabled when I released the gate. React's reference page adds a rule the types can't enforce: the hook has to be called from a component rendered inside the form, and it reports only on a parent form. I didn't test the wrong placement, so that one is the docs talking.

Do TypeScript 7.0.2 and 5.9.3 Agree?

On every code and every line, yes. Ten of ten broken files produced exactly one diagnostic on each compiler, and it was the same code on the same line.

Ten broken React 19 action files and their tsc error codes, identical on TypeScript 7.0.2 and 5.9.3
Each file in fail/ is compiled alone and must produce exactly one error with this code, on both compilers.

The wording is another matter. In four of the ten (the one-type-argument case, the missing bind, the hoisted initial state and the unannotated return), 5.9.3 lists both overloads and 7.0.2 prints only the last one's complaint. The column can move too: on the unannotated return, 5.9.3 points at the call and 7.0.2 at the initial-state argument. If you diff CI logs across a compiler upgrade, expect that. For the full migration picture see the TypeScript 7 React migration checklist.

One surprise had nothing to do with React. Compile a single named file while a tsconfig.json sits beside it and TypeScript 7.0.2 stops with TS5112, telling you to pass --ignoreConfig. Version 5.9.3 silently ignores the config instead, and doesn't have the flag. My per-file runner passes it for 7 only.

What Does a Green tsc Still Miss?

Three things showed up in jsdom that no type predicted.

Three behaviors tsc cannot see: a dispatch outside a transition, a form emptied after submit, Zod coercing strings
Observed with react-dom 19.3.1 in jsdom, asserted by test/dom.mjs.

A bare add(1) inside onClick compiles on both compilers. At runtime React logged one console.error: an async function with useActionState was called outside of a transition. The docs say the same thing, and the fix is startTransition, or handing the dispatcher to a form action prop, where React does it for you. The types can't tell an event handler from a form.

The form reset is the second. And the third is the coercion above, a number the type promises and only the schema delivers.

Run It Yourself

cd blogs/coding-dunia/examples/react-19-action-typing-patterns
npm install   # react, react-dom, zod, jsdom, two TypeScript versions
npm test      # needs Node 22.22.2 or newer

It prints one line per check and exits non-zero if a result changes. On my machine it passed 39 of 39 on Node 22.23.3, 24.21.0 and 26.10.0.

Is it overkill to run two compilers on a form? For one app, yes. For a shared action layer other people will upgrade under you, I'd keep both until the team has moved.

If You're Also Dealing With...

Frequently Asked Questions

Do I need to pass type arguments to useActionState?
Usually not. With @types/react 19.3.0, four calls with a null initial state, an inline async arrow and an annotated union all compiled with no type arguments on tsc 7.0.2 and 5.9.3. The exception is an action with no return annotation that returns only one member of your state union: then State is inferred as that member and the initial state no longer fits (TS2769). Annotate the action's return type, or pass useActionState<State, FormData>.
Why does useActionState<State> with one type argument fail?
A single type argument selects the first overload, which describes an action that takes only the previous state and returns a dispatcher with no payload. An action that also takes FormData then fails with TS2345, 'Target signature provides too few arguments'. Pass both type arguments, State and Payload, or pass none.
How do I pass an id to a server action typed for useActionState?
Put the id first and bind it: renameTodo.bind(null, id) removes that leading parameter, so what remains is (previousState, formData). The type check depends on strictBindCallApply, which is part of strict. With it off, bind returns any and a wrong-typed argument compiles. Treat the bound value as client-controlled input anyway, because React's docs say server function arguments are.
Does TypeScript catch a dispatch from onClick outside a transition?
No. A bare add(1) inside onClick compiled cleanly on both compilers, and in jsdom with react-dom 19.3.1 React logged one console.error saying the async function was called outside of a transition. Wrap the call in startTransition, or pass the dispatcher to a form action prop, where React does that for you.