Managing forms and file uploads in React using useActionState
useActionState is a React 19 hook that wraps an async function, tracks its pending state,
and returns the result as component state.
It collapses the standard form-submission pattern: an async action, loading state, error handling,
and field validation into a single hook. No onChange handlers, no manual state imports, no separate API route.
This guide covers the full pattern in Next.js: basic form submission, server-side validation with field preservation on failure, file uploads through server actions, and offloading file storage to Uploadcare’s CDN for production use.
What you’ll learn in this guide:
- What
useActionStatedoes and how it queues actions - Why traditional form and file handling in React adds unnecessary complexity
- How to build a basic form with
useActionState - How to manage validation and preserve field values on failure
- How to handle file uploads inside a server action
- How to integrate Uploadcare to offload file storage and CDN delivery
What is useActionState in React?
useActionState is a React hook that takes an async function (an action) and an initial state value,
and gives you back three things: the current state, a dispatch function to trigger the action,
and a boolean that tells you if it’s running.
On the basic level, you can use it like this:
const [state, formAction, isPending] = useActionState(actionFn, initialState);The useActionState provides you with:
-
statewhich is whatever your action returns. It starts asinitialStateand updates every time the action resolves. -
formActiona function you can call to run the action. You can pass it directly to a form’sactionprop, and React will handle the submission for you. -
isPendingis a boolean that indicates whether the action is currently running. It flips totrueas soon as the form submits and stays that way until the action finishes, so you can use it to disable buttons or show loading indicators.
Your action function gets the previous state and whatever data you pass in when you call formAction.
For form submissions, that data is a FormData object with all the fields and files from the form.
So your action looks like this:
async function action(prevState: State, formData: FormData): Promise<State> {
// do something with formData
return newState;
}Think of it like useReducer,
but the reducer can be async and do real work like making a request to an API or writing to a database.
Whatever your action returns becomes the new state.
Why form and file submission is hard in React
By default, React doesn’t give you a form solution out of the box.
The standard approach, where you control every input with state,
means you’re writing onChange handlers for every field and manually assembling the payload on submit.
For a form with five fields, that’s a lot of boilerplate and useState calls to manage.
When you add file uploads to the form, it gets worse.
An <input type="file"> gives you a FileList, which won’t serialize to JSON.
So you switch to FormData and encType="multipart/form-data", which breaks your controlled-form pattern.
Then on the server you’re parsing multipart data, validating file types and sizes, writing to storage. It adds up.
This is the reason why libraries like React Hook Form and Formik were created to help with field state management, but they don’t solve the server side of the problem.
useActionState with a server function does: the action function receives the FormData with files included,
does the work, and returns a result your component can render immediately.
Basic form submission with useActionState
Here’s a simple contact form in a Next.js application. Nothing fancy, just enough to understand the pattern:
'use client';
import { useActionState } from 'react';
type FormState = {
success: boolean;
error: string | null;
message: string | null;
};
async function submitContact(
prevState: FormState,
formData: FormData,
): Promise<FormState> {
const name = formData.get('name') as string;
const message = formData.get('message') as string;
if (!name || !message) {
return { success: false, error: 'Name and message are required', message: null };
}
await new Promise((resolve) => setTimeout(resolve, 1000));
return { success: true, error: null, message: `Thanks, ${name}!` };
}
const initialState: FormState = {
success: false,
error: null,
message: null,
};
export default function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, initialState);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
</div>
{state.error && <p role="alert">{state.error}</p>}
{state.success && <p>{state.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
);
}You pass formAction straight to the form’s action prop. React takes care of the transition.
isPending flips to true the moment the form submits and stays that way until the action resolves,
so you get loading state with no extra work.
This is how the form works with some CSS for better visuals:
Managing form state and validation
When you need to manage form state and validation, the prevState becomes useful.
Whenever the action runs and the form is submitted, the prevState holds whatever you returned last time
(or initialState on the first call).
This allows you to return validation errors alongside the field values the user entered, so the form doesn’t wipe on failure:
// app/actions/contact.ts
'use server';
type ContactState = {
errors: { name?: string; email?: string; message?: string } | null;
values: { name: string; email: string; message: string } | null;
success: boolean;
};
export async function submitContact(
prevState: ContactState,
formData: FormData,
): Promise<ContactState> {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
const errors: { name?: string; email?: string; message?: string } = {};
if (!name || name.trim().length < 2) {
errors.name = 'Name must be at least 2 characters.';
}
if (!email || !email.includes('@')) {
errors.email = 'A valid email address is required.';
}
if (!message || message.trim().length === 0) {
errors.message = 'Message is required.';
}
if (message && message.length > 500) {
errors.message = 'Message must be 500 characters or fewer.';
}
if (Object.keys(errors).length > 0) {
return { errors, values: { name, email, message }, success: false };
}
await db.messages.create({ name, email, message });
return { errors: null, values: null, success: true };
}In the code above:
-
The action receives the form data and validates it.
-
If there are validation errors, it returns an object with the
errorsand thevaluesthe user entered. Thesuccessflag isfalse. -
If validation passes, it saves the message and returns a success state.
Now in the component, you can use defaultValue to put the user’s input back into the fields after a failed submit:
'use client';
import { useActionState } from 'react';
import { submitContact } from '../actions/contact';
const initialState = { errors: null, values: null, success: false };
export default function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, initialState);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
defaultValue={state.values?.name ?? ''}
/>
{state.errors?.name && <span>{state.errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={state.values?.email ?? ''}
/>
{state.errors?.email && <span>{state.errors.email}</span>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
defaultValue={state.values?.message ?? ''}
/>
{state.errors?.message && <span>{state.errors.message}</span>}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send message'}
</button>
{state.success && <p>Message sent!</p>}
</form>
);
}Using defaultValue instead of value helps to keep the form uncontrolled.
React will set the initial field value from defaultValue, and after a failed validation pass,
the values returned from the action repopulate the inputs automatically. No onChange handlers needed.
Handling file uploads with useActionState
Every FormData object is able to carry files natively, so file uploads fit into this pattern naturally.
To retrieve a file from the FormData, call formData.get() with the field name to get the file,
then validate and process it in the action:
// app/actions/upload.ts
'use server';
import { revalidatePath } from 'next/cache';
type UploadState = {
success: boolean;
error: string | null;
fileUrl: string | null;
};
const MAX_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
export async function uploadFileAction(
prevState: UploadState,
formData: FormData,
): Promise<UploadState> {
const file = formData.get('file') as File | null;
if (!file || file.size === 0) {
return { success: false, error: 'No file selected', fileUrl: null };
}
if (!ALLOWED_TYPES.includes(file.type)) {
return { success: false, error: 'File type not allowed', fileUrl: null };
}
if (file.size > MAX_SIZE) {
return { success: false, error: 'File too large (max 5 MB)', fileUrl: null };
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadToStorage(buffer, file.name, file.type);
revalidatePath('/files');
return { success: true, error: null, fileUrl: url };
} catch {
return { success: false, error: 'Upload failed', fileUrl: null };
}
}// app/upload/page.tsx
'use client';
import { useActionState } from 'react';
import { uploadFileAction } from '../actions/upload';
const initialState = { success: false, error: null, fileUrl: null };
export default function UploadPage() {
const [state, formAction, isPending] = useActionState(uploadFileAction, initialState);
return (
<form action={formAction}>
<input name="file" type="file" accept="image/*,.pdf" />
{state.error && <p role="alert">{state.error}</p>}
{state.fileUrl && <a href={state.fileUrl}>View uploaded file</a>}
<button type="submit" disabled={isPending}>
{isPending ? 'Uploading...' : 'Upload'}
</button>
</form>
);
}When a user selects a file and submits the form, the uploadFileAction receives the file as a File object inside FormData.
The action validates the file type and size, reads its bytes with arrayBuffer(), and uploads it to storage.
The resulting URL is returned in the state for the component to render.
Show a pending state with useFormStatus
When your submit button lives in a child component rather than directly in the form, you can’t easily pass isPending down as a prop.
React 19 also provides a useFormStatus hook from react-dom
that solves that by reading the pending state of the nearest parent form without any prop drilling:
// components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
type SubmitButtonProps = {
label: string;
pendingLabel: string;
};
export function SubmitButton({ label, pendingLabel }: SubmitButtonProps) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? pendingLabel : label}
</button>
);
}<form action={formAction}>
<input name="file" type="file" />
<SubmitButton label="Upload" pendingLabel="Uploading..." />
</form>💡 One catch: useFormStatus only works inside a component that’s rendered inside a <form>.
If you call it outside that context, pending will always be false.
Using Uploadcare for file handling with useActionState
Uploading files through a server action works fine for small, simple cases. But once you need things like large file support, resumable uploads, image transformations, or CDN delivery, building that yourself is a significant chunk of infrastructure work.
A cleaner approach is to let the user upload directly to Uploadcare from the browser, and pass the resulting CDN URL to your server action. Uploadcare handles the binary data; your server action just gets a URL to validate and store.
npm install @uploadcare/react-uploader// app/profile/page.tsx
'use client';
import { useActionState, useState } from 'react';
import { FileUploaderRegular } from '@uploadcare/react-uploader/next';
import '@uploadcare/react-uploader/core.css';
import { submitWithFile } from '../actions/submit';
type FormState = {
success: boolean;
error: string | null;
submissionId: string | null;
};
const initialState: FormState = {
success: false,
error: null,
submissionId: null,
};
export default function ProfileForm() {
const [cdnUrl, setCdnUrl] = useState<string | null>(null);
const [state, formAction, isPending] = useActionState(submitWithFile, initialState);
type UploadedFile = { cdnUrl?: string; uuid?: string };
const handleUploadSuccess = (file: UploadedFile) => {
const url = file.cdnUrl ?? (file.uuid ? `https://ucarecdn.com/${file.uuid}/` : null);
if (url) setCdnUrl(url);
};
return (
<form action={formAction}>
<div>
<label htmlFor="username">Username</label>
<input id="username" name="username" type="text" required />
</div>
<div>
<label>Avatar</label>
<FileUploaderRegular
pubkey={process.env.NEXT_PUBLIC_UPLOADCARE_PUBLIC_KEY as string}
onFileUploadSuccess={handleUploadSuccess}
imgOnly
/>
<input type="hidden" name="avatarUrl" value={cdnUrl ?? ''} />
</div>
{state.error && <p role="alert">{state.error}</p>}
{state.success && <p>Profile saved.</p>}
<button type="submit" disabled={isPending || !cdnUrl}>
{isPending ? 'Saving...' : 'Save profile'}
</button>
</form>
);
}// app/actions/submit.ts
'use server';
type FormState = {
success: boolean;
error: string | null;
submissionId: string | null;
};
export async function submitWithFile(
prevState: FormState,
formData: FormData,
): Promise<FormState> {
const username = formData.get('username') as string;
const avatarUrl = formData.get('avatarUrl') as string;
if (!username) {
return { success: false, error: 'Username is required', submissionId: null };
}
if (!avatarUrl || !avatarUrl.startsWith('https://ucarecdn.com/')) {
return { success: false, error: 'Please upload an avatar', submissionId: null };
}
const id = await db.profiles.create({ username, avatarUrl });
return { success: true, error: null, submissionId: id };
}Your server action receives a plain string. No multipart parsing, no streaming large files through your server. The file is already sitting on Uploadcare’s CDN.
Handle multiple file uploads
FileUploaderRegular fires onFileUploadSuccess once per file.
To handle multiple uploads, keep an array of CDN URLs in state and pass them to your action as a JSON string via a hidden input:
'use client';
import { useActionState, useState } from 'react';
import { FileUploaderRegular } from '@uploadcare/react-uploader/next';
import '@uploadcare/react-uploader/core.css';
import { submitGallery } from '../actions/gallery';
type GalleryState = { success: boolean; error: string | null };
const initialState: GalleryState = { success: false, error: null };
export default function GalleryForm() {
const [fileUrls, setFileUrls] = useState<string[]>([]);
const [state, formAction, isPending] = useActionState(submitGallery, initialState);
type UploadedFile = { cdnUrl?: string; uuid?: string };
const handleUploadSuccess = (file: UploadedFile) => {
const url = file.cdnUrl ?? (file.uuid ? `https://ucarecdn.com/${file.uuid}/` : null);
if (url) setFileUrls((prev) => [...prev, url]);
};
return (
<form action={formAction}>
<FileUploaderRegular
pubkey={process.env.NEXT_PUBLIC_UPLOADCARE_PUBLIC_KEY as string}
onFileUploadSuccess={handleUploadSuccess}
multiple
/>
<input type="hidden" name="fileUrls" value={JSON.stringify(fileUrls)} />
<p>{fileUrls.length} file(s) uploaded</p>
{state.error && <p role="alert">{state.error}</p>}
{state.success && <p>Gallery saved.</p>}
<button type="submit" disabled={isPending || fileUrls.length === 0}>
{isPending ? 'Saving...' : 'Save gallery'}
</button>
</form>
);
}When to use useActionState
useActionState is a solid fit when your form submits to a server, and the server tells you what happened. Specifically:
- The server action returns all the states the component needs to render
- You want the form to work without JavaScript when paired with server actions
- You want a simple pending state without reaching for
useTransitionmanually
It’s less suited for:
- Real-time keystroke validation: the action runs on submit, not on change
- Complex client-side state that doesn’t map cleanly to a single return value
- Optimistic UI updates: reach for
useOptimisticalongsideuseActionStatefor that
For forms with complex validation rules, field arrays, or a lot of client-side logic, React Hook Form still gives you more control. See how to use Uploadcare with React Hook Form for that approach.
Further reading
- How to upload files in Next.js
- How do I handle file uploads in a Node.js Express API?
- How do I use Uploadcare with React Hook Form?
- How do I restrict file types in an Uploadcare uploader?
FAQ
What is useActionState used for in React?
useActionState is a React 19 hook that ties an async function to component state.
You pass it an action and an initial state, and it returns the current state, a dispatch function, and an isPending boolean.
Each time the action runs, its return value becomes the new state. It’s the recommended way to handle form submissions in React 19.
Can you upload files with React Server Actions?
Yes. When a form with a file input submits to a server action, the action receives the file as a File object inside FormData.
You read it with formData.get('fieldName') and handle it from there.
For production, uploading from the browser directly to a CDN like Uploadcare and passing the URL to the action is more scalable. Your server gets a URL string instead of binary data, which is simpler to validate and store.
Is useActionState better than form libraries?
It depends on what you’re building. useActionState is great when validation and persistence live on the server.
Libraries like React Hook Form are better for complex client-side validation, field arrays, and real-time feedback.
For most standard forms that talk to a server, useActionState reduces the code you need and removes the need for a separate API route.
How should files be handled in server actions?
Two approaches:
-
First, receive the file directly in the action via
formData.get('file'), read its bytes witharrayBuffer(), and write to storage yourself. -
Second, upload from the browser to a service like Uploadcare first, then pass the CDN URL as a hidden input to the action. The second approach means no binary data flows through your server, which is simpler to scale and easier to reason about.