💡 Forms have been with us since the dawn of the web… but React 19 just rewired how we think about them.
Instead of juggling onSubmit
handlers, manual state, and fetch
calls, we now have a built-in system for doing it all — faster, cleaner, and with fewer bugs.
This guide takes you from the basics to pro-level patterns with Actions, useFormState
, useFormStatus
, and useOptimistic
.
Before we dive in, here’s your roadmap 👇
Table of Contents
Actions in React 19
- The Simplest Example
- How This Works
- Benefits Over Old-School Handling
- A Slightly More Realistic Example
- Common Mistakes with Actions
useFormState
- Basic Syntax
- Example: Login Form with Error Handling
- Why This Is So Nice
- A Mental Model for Beginners
useFormStatus
- Basic Syntax
- Example: Disable Button While Submitting
- Why It’s Scoped
- Common Uses
useFormState
vsuseFormStatus
useOptimistic
- The Mental Model
- Basic Syntax
- Example: Optimistic Likes
- Example: Adding a Comment Optimistically
- When to Use
Patterns That Combine These Hooks
- Pattern 1 — Validation + Pending State
- Pattern 2 — Instant Feedback + Real Confirmation
- Pattern 3 — Full Power Trio
Pitfalls & Gotchas
- 1. Forgetting to Give Inputs a
name
- 2. Expecting
useFormStatus
to Work Outside Its Form - 3. Returning the Wrong Data Shape from Actions
- 4. Forgetting to Handle Async Errors in
useOptimistic
- 5. Mixing Manual State Updates with
useFormState
- 6. Forgetting Accessibility
Wrap-Up
Why React 19 Changed Form Handling
Forms seem simple… until you’ve actually built a few.
At first, it’s just an <input>
and a <button>
— no problem.
But then you need:
- Client-side validation
- Server validation
- Loading indicators
- Preventing double submissions
- Displaying errors in the right place
- Resetting fields after success
Before you know it, your “simple” form is juggling 3+ pieces of state, a fetch
call, and multiple useEffect
hooks. 😬
Before React 19
Here’s a bare-bones login form before Actions existed:
function LoginForm() {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.target);
const response = await fetch("/api/login", {
method: "POST",
body: formData
});
if (!response.ok) {
setError("Login failed");
}
setLoading(false);
}
return (
<form onSubmit={handleSubmit}>
<input name="email" placeholder="Email" />
<input name="password" placeholder="Password" type="password" />
<button disabled={loading}>Log In</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</form>
);
}
It works — but you’re manually managing:
- Loading state
- Error state
- Form resetting
- Preventing default browser behavior
And you’re splitting logic between:
- The form UI (
<form>
,<input>
,<button>
) - The JS handler (
handleSubmit
) - A separate API endpoint (
/api/login
)
The React 19 Way
React 19 introduced Actions — a way to submit forms directly to a function.
No fetch
boilerplate. No e.preventDefault()
. No juggling state by hand.
Here’s the same login form using Actions:
export async function loginAction(formData) {
const email = formData.get("email");
const password = formData.get("password");
if (email === "admin@example.com" && password === "password") {
return { success: true };
}
return { success: false, error: "Invalid credentials" };
}
export default function LoginForm() {
return (
<form action={loginAction}>
<input name="email" placeholder="Email" />
<input name="password" placeholder="Password" type="password" />
<button>Log In</button>
</form>
);
}
What’s different now?
- The
loginAction
function is server code that runs when the form submits. - The
<form>
tag directly references the action — no JS event handler needed. - You can return data from the action to use in your UI without setting up an API route.
In short: Actions cut out the middleman between your form and your server logic.
For React to know that
loginAction
belongs and executes on the server, it must include the special directive:async function loginAction(formData) { "use server"; // your logic here }
That line lets React compile the function as a server-side endpoint. Without
"use server"
, it would instead bundle the function into the client—breaking the workflow and causing hydration or security issues.
The Bigger Picture
This isn’t just syntactic sugar.
Actions are integrated with React’s rendering model:
- They automatically trigger re-renders with updated data.
- They play nicely with streaming server rendering.
- They work with built-in hooks like
useFormState
,useFormStatus
, anduseOptimistic
to give you state management for free.
Let's break down what Actions are under the hood and how to start using them effectively.
Actions in React 19
Actions are functions that run when your form is submitted.
Instead of handling the event in JavaScript and calling an API manually, you just tell the form:
“When submitted, run this function.”
It’s almost like going back to classic HTML form handling — but with React’s brains behind it.
The Simplest Example
export async function subscribeAction(formData) {
const email = formData.get("email");
if (!email.includes("@")) {
return { success: false, error: "Invalid email" };
}
// Simulate API call
await new Promise((r) => setTimeout(r, 500));
return { success: true };
}
export default function NewsletterForm() {
return (
<form action={subscribeAction}>
<input name="email" placeholder="Email" />
<button>Subscribe</button>
</form>
);
}
How This Works
- The
<form>
is submitted (either by clicking the button or pressing Enter). - Instead of running a JavaScript handler, React sends the form data to the
subscribeAction
function. - The
formData
object contains all your field values (formData.get("name")
etc.). - The function can run server logic (database write, API call, validation).
- Whatever the function returns is available to your component on the next render.
Benefits Over Old-School Handling
- No manual fetch calls — React handles sending the data to the server action.
-
Automatic form serialization — You get a
FormData
object instantly. - Works with progressive enhancement — Without JavaScript, the form still works like a normal HTML form.
A Slightly More Realistic Example
Let’s say you want to show a success or error message:
export async function loginAction(formData) {
const email = formData.get("email");
const password = formData.get("password");
await new Promise((r) => setTimeout(r, 1000)); // Simulated delay
if (email === "admin@example.com" && password === "password") {
return { success: true };
}
return { success: false, error: "Invalid credentials" };
}
export default function LoginForm() {
const [state, formAction] = React.useActionState(loginAction, {
success: null,
error: null
});
return (
<form action={formAction}>
<input name="email" placeholder="Email" />
<input name="password" placeholder="Password" type="password" />
<button>Log In</button>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{state.success && <p style={{ color: "green" }}>Welcome back!</p>}
</form>
);
}
Here we’re introducing useActionState
(part of the Actions API) to store and react to the action’s result — more on that in the next sections.
Common Mistakes with Actions
-
Forgetting to name inputs — If a field has no
name
, it won’t appear informData
. -
Returning raw values instead of objects — Returning a plain string makes your UI harder to manage. Wrap data in objects (
{ success: true, ... }
) so you can extend them later. -
Not handling async — Actions can be async, but you must use
await
where needed to avoid race conditions.
useFormState
useFormState
is like a built-in state manager for your form.
It works hand-in-hand with Actions to:
- Keep track of what happened after the form was submitted
- Provide the current state (errors, success messages, updated values)
- Automatically reset when needed
Think of it as useState
… but React updates it for you when your Action runs.
Basic Syntax
const [state, formAction] = useFormState(actionFunction, initialState);
-
state
→ whatever your Action returns -
formAction
→ the function you pass to your<form action={...}>
-
initialState
→ the starting point forstate
before submission
Example: Login Form with Error Handling
import { useFormState } from "react-dom";
async function loginAction(prevState, formData) {
const email = formData.get("email");
const password = formData.get("password");
await new Promise((r) => setTimeout(r, 1000)); // Simulate delay
if (email === "admin@example.com" && password === "password") {
return { success: true, error: null };
}
return { success: false, error: "Invalid credentials" };
}
export default function LoginForm() {
const [state, formAction] = useFormState(loginAction, {
success: false,
error: null
});
return (
<form action={formAction}>
<input name="email" placeholder="Email" />
<input name="password" placeholder="Password" type="password" />
<button>Log In</button>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{state.success && <p style={{ color: "green" }}>Welcome back!</p>}
</form>
);
}
Why This Is So Nice
-
No manual
setState
calls — The Action’s return value becomes the newstate
automatically. - Error & success handling built-in — You can display messages without extra logic.
- Keeps everything together — The UI, state, and server logic are connected.
A Mental Model for Beginners
If you’ve ever done:
const [data, setData] = useState();
…and then inside an onSubmit
handler:
setData(await doSomething(formData));
That’s exactly what useFormState
does — React just wires it up for you so you don’t have to.
useFormStatus
If useFormState
tells you what happened,
useFormStatus
tells you what’s happening right now.
It’s perfect for:
- Showing a loading spinner while the form is submitting
- Disabling buttons to prevent double clicks
- Giving users instant “in progress” feedback
Basic Syntax
const { pending, data, method, action } = useFormStatus();
-
pending
→true
while submission is in progress -
data
→ the form data being submitted -
method
→"POST"
,"GET"
, etc. -
action
→ the URL or function the form is submitting to
Example: Disable Button While Submitting
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? "Logging in..." : "Log In"}
</button>
);
}
export default function LoginForm({ formAction }) {
return (
<form action={formAction}>
<input name="email" placeholder="Email" />
<input name="password" placeholder="Password" type="password" />
<SubmitButton />
</form>
);
}
Why It’s Scoped
useFormStatus
only works inside the form it’s tracking.
That’s why in the example above, SubmitButton
is a child of the <form>
element — otherwise pending
would always be false
.
Common Uses
- Disabling the submit button during submission
- Showing a “Saving…” label or spinner
- Changing the button color while pending
- Preventing accidental resubmission
useFormState
vs useFormStatus
Think of them like this:
-
useFormStatus
→ “What’s happening right now?” (pending, loading) -
useFormState
→ “What happened after submission?” (success, error, updated data)
useOptimistic
Most apps wait for the server to confirm something before updating the UI.
But here’s the problem:
- Server round-trips can take hundreds of milliseconds (or more)
- In that time, the UI feels laggy — users click and wonder, “Did that work?”
useOptimistic
flips this on its head.
Instead of waiting for the server, you:
- Instantly update the UI as if the action succeeded
- Revert or adjust later if the server disagrees
The Mental Model
Imagine you’re liking a tweet.
You tap the heart, and it immediately turns red — even though your phone hasn’t talked to Twitter’s servers yet.
That’s optimistic UI.
It’s about trusting the user’s intent first, then syncing up later.
Basic Syntax
const [optimisticState, addOptimistic] = useOptimistic(currentState, updateFn);
-
currentState
→ your real state -
updateFn
→ a function that predicts the new state given the old state and some action data -
optimisticState
→ what the UI should look like right now -
addOptimistic
→ function to trigger an optimistic update
Example: Optimistic Likes
import { useOptimistic } from "react";
export default function LikeButton({ initialLikes }) {
const [likes, addOptimistic] = useOptimistic(initialLikes, (prevLikes) => prevLikes + 1);
async function handleClick() {
addOptimistic();
try {
await fetch("/api/like", { method: "POST" });
} catch {
// Rollback on error
addOptimistic((prevLikes) => prevLikes - 1);
}
}
return (
<button onClick={handleClick}>
❤️ {likes}
</button>
);
}
Example: Adding a Comment Optimistically
import { useOptimistic } from "react";
export default function CommentList({ initialComments }) {
const [comments, addOptimistic] = useOptimistic(initialComments, (prev, newComment) => [
...prev,
{ id: Date.now(), text: newComment }
]);
async function handleAddComment(text) {
addOptimistic(text);
try {
await fetch("/api/comment", {
method: "POST",
body: JSON.stringify({ text })
});
} catch {
// Could remove comment from UI if needed
}
}
return (
<div>
{comments.map((c) => <p key={c.id}>{c.text}</p>)}
<button onClick={() => handleAddComment("New comment!")}>Add Comment</button>
</div>
);
}
When to Use
- Feeds (likes, comments, shares)
- Chat messages
- E-commerce carts
- Any UI where speed is more important than 100% immediate accuracy
Patterns That Combine These Hooks
Each of these hooks is powerful on its own…
…but the real magic happens when you combine them.
That’s when your forms become fast, reactive, and a joy to use.
Pattern 1 — Validation + Pending State
Combine useFormState
and useFormStatus
to:
- Track validation results after submission
- Disable the form while it’s processing
import { useFormState, useFormStatus } from "react-dom";
async function signupAction(prevState, formData) {
const email = formData.get("email");
await new Promise((r) => setTimeout(r, 1000));
if (!email.includes("@")) {
return { success: false, error: "Invalid email" };
}
return { success: true, error: null };
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? "Signing up..." : "Sign Up"}
</button>
);
}
export default function SignupForm() {
const [state, formAction] = useFormState(signupAction, { success: false, error: null });
return (
<form action={formAction}>
<input name="email" placeholder="Email" />
<SubmitButton />
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{state.success && <p style={{ color: "green" }}>Welcome aboard!</p>}
</form>
);
}
Pattern 2 — Instant Feedback + Real Confirmation
Combine useFormState
with useOptimistic
for UIs where speed is key.
Example: Adding items to a cart
import { useFormState, useOptimistic } from "react";
async function addToCartAction(prevState, formData) {
const product = formData.get("product");
await new Promise((r) => setTimeout(r, 500));
return [...prevState, product];
}
export default function Cart() {
const [cart, formAction] = useFormState(addToCartAction, []);
const [optimisticCart, addOptimistic] = useOptimistic(cart, (prev, product) => [...prev, product]);
function handleAdd(product) {
addOptimistic(product);
formAction(new FormData(Object.assign(document.createElement("form"), { product })));
}
return (
<div>
<ul>
{optimisticCart.map((item, i) => <li key={i}>{item}</li>)}
</ul>
<button onClick={() => handleAdd("Laptop")}>Add Laptop</button>
</div>
);
}
Pattern 3 — Full Power Trio
-
useFormState
→ Manages final, confirmed data -
useFormStatus
→ Controls pending/loading state -
useOptimistic
→ Makes the UI feel instant
Example: A comment form that:
- Shows comments instantly
- Disables the button while pending
- Displays errors if submission fails
import { useFormState, useFormStatus, useOptimistic } from "react";
async function commentAction(prevComments, formData) {
const text = formData.get("text");
await new Promise((r) => setTimeout(r, 800));
if (!text.trim()) {
return prevComments; // No change if empty
}
return [...prevComments, { id: Date.now(), text }];
}
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Posting..." : "Post"}</button>;
}
export default function CommentSection() {
const [comments, formAction] = useFormState(commentAction, []);
const [optimisticComments, addOptimistic] = useOptimistic(comments, (prev, text) => [
...prev,
{ id: Date.now(), text }
]);
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const text = formData.get("text");
addOptimistic(text);
formAction(formData);
e.target.reset();
}
return (
<div>
<ul>
{optimisticComments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
<form onSubmit={handleSubmit} action={formAction}>
<input name="text" placeholder="Add a comment" />
<SubmitButton />
</form>
</div>
);
}
Pitfalls & Gotchas
Even though useFormState
, useFormStatus
, and useOptimistic
make forms much easier in React 19, there are still a few places where things can go wrong if you’re not careful.
1. Forgetting to Give Inputs a name
Without a name
attribute:
<input placeholder="Email" />
…this field’s value won’t appear in formData
.
✅ Fix:
<input name="email" placeholder="Email" />
2. Expecting useFormStatus
to Work Outside Its Form
useFormStatus
is scoped to the nearest parent <form>
.
If you put it somewhere else in the tree, pending
will always be false
.
✅ Fix:
Put your loading UI inside the form:
<form action={formAction}>
<SubmitButton /> {/* This sees the correct pending state */}
</form>
3. Returning the Wrong Data Shape from Actions
If your Action returns inconsistent data shapes (sometimes a string, sometimes an object), you’ll have messy conditional logic.
✅ Fix:
Always return predictable objects:
return { success: false, error: "Invalid email" };
4. Forgetting to Handle Async Errors in useOptimistic
If your optimistic update fails and you never roll back, your UI will show incorrect data.
✅ Fix:
try {
await serverCall();
} catch {
rollback();
}
5. Mixing Manual State Updates with useFormState
If you call setState
manually and rely on useFormState
, they can conflict.
✅ Fix:
Let useFormState
be the single source of truth for that piece of data.
If you need extra local state, keep it separate.
6. Forgetting Accessibility
Disabling a button while pending is good,
but also let assistive tech know what’s happening.
✅ Fix:
<button aria-busy={pending} disabled={pending}>
{pending ? "Submitting..." : "Submit"}
</button>
Wrap-Up
React 19’s new form APIs — Actions, useFormState
, useFormStatus
, and useOptimistic
— aren’t just shiny new toys.
They represent a shift back toward declarative, HTML-first thinking, but with all the benefits of modern React.
The Big Picture Mental Model
- Actions — “Here’s what to do when the form is submitted.”
- Server logic, validation, database writes — all in one place.
useFormState
— “Here’s the final result after submission.”Perfect for showing success/error messages and updated data.
useFormStatus
— “Here’s what’s happening right now.”Great for disabling inputs, showing spinners, and preventing double submits.
useOptimistic
— “Here’s what the user should see instantly, even before the server replies.”Keeps the UI feeling lightning-fast and responsive.
Why This Matters
Before React 19:
- You’d write
onSubmit
handlers - Serialize
FormData
manually - Call
fetch
yourself - Manage loading, errors, and optimistic updates with a bunch of
useState
calls
Now:
- You wire up an Action
- Use the right hook for the right part of the process
- Let React handle the orchestration
Your Next Step
Next time you’re building a form:
- Start with Actions for server work
- Use
useFormState
for results - Use
useFormStatus
for in-progress UI - Sprinkle in
useOptimistic
for speed
And you’ll have a form that’s:
- Faster to build
- Easier to read
- More resilient
So the next time a teammate asks:
“How did you make that form so fast?”
You can smile and say:
“It’s a React 19 thing — let me show you
useOptimistic
.”
Up Next:
React 19 Server Components Deep Dive — What They Are, How They Work, and When to Use Them →
Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Want video demos? Subscribe on YouTube: @LearnAwesome
Top comments (0)