React 19 useActionState: Practical Examples That Replace Your Old Form Code
React 19 introduced useActionState, a hook that fundamentally changes how you handle form submissions and async actions. If you're still managing loading states, error states, and submitted values with separate useState calls, this hook will cut your boilerplate in half.
What useActionState Does
useActionState wraps an async action function and returns three values:
- state — whatever your action returns
- dispatch — a function to trigger the action
-
isPending —
truewhile the action is in flight
const [state, dispatch, isPending] = useActionState(
async (prevState, formData) => {
// Your async logic here
return { success: true, message: "Done" };
},
initialState
);
This replaces the React 18 pattern of juggling useState for loading, errors, and results — plus manual e.preventDefault() and try/catch blocks.
Example 1: Simple Form with Validation
Here's a common pattern: a form that validates input, shows loading state, and displays success or error messages.
import { useActionState } from "react";
async function submitForm(prevState, formData) {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
const email = formData.get("email");
if (!email || !email.includes("@")) {
return { success: false, message: "Please enter a valid email." };
}
return { success: true, message: "Submitted successfully!" };
}
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitForm, {
success: null,
message: "",
});
return (
<form action={formAction}>
<input type="text" name="name" placeholder="Name" />
<input type="email" name="email" placeholder="Email" />
<button disabled={isPending}>
{isPending ? "Submitting..." : "Submit"}
</button>
{state.message && (
<p className={state.success ? "success" : "error"}>
{state.message}
</p>
)}
</form>
);
}
Key observations:
- No
useStateforisPending,error, orsuccess— all handled by the hook - No
e.preventDefault()— theactionattribute handles it - The action receives
formDatadirectly via theFormDataAPI - Validation and error handling happen inside the action function
Example 2: Counter with Async Increment
For non-form actions, you can call the dispatch function imperatively with startTransition:
import { useActionState, startTransition } from "react";
import { addToCart } from "./api";
function CartButton() {
const [count, dispatch, isPending] = useActionState(
async (prevCount) => {
return await addToCart(prevCount);
},
0
);
function handleClick() {
startTransition(() => {
dispatch();
});
}
return (
<div>
<span>Items: {count}</span>
<button onClick={handleClick} disabled={isPending}>
Add to Cart{isPending ? " 🌀" : ""}
</button>
</div>
);
}
This pattern is useful for buttons that trigger side effects without a form — like adding items to a cart, liking a post, or following a user.
Example 3: Multiple Independent Actions in One Component
You can use multiple useActionState calls in the same component, each managing its own state:
import { useActionState } from "react";
import { toggleLike, toggleFollow } from "./actions";
function SocialActions() {
const [liked, likeAction] = useActionState(toggleLike, false);
const [following, followAction] = useActionState(toggleFollow, false);
return (
<div>
<form action={likeAction}>
<button>{liked ? "❤️ Liked" : "♡ Like"}</button>
</form>
<form action={followAction}>
<button>{following ? "✔ Following" : "+ Follow"}</button>
</form>
</div>
);
}
Each action is completely independent — toggling "like" doesn't affect the "follow" state. This is much cleaner than managing multiple useState pairs with separate loading flags.
Example 4: With useFormStatus for Child Components
If you need pending state in a child component (like a loading spinner inside a button), use useFormStatus — but it must be in a child of the form, not the form itself:
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</button>
);
}
function ProfileForm() {
const [state, formAction] = useActionState(
async (prev, formData) => {
await saveProfile(formData);
return { saved: true };
},
{ saved: false }
);
return (
<form action={formAction}>
<input name="name" defaultValue="John" />
<SubmitButton /> {/* useFormStatus works here */}
</form>
);
}
Example 5: Error-Only State Pattern
For simple cases where you only care about errors (not success state), use a string as the initial state:
import { useActionState } from "react";
function NameForm() {
const [error, submitAction, isPending] = useActionState(
async (prev, formData) => {
const response = await fetch("/api/update-name", {
method: "POST",
body: JSON.stringify({ name: formData.get("name") }),
});
if (!response.ok) {
const { message } = await response.json();
return message; // Return error string
}
return ""; // Empty string = no error
},
"" // Initial state: no error
);
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit" disabled={isPending}>Save</button>
{isPending && <Spinner />}
{error && <p className="error">{error}</p>}
</form>
);
}
React 18 vs React 19: The Difference
React 18 (old way):
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);
const [result, setResult] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setIsPending(true);
setError(null);
try {
const res = await api.submit(formData);
setResult(res);
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
}
<form onSubmit={handleSubmit}>
React 19 (new way):
const [state, formAction, isPending] = useActionState(
async (prev, formData) => {
try {
const res = await api.submit(formData);
return { success: true, data: res };
} catch (err) {
return { success: false, error: err.message };
}
},
{ success: null, data: null, error: null }
);
<form action={formAction}>
The React 19 version eliminates:
- Three separate
useStatecalls - Manual
e.preventDefault() - Manual
setIsPending(true/false) - Try/catch/finally boilerplate
When to Use useActionState
- Form submissions — any form that sends data to a server
- Async actions — like/dislike, follow/unfollow, add to cart
-
Optimistic updates — combine with
useOptimisticfor instant UI feedback - Server Functions — works seamlessly with React Server Components
When NOT to Use It
- Simple synchronous state updates (just use
useState) - Complex multi-step wizards (consider a state machine or form library)
- When you need fine-grained control over every state transition
Key Takeaways
-
useActionStateconsolidates loading, error, and result state into one hook - Works with both form actions (
<form action={formAction}>) and imperative calls (dispatch()) - The action function receives
formDatavia the standardFormDataAPI - Combine with
useFormStatusin child components for pending state - Multiple
useActionStatecalls in one component are independent
The hook is stable in React 19 and ready for production. If you're still writing React 18-style form handlers, it's time to simplify.
Sources:
- React Official Docs
- freeCodeCamp: React 19 Actions
- LogRocket: useActionState Guide
- DEV Community: React 19 useActionState
- Plain English: Cleaner Action State
What's your experience with useActionState? Are you still on React 18 form patterns, or have you made the switch?
Top comments (0)