Loading...
save

How to correctly type a React 19 form action using the new useActionState hook in TypeScript?

clock icon

asked 77 days ago

message icon

3

eye icon

42

I am upgrading my Next.js application to use React 19 forms, and I am trying to implement the new useActionState hook for handling form submissions and server actions cleanly.

However, I am running into a TypeScript compilation error when trying to type the action function parameters, specifically regarding the prevState argument.

Here is the simplified version of my form component code:

1"use server";
2
3interface FormState {
4 success: boolean;
5 message: string;
6}
7
8async function submitFormAction(prevState: FormState, formData: FormData): Promise<FormState> {
9 const email = formData.get("email");
10
11 if (!email) {
12 return { success: false, message: "Email is required." };
13 }
14
15 return { success: true, message: "Subscribed successfully!" };
16}
1"use server";
2
3interface FormState {
4 success: boolean;
5 message: string;
6}
7
8async function submitFormAction(prevState: FormState, formData: FormData): Promise<FormState> {
9 const email = formData.get("email");
10
11 if (!email) {
12 return { success: false, message: "Email is required." };
13 }
14
15 return { success: true, message: "Subscribed successfully!" };
16}

Inside my client component, I am calling it like this:

1"use client";
2
3import { useActionState } from "react";
4
5export default function SubscribeForm() {
6 // TypeScript throws an error here on submitFormAction
7 const [state, formAction, isPending] = useActionState(submitFormAction, {
8 success: false,
9 message: "",
10 });
11
12 return (
13 <form action={formAction}>
14 <input type="email" name="email" className="border p-2" />
15 <button type="submit" disabled={isPending}>
16 {isPending ? "Submitting..." : "Subscribe"}
17 </button>
18 {state.message && <p>{state.message}</p>}
19 </form>
20 );
21}
1"use client";
2
3import { useActionState } from "react";
4
5export default function SubscribeForm() {
6 // TypeScript throws an error here on submitFormAction
7 const [state, formAction, isPending] = useActionState(submitFormAction, {
8 success: false,
9 message: "",
10 });
11
12 return (
13 <form action={formAction}>
14 <input type="email" name="email" className="border p-2" />
15 <button type="submit" disabled={isPending}>
16 {isPending ? "Submitting..." : "Subscribe"}
17 </button>
18 {state.message && <p>{state.message}</p>}
19 </form>
20 );
21}

The Error I am getting:

Argument of type (prevState: FormState, formData: FormData) => Promise<FormState> is not assignable to parameter of type (state: FormState, formData: FormData) => void | FormState | Promise<void | FormState>.

What is the correct way to type this function signature in React 19 so that TypeScript infers the state correctly without resorting to any?

3 Answers

The TypeScript error you're encountering stems from a common strictness issue in how TypeScript handles generic types, particularly when void is involved in a union within a Promise. While your original action function signature is logically sound, the @types/react definition for useActionState might have a specific expectation that Promise<T> isn't implicitly assignable to Promise<T | void>.

Here's how to correctly type your action function to resolve this, prioritizing the solutions.

1. Update Dependencies (Recommended First Step)

First, ensure your react, react-dom, and @types/react dependencies are updated to their latest canary/experimental versions for React 19. Type definitions for new, experimental features can evolve quickly, and an outdated type definition is a frequent cause of such errors.

1# For npm
2npm install react@next react-dom@next @types/react@next @types/react-dom@next
3
4# For yarn
5yarn add react@next react-dom@next @types/react@next @types/react-dom@next
1# For npm
2npm install react@next react-dom@next @types/react@next @types/react-dom@next
3
4# For yarn
5yarn add react@next react-dom@next @types/react@next @types/react-dom@next

After updating, restart your TypeScript server or IDE (e.g., VS Code) and check if the error persists. If the error still shows, proceed to the solution below.

2. Adjust the Action Function's Return Type

If updating dependencies doesn't resolve the issue, you need to explicitly broaden the return type of your async action function to include void within the Promise's generic parameter. This satisfies the strict type checker based on the error message's expectation of Promise<void | FormState>.

1// app/actions/submitFormAction.ts
2"use server";
3
4interface FormState {
5 success: boolean;
6 message: string;
7}
8
9// Corrected signature to match the expected Promise return type from the error message
10async function submitFormAction(
11 prevState: FormState,
12 formData: FormData
13): Promise<FormState | void> { // <-- Key change: added `| void` to the Promise generic
14 const email = formData.get("email");
15
16 if (!email) {
17 // Explicitly return FormState; it is compatible with FormState | void
18 return { success: false, message: "Email is required." };
19 }
20
21 // Explicitly return FormState; it is compatible with FormState | void
22 return { success: true, message: "Subscribed successfully!" };
23}
24
25export { submitFormAction, type FormState }; // Export FormState for client component
1// app/actions/submitFormAction.ts
2"use server";
3
4interface FormState {
5 success: boolean;
6 message: string;
7}
8
9// Corrected signature to match the expected Promise return type from the error message
10async function submitFormAction(
11 prevState: FormState,
12 formData: FormData
13): Promise<FormState | void> { // <-- Key change: added `| void` to the Promise generic
14 const email = formData.get("email");
15
16 if (!email) {
17 // Explicitly return FormState; it is compatible with FormState | void
18 return { success: false, message: "Email is required." };
19 }
20
21 // Explicitly return FormState; it is compatible with FormState | void
22 return { success: true, message: "Subscribed successfully!" };
23}
24
25export { submitFormAction, type FormState }; // Export FormState for client component

Explanation of the Fix:

By changing the return type to Promise<FormState | void>, you explicitly tell TypeScript that the promise will resolve to either a FormState object or void. Since your function always returns FormState, and FormState is compatible with FormState | void, this satisfies the type checker's expectation for Promise<void | FormState>.

Your Client Component (Remains Unchanged)

Your client component code for useActionState remains correct as it is, provided submitFormAction and FormState are properly imported:

1// app/components/SubscribeForm.tsx
2"use client";
3
4import { useActionState } from "react";
5// Import both the action and the FormState interface
6import { submitFormAction, type FormState } from "../actions/submitFormAction";
7
8export default function SubscribeForm() {
9 // TypeScript will now correctly infer types or you can explicitly provide them
10 const [state, formAction, isPending] = useActionState<FormState, FormData>(
11 submitFormAction,
12 { success: false, message: "" }
13 );
14
15 return (
16 <form action={formAction}>
17 <input type="email" name="email" className="border p-2" />
18 <button type="submit" disabled={isPending}>
19 {isPending ? "Submitting..." : "Subscribe"}
20 </button>
21 {state.message && <p>{state.message}</p>}
22 </form>
23 );
24}
1// app/components/SubscribeForm.tsx
2"use client";
3
4import { useActionState } from "react";
5// Import both the action and the FormState interface
6import { submitFormAction, type FormState } from "../actions/submitFormAction";
7
8export default function SubscribeForm() {
9 // TypeScript will now correctly infer types or you can explicitly provide them
10 const [state, formAction, isPending] = useActionState<FormState, FormData>(
11 submitFormAction,
12 { success: false, message: "" }
13 );
14
15 return (
16 <form action={formAction}>
17 <input type="email" name="email" className="border p-2" />
18 <button type="submit" disabled={isPending}>
19 {isPending ? "Submitting..." : "Subscribe"}
20 </button>
21 {state.message && <p>{state.message}</p>}
22 </form>
23 );
24}

By applying the recommended dependency updates first, and then the explicit Promise<FormState | void> return type if necessary, you should resolve the TypeScript compilation error.

The TypeScript error you're encountering, while seemingly specific, points to a subtle mismatch in how your environment's @types/react might be interpreting the useActionState hook's action signature, especially regarding the void return type. Based on the official React 19 useActionState type definitions, your original submitFormAction signature (Promise<FormState>) should generally be compatible.

However, to resolve the exact error message you're receiving—where TypeScript expects the action's return type to include void | FormState within the Promise—you can adjust the return type of your server action.

The Problem

The error:

Argument of type (prevState: FormState, formData: FormData) => Promise<FormState> is not assignable to parameter of type (state: FormState, formData: FormData) => void | FormState | Promise<void | FormState>.

This indicates that TypeScript expects the return type of your submitFormAction to be assignable to void | FormState | Promise<void | FormState>. Your action returns Promise<FormState>. While Promise<FormState> should be assignable to Promise<void | FormState> (as FormState is a subtype of void | FormState), your TypeScript setup is being stricter, or picking up a slightly different overload.

The Solution

To explicitly satisfy the expected type from the error message, you can declare your submitFormAction to return Promise<FormState | void>. Even though your function will always resolve with a FormState object, this type signature makes it compatible with the broader Promise<void | FormState> expectation.

Here's the corrected submitFormAction signature:

1// actions.ts or wherever your server action is defined
2"use server";
3
4interface FormState {
5 success: boolean;
6 message: string;
7}
8
9// Corrected type signature
10async function submitFormAction(prevState: FormState, formData: FormData): Promise<FormState | void> {
11 const email = formData.get("email");
12
13 if (!email) {
14 // This still returns FormState, which is compatible with Promise<FormState | void>
15 return { success: false, message: "Email is required." };
16 }
17
18 return { success: true, message: "Subscribed successfully!" };
19}
1// actions.ts or wherever your server action is defined
2"use server";
3
4interface FormState {
5 success: boolean;
6 message: string;
7}
8
9// Corrected type signature
10async function submitFormAction(prevState: FormState, formData: FormData): Promise<FormState | void> {
11 const email = formData.get("email");
12
13 if (!email) {
14 // This still returns FormState, which is compatible with Promise<FormState | void>
15 return { success: false, message: "Email is required." };
16 }
17
18 return { success: true, message: "Subscribed successfully!" };
19}

Your client component code then remains the same, as useActionState will correctly infer the State as FormState from the initialState argument:

1// components/SubscribeForm.tsx
2"use client";
3
4import { useActionState } from "react";
5// Assuming submitFormAction is imported from its file
6import { submitFormAction } from './actions';
7
8interface FormState {
9 success: boolean;
10 message: string;
11}
12
13export default function SubscribeForm() {
14 // TypeScript will now correctly infer the types
15 const [state, formAction, isPending] = useActionState(submitFormAction, {
16 success: false,
17 message: "",
18 });
19
20 return (
21 <form action={formAction}>
22 <input type="email" name="email" className="border p-2" />
23 <button type="submit" disabled={isPending}>
24 {isPending ? "Submitting..." : "Subscribe"}
25 </button>
26 {state.message && <p>{state.message}</p>}
27 </form>
28 );
29}
1// components/SubscribeForm.tsx
2"use client";
3
4import { useActionState } from "react";
5// Assuming submitFormAction is imported from its file
6import { submitFormAction } from './actions';
7
8interface FormState {
9 success: boolean;
10 message: string;
11}
12
13export default function SubscribeForm() {
14 // TypeScript will now correctly infer the types
15 const [state, formAction, isPending] = useActionState(submitFormAction, {
16 success: false,
17 message: "",
18 });
19
20 return (
21 <form action={formAction}>
22 <input type="email" name="email" className="border p-2" />
23 <button type="submit" disabled={isPending}>
24 {isPending ? "Submitting..." : "Subscribe"}
25 </button>
26 {state.message && <p>{state.message}</p>}
27 </form>
28 );
29}

Explanation

By changing the return type of submitFormAction to Promise<FormState | void>, you explicitly declare that the promise it returns can resolve to either a FormState object or void. This broader type makes it assignable to the Promise<void | FormState> part of the useActionState's expected function signature, resolving the TypeScript error.

While your original code's signature Promise<FormState> is often sufficient and semantically more precise (since your function always returns a FormState object), this adjustment directly addresses the specific type mismatch reported by your TypeScript compiler without resorting to any.

This error happens because React 19's useActionState expects the first argument of the action function (state) to perfectly match the type of the initial state you pass as the second argument, but it can be strict about async return types if the state transitions are not exact.

In your case, the signature mismatch often triggers because of how the bundler or TypeScript handles the Promise<FormState> wrapper across the client-server boundary.

Here are the two best ways to fix this type mismatch cleanly.


Solution 1: Use the Explicit Awaited Type Signature

The cleanest approach is to type your function parameters explicitly so TypeScript knows exactly how to map the state across async execution. Update your action function signature like this:

1async function submitFormAction(
2 prevState: Awaited<FormState>,
3 formData: FormData
4): Promise<FormState> {
5 const email = formData.get("email");
6
7 if (!email) {
8 return { success: false, message: "Email is required." };
9 }
10
11 return { success: true, message: "Subscribed successfully!" };
12}
1async function submitFormAction(
2 prevState: Awaited<FormState>,
3 formData: FormData
4): Promise<FormState> {
5 const email = formData.get("email");
6
7 if (!email) {
8 return { success: false, message: "Email is required." };
9 }
10
11 return { success: true, message: "Subscribed successfully!" };
12}

1

Write your answer here

Top Questions