React 19 useActionState: Simplifying Forms and Async Actions
Learn how React 19's useActionState hook simplifies form submissions, async actions, loading states, validation errors, and server mutations with practical examples you can test yourself.
Introduction
Handling forms is one of the most common tasks in React applications.
Consider a simple profile update form.
A user changes their name and clicks:
Update ProfileBehind the scenes, we normally need to:
Read the form values.
Set a loading state.
Call an API.
Wait for the response.
Handle validation errors.
Handle request failures.
Update the UI.
Disable the button while submitting.
Before React 19, developers often managed these states manually using multiple useState hooks.
For example:
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [message, setMessage] = useState(null);This works, but forms can quickly become repetitive.
React 19 introduces a cleaner approach:
useActionStateA basic example looks like:
const [state, formAction, isPending] = useActionState(
updateProfile,
initialState
);From one hook, we get:
state
formAction
isPendingLet's understand how it works and build a real example.
What Problem Does useActionState Solve?
Imagine we have this form:
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit">
Update
</button>
</form>Our submit function might look like:
async function handleSubmit(event) {
event.preventDefault();
setLoading(true);
setError(null);
try {
const response = await updateUser(name);
setMessage("Profile updated successfully");
} catch (error) {
setError("Something went wrong");
} finally {
setLoading(false);
}
}This is completely valid React.
But we are manually managing:
loading
error
success
form submission
API stateWith more complicated forms, the amount of state grows.
React 19 Actions and useActionState help reduce this boilerplate.
What Is useActionState?
Import it from React:
import { useActionState } from "react";The basic syntax is:
const [state, dispatchAction, isPending] =
useActionState(actionFunction, initialState);It returns three values.
1. state
The value returned by your action.
2. dispatchAction
The Action that triggers your function.
It can be passed directly to a form:
<form action={dispatchAction}>3. isPending
A boolean indicating whether the Action is currently running.
For example:
<button disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>Our Practical Example
We will build a simple profile form.
The user can update:
Name
EmailOur form will support:
Validation
Loading state
Success message
Error message
Simulated API delay
Failed API simulation
You can run this example in a React 19 project.
Step 1: Create a React Project
Using Vite:
npm create vite@latest react-action-demoChoose:
React
JavaScriptEnter the project:
cd react-action-demoInstall dependencies:
npm installStart the application:
npm run devOpen the URL shown by Vite.
Usually:
http://localhost:5173Step 2: Confirm React Version
Run:
npm list reactYou should be using React 19 or later.
You can also inspect:
package.jsonand look for:
{
"dependencies": {
"react": "^19.x.x",
"react-dom": "^19.x.x"
}
}Step 3: Create the Action
Create:
src/actions/profileAction.jsAdd:
export async function updateProfileAction(
previousState,
formData
) {
const name = formData.get("name");
const email = formData.get("email");
await new Promise((resolve) =>
setTimeout(resolve, 1500)
);
if (!name?.trim()) {
return {
success: false,
message: "Name is required.",
};
}
if (!email?.trim()) {
return {
success: false,
message: "Email is required.",
};
}
if (!email.includes("@")) {
return {
success: false,
message: "Please enter a valid email address.",
};
}
return {
success: true,
message: "Profile updated successfully.",
};
}For now, we are not calling a real backend.
This line simulates API latency:
await new Promise((resolve) =>
setTimeout(resolve, 1500)
);This gives us enough time to see the pending state.
Step 4: Create the Form
Create:
src/ProfileForm.jsxAdd:
import { useActionState } from "react";
import {
updateProfileAction,
} from "./actions/profileAction";
const initialState = {
success: false,
message: "",
};
export default function ProfileForm() {
const [
state,
formAction,
isPending,
] = useActionState(
updateProfileAction,
initialState
);
return (
<form action={formAction}>
<div>
<label htmlFor="name">
Name
</label>
<input
id="name"
name="name"
type="text"
/>
</div>
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
/>
</div>
<button
type="submit"
disabled={isPending}
>
{isPending
? "Updating..."
: "Update Profile"}
</button>
{state.message && (
<p>
{state.message}
</p>
)}
</form>
);
}Notice:
<form action={formAction}>We are not using:
onSubmit={handleSubmit}React's Action handles the form submission.
Step 5: Render the Component
Update:
src/App.jsxwith:
import ProfileForm from "./ProfileForm";
function App() {
return (
<main>
<h1>Update Profile</h1>
<ProfileForm />
</main>
);
}
export default App;Run:
npm run devOpen:
http://localhost:5173You should now see the form.
Step 6: Test Empty Name
Leave the name field empty.
Enter:
developer@example.comas the email.
Click:
Update ProfileDuring the simulated request, the button should display:
Updating...After approximately 1.5 seconds, you should see:
Name is required.That error came from:
return {
success: false,
message: "Name is required.",
};The returned object automatically becomes the new:
stateUnderstanding previousState
Our Action receives:
async function updateProfileAction(
previousState,
formData
)The first parameter is:
previousStateThe second parameter contains the Action payload—in this case, our form data.
For example:
const name = formData.get("name");Without useActionState, a normal form action might receive:
async function action(formData)With useActionState, React adds the previous state before the original arguments.
So it becomes:
async function action(
previousState,
formData
)This is an important detail.
Step 7: Test Invalid Email
Enter:
Name:
Jalishand:
Email:
wrong-emailSubmit the form.
Our Action checks:
if (!email.includes("@")) {
return {
success: false,
message:
"Please enter a valid email address.",
};
}Expected result:
Please enter a valid email address.Step 8: Test Successful Submission
Enter:
Name:
Jalishand:
Email:
jalish@example.comSubmit.
You should first see:
Updating...and then:
Profile updated successfully.The flow is:
User submits form
↓
formAction runs
↓
isPending = true
↓
Action processes request
↓
Action returns state
↓
state updates
↓
isPending = falseThis is the main idea behind useActionState.
Before React 19 vs React 19
Let's compare both approaches.
Traditional Approach
We might need:
const [loading, setLoading] =
useState(false);
const [error, setError] =
useState("");
const [success, setSuccess] =
useState("");Then:
async function handleSubmit(event) {
event.preventDefault();
setLoading(true);
setError("");
try {
await updateProfile();
setSuccess(
"Profile updated successfully."
);
} catch {
setError(
"Something went wrong."
);
} finally {
setLoading(false);
}
}Our component is responsible for managing the entire request lifecycle.
React 19 Approach
With useActionState:
const [
state,
formAction,
isPending,
] = useActionState(
updateProfileAction,
initialState
);The Action handles the operation.
The component mainly renders the resulting state.
<form action={formAction}>Pending state becomes:
isPendingThe returned result becomes:
stateThis can make action-oriented components much easier to understand.
Returning Field-Level Validation Errors
A real form may need individual errors.
Instead of returning:
{
message: "Validation failed"
}we can return:
{
success: false,
errors: {
name: "Name is required",
email: "Invalid email",
},
}Let's implement that.
Update the Action:
export async function updateProfileAction(
previousState,
formData
) {
const name = formData.get("name");
const email = formData.get("email");
const errors = {};
if (!name?.trim()) {
errors.name =
"Name is required.";
}
if (!email?.trim()) {
errors.email =
"Email is required.";
} else if (!email.includes("@")) {
errors.email =
"Enter a valid email.";
}
if (Object.keys(errors).length > 0) {
return {
success: false,
message:
"Please fix the errors below.",
errors,
};
}
await new Promise((resolve) =>
setTimeout(resolve, 1500)
);
return {
success: true,
message:
"Profile updated successfully.",
errors: {},
};
}Update:
const initialState = {
success: false,
message: "",
errors: {},
};Now show the error below each field.
<input
id="name"
name="name"
/>
{state.errors?.name && (
<p>
{state.errors.name}
</p>
)}And:
<input
id="email"
name="email"
type="email"
/>
{state.errors?.email && (
<p>
{state.errors.email}
</p>
)}Now the form provides much better feedback.
Connecting It to a Real API
Our current example only simulates an API.
Let's replace it with a real HTTP request.
Suppose our backend provides:
PUT /api/profileOur Action can call it using:
export async function updateProfileAction(
previousState,
formData
) {
const name = formData.get("name");
const email = formData.get("email");
try {
const response = await fetch(
"http://localhost:3000/api/profile",
{
method: "PUT",
headers: {
"Content-Type":
"application/json",
},
body: JSON.stringify({
name,
email,
}),
}
);
const data =
await response.json();
if (!response.ok) {
return {
success: false,
message:
data.message ||
"Profile update failed.",
};
}
return {
success: true,
message:
"Profile updated successfully.",
};
} catch (error) {
return {
success: false,
message:
"Unable to connect to the server.",
};
}
}The React component does not need to change.
This is useful because the UI is separated from the mutation logic.
Handling Pending State
One of my favorite parts of this hook is:
isPendingInstead of manually writing:
const [loading, setLoading] =
useState(false);we can simply use:
<button disabled={isPending}>and:
{isPending
? "Updating..."
: "Update Profile"}This prevents users from repeatedly clicking the submit button.
For example:
Click
Click
Click
Clickcould otherwise create multiple requests.
Disabling the button helps reduce accidental duplicate submissions.
Using useFormStatus
React 19 also improves form handling through:
useFormStatusfrom:
react-domImagine our submit button is a separate reusable component.
Create:
SubmitButton.jsxAdd:
import {
useFormStatus,
} from "react-dom";
export default function SubmitButton() {
const { pending } =
useFormStatus();
return (
<button
type="submit"
disabled={pending}
>
{pending
? "Updating..."
: "Update Profile"}
</button>
);
}Then your form becomes:
<form action={formAction}>
<input
name="name"
/>
<input
name="email"
/>
<SubmitButton />
</form>The button automatically understands the status of its parent form.
You do not need to pass:
isPendingthrough props.
useActionState vs useFormStatus
These hooks solve related but different problems.
useActionState
Use it when you need:
Action result
Validation result
Success state
Error state
Pending state
Previous action stateExample:
const [
state,
action,
isPending,
] = useActionState(
updateProfile,
initialState
);useFormStatus
Use it inside a child of a form when that component only needs information about the form submission.
Example:
const { pending } =
useFormStatus();A common combination is:
Form Component
↓
useActionState
↓
Submit Button
↓
useFormStatusCombining useActionState with useOptimistic
Another powerful React 19 combination is:
useActionState
+
useOptimisticImagine a shopping cart.
Current quantity:
1The user clicks:
+Without optimistic UI:
Click
↓
Wait for server
↓
Server returns
↓
Quantity becomes 2With optimistic UI:
Click
↓
Quantity immediately becomes 2
↓
Server request runs
↓
React confirms or reconciles stateConceptually:
const [
cart,
updateCart,
isPending,
] = useActionState(
updateCartAction,
initialCart
);
const [
optimisticCart,
setOptimisticCart,
] = useOptimistic(cart);useActionState manages the actual Action result.
useOptimistic provides immediate visual feedback.
These hooks solve different parts of the same user experience.
Practical Use Cases for useActionState
The hook is especially useful for operations such as:
Login Form
Submit credentials
↓
Authenticate
↓
Return validation/error stateRegistration
Create account
↓
Validate server response
↓
Show success/errorProfile Update
Edit profile
↓
Save
↓
Display resultPassword Change
Submit password
↓
Validate
↓
Update
↓
Display resultComment Form
Write comment
↓
Submit
↓
Server saves comment
↓
Update UIContact Form
Enter message
↓
Send
↓
Show confirmationShopping Cart
Add product
↓
Update backend
↓
Update action stateCheckout
Submit order
↓
Process request
↓
Return order statusWhen Should You Use useActionState?
Use it when your UI is centered around an Action that changes some state.
Examples:
Submit form
Save settings
Update profile
Delete item
Add comment
Create order
Update cartIt is especially useful when you need:
Action result
+
Pending state
+
Validation feedbackWhen You May Not Need It
Not every state belongs in useActionState.
For example:
const [isOpen, setIsOpen] =
useState(false);is still perfectly appropriate for:
Modal open/close
Dropdown state
Tabs
Theme selection
Local UI togglesuseActionState is not a replacement for:
useStateIt solves a different problem.
Think of it like this:
Local UI State
→ useState
Async Action State
→ useActionStateImportant Mistake to Avoid
One common mistake is forgetting that your Action receives the previous state first.
Wrong:
async function updateProfile(
formData
) {
const name =
formData.get("name");
}When used through useActionState, write:
async function updateProfile(
previousState,
formData
) {
const name =
formData.get("name");
}The first argument is now:
previousStateThis is important when migrating existing form Actions.
Test Checklist
After building the example, test these scenarios.
Test 1 — Empty Name
Expected:
Name is required.Test 2 — Empty Email
Expected:
Email is required.Test 3 — Invalid Email
Expected:
Enter a valid email.Test 4 — Valid Form
Expected:
Updating...followed by:
Profile updated successfully.Test 5 — Pending State
During submission:
Button disabled = trueTest 6 — API Failure
Stop your backend and submit.
Expected:
Unable to connect to the server.Testing each state helps you understand exactly what useActionState is doing.
Final Flow
Our complete flow is:
User
↓
Form
↓
formAction
↓
useActionState
↓
Async Action
↓
API / Server
↓
Return State
↓
React Updates UIDuring processing:
isPending = trueAfter processing:
isPending = falseThe returned Action value becomes:
stateThis gives us a clean way to manage the complete Action lifecycle.
Before and After
Traditional form handling often looks like:
useState
↓
loading
useState
↓
error
useState
↓
success
handleSubmit
↓
try/catch/finallyWith React 19:
useActionState
↓
state
action
pendingIt does not mean the traditional approach is wrong.
Instead, React 19 gives developers another abstraction specifically designed around Actions.
Conclusion
React 19's useActionState is particularly useful when an interface needs to perform an asynchronous mutation and display the result of that operation.
The core API is simple:
const [
state,
action,
isPending,
] = useActionState(
actionFunction,
initialState
);From this, we get:
state
→ result of the Action
action
→ function used to trigger it
isPending
→ whether it is processingFor form-heavy applications, this can reduce repetitive code around:
Loading states
Validation messages
Success messages
Errors
Async requestsMore importantly, it encourages us to think about form submissions as Actions rather than manually coordinating many unrelated pieces of state.
If you are learning React 19, I recommend building the example in this article yourself.
Try:
Successful submission
Validation failure
Network failure
Slow API
Multiple submissionsOnce those cases make sense, the purpose of useActionState becomes much clearer.