As React continues to evolve with a focus on declarative data flow, server actions, and streamlined UX, new hooks like useActionState
are becoming central to modern React applications—especially those built with frameworks like Next.js App Router. But for developers working with TypeScript, combining this hook with strong typing can lead to cleaner, safer, and more scalable code.
In this blog, we’ll dive deep into how to use useActionState
effectively with TypeScript. You’ll learn how to type the action reducer, form state, and FormData
handling, and how this approach can simplify form handling and asynchronous state management in React.
What Is useActionState
?
The useActionState
hook was introduced in React 18.2 as a way to manage async form submissions in a more declarative and stateful manner. It’s particularly useful when paired with native HTML forms and server actions in frameworks like Next.js.
Instead of using useState
, useReducer
, and onSubmit
handlers separately, useActionState
allows you to centralize all logic within a single async reducer function and automatically handle state transitions on form submission.
Syntax:
const [state, formAction] = useActionState(actionReducer, initialState);
Why Combine useActionState
with TypeScript?
TypeScript offers several advantages when using useActionState
:
- 🔐 Type safety for
FormData
extraction and validation - 📦 Predictable state structure across submissions
- 🚫 Fewer runtime errors by catching type issues at compile time
- 🔄 Reusable reducer logic with clear input/output expectations
When your state is asynchronous and handled by user actions (like submitting a form), TypeScript’s strong typing can make your app easier to debug and extend.
Basic Example: Typing useActionState
in TypeScript
Let’s begin with a basic file upload form that uses useActionState
and adds TypeScript support.
Step 1: Define State Types
type UploadFormState = {
success: string | null;
error: string | null;
};
Step 2: Define the Reducer Function
const uploadReducer = async (
prevState: UploadFormState,
formData: FormData
): Promise<UploadFormState> => {
const file = formData.get("file");
if (!file || !(file instanceof File)) {
return {
success: null,
error: "Please select a valid file.",
};
}
// Simulate upload
await new Promise((res) => setTimeout(res, 1000));
return {
success: `File "${file.name}" uploaded successfully!`,
error: null,
};
};
Step 3: Create the Component
'use client';
import { useActionState } from 'react';
export default function FileUploadForm() {
const [uploadState, formAction] = useActionState<UploadFormState, FormData>(
uploadReducer,
{ success: null, error: null }
);
return (
<form action={formAction} encType="multipart/form-data">
<input type="file" name="file" />
<button type="submit">Upload</button>
{uploadState.error && <p style={{ color: 'red' }}>{uploadState.error}</p>}
{uploadState.success && <p style={{ color: 'green' }}>{uploadState.success}</p>}
</form>
);
}
With this, TypeScript enforces:
- That the reducer returns the correct shape
- That your form handler logic matches expected state transitions
- That the inputs from
FormData
are safely checked
Custom Form Data Types with TypeScript
You can define your form data structure explicitly to reduce errors from formData.get()
calls.
type FormFields = {
name: string;
email: string;
};
function extractFormFields(formData: FormData): FormFields {
const name = formData.get('name');
const email = formData.get('email');
if (typeof name !== 'string' || typeof email !== 'string') {
throw new Error('Invalid form input');
}
return { name, email };
}
Then use it in your reducer:
type FormState = {
success: string | null;
error: string | null;
};
const reducer = async (
prevState: FormState,
formData: FormData
): Promise<FormState> => {
try {
const { name, email } = extractFormFields(formData);
if (!email.includes('@')) {
return { success: null, error: 'Invalid email' };
}
return { success: `Welcome, ${name}!`, error: null };
} catch {
return { success: null, error: 'Form submission failed' };
}
};
Typing File Uploads, Validation, and Form Logic
Let’s extend this with file validation:
type UploadState = {
error: string | null;
success: string | null;
};
const reducer = async (
prev: UploadState,
formData: FormData
): Promise<UploadState> => {
const file = formData.get('file');
if (!file || !(file instanceof File)) {
return { error: 'No file selected', success: null };
}
if (!['image/png', 'image/jpeg'].includes(file.type)) {
return { error: 'Only PNG or JPEG allowed', success: null };
}
if (file.size > 5 * 1024 * 1024) {
return { error: 'File must be less than 5MB', success: null };
}
return { success: `Uploaded: ${file.name}`, error: null };
};
TypeScript helps ensure:
- The file is actually a
File
object - Accepted MIME types are enforced
- Size limits are respected
Server Actions + useActionState
with TypeScript (Next.js)
In Next.js App Router, server actions can be passed directly into useActionState
for even more powerful typed behavior.
Define Server Action
// app/actions/createUser.ts
'use server';
export type CreateUserState = {
success: string | null;
error: string | null;
};
export async function createUser(
prevState: CreateUserState,
formData: FormData
): Promise<CreateUserState> {
const name = formData.get('name');
const email = formData.get('email');
if (typeof name !== 'string' || typeof email !== 'string') {
return { success: null, error: 'Invalid input' };
}
if (!email.includes('@')) {
return { success: null, error: 'Invalid email format' };
}
return {
success: `User ${name} created successfully`,
error: null,
};
}
Use It in a Client Component
'use client';
import { useActionState } from 'react';
import { createUser, CreateUserState } from '../actions/createUser';
export default function CreateUserForm() {
const [formState, formAction] = useActionState<CreateUserState, FormData>(
createUser,
{ success: null, error: null }
);
return (
<form action={formAction}>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" type="email" />
<button type="submit">Create</button>
{formState.error && <p style={{ color: 'red' }}>{formState.error}</p>}
{formState.success && <p style={{ color: 'green' }}>{formState.success}</p>}
</form>
);
}
This combination gives you:
- Strongly typed server-side validation
- Typed client-side reducer expectations
- Clear, safe UI rendering paths
Benefits of Typing useActionState
✅ Autocompletion for reducer logic and return values
✅ Type-safe FormData
extraction
✅ Easier debugging with fewer runtime surprises
✅ Better collaboration in large teams
✅ Scalability for growing forms or state structures
✅ Better documentation and readability
Best Practices
- ✅ Always define a clear
State
interface - ✅ Check for
typeof === 'string'
onformData.get()
- ✅ Avoid spreading unknown values—validate them first
- ✅ Use utility functions like
extractFormFields()
for reuse and testing - ✅ Use union types for enums or conditional state transitions if needed
Conclusion
The combination of useActionState
and TypeScript unlocks a new level of form handling power in React. You get the benefits of declarative form submission and clean component logic, without sacrificing type safety or maintainability.
Whether you’re building client-only forms or leveraging server actions in frameworks like Next.js, using strong TypeScript types with useActionState
helps you write better code with fewer bugs—and a better developer experience overall.
If you’re building modern React apps in TypeScript, now is the perfect time to adopt this pattern and take full advantage of what React 18.2 and the modern ecosystem have to offer.