DEV Community

Cover image for React 19 Deep Dive — Forms & Actions with `useFormState`, `useFormStatus`, and `useOptimistic`
Ali Aslam
Ali Aslam

Posted on

React 19 Deep Dive — Forms & Actions with `useFormState`, `useFormStatus`, and `useOptimistic`

💡 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

useFormState

useFormStatus

useOptimistic

Patterns That Combine These Hooks

Pitfalls & Gotchas

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>
);
}
Enter fullscreen mode Exit fullscreen mode

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>
);
}
Enter fullscreen mode Exit fullscreen mode

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, and useOptimistic 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>
);
}
Enter fullscreen mode Exit fullscreen mode

How This Works

  1. The <form> is submitted (either by clicking the button or pressing Enter).
  2. Instead of running a JavaScript handler, React sends the form data to the subscribeAction function.
  3. The formData object contains all your field values (formData.get("name") etc.).
  4. The function can run server logic (database write, API call, validation).
  5. 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>
);
}
Enter fullscreen mode Exit fullscreen mode

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 in formData.
  • 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);
Enter fullscreen mode Exit fullscreen mode
  • state → whatever your Action returns
  • formAction → the function you pass to your <form action={...}>
  • initialState → the starting point for state 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>
);
}
Enter fullscreen mode Exit fullscreen mode

Why This Is So Nice

  • No manual setState calls — The Action’s return value becomes the new state 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();
Enter fullscreen mode Exit fullscreen mode

…and then inside an onSubmit handler:

setData(await doSomething(formData));
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode
  • pendingtrue 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>
);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Instantly update the UI as if the action succeeded
  2. 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);
Enter fullscreen mode Exit fullscreen mode
  • 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>
);
}
Enter fullscreen mode Exit fullscreen mode

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>
);
}
Enter fullscreen mode Exit fullscreen mode

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>
);
}
Enter fullscreen mode Exit fullscreen mode

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>
);
}
Enter fullscreen mode Exit fullscreen mode

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>
);
}
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

…this field’s value won’t appear in formData.

Fix:

<input name="email" placeholder="Email" />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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" };
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

  1. 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)