React 19 Actions in Practice: Implementing Forms and Optimistic UI Without Libraries Using useActionState, useFormStatus, and useOptimistic
In the React 18 era, building a proper form meant declaring separate isLoading, error, and success states, wrapping the submit function in a try/catch, and pulling in Context just to disable a button without prop drilling. Honestly, I suspect quite a few developers fled to React Hook Form or Formik out of frustration with that boilerplate. I know I did.
With the stable release of React 19 in December 2024, managing the state of async actions in a single hook with useActionState, having child components subscribe directly to form state with useFormStatus, and updating the UI instantly without waiting for a server response with useOptimistic all became possible using React core alone. As of mid-2026, major starters like Next.js, TanStack Start, and Vite have adopted React 19 as their default, so it's worth learning these patterns now so that encountering useActionState code in a new codebase doesn't feel unfamiliar.
This post covers how the three hooks work and how to combine them, along with the pitfalls you'll run into in real code. It addresses both the Server Actions environment (Next.js 15) and a pure client-side environment, so it's useful regardless of your framework choice.
Core Concepts
Actions — You can now pass a function to the action prop of form
Before React 19, <form onSubmit={handleSubmit}> was all you had. Starting with React 19, you can pass an async function directly to the action or formAction prop, like <form action={fn}>. React then processes the form submission inside a transition and automatically resets the form values on success.
The important change is how startTransition behaves differently between React 18 and 19. In React 18, startTransition did not support async callbacks. Code after the first await ran outside the transition scope, so isPending would immediately flip to false at the await point. React 19 officially supports async transitions, keeping isPending at true until the async operation fully completes.
sequenceDiagram
participant User
participant Form
participant React
participant Server
User->>Form: Click Submit
Form->>React: Call action function
React->>React: isPending = true starts
React->>Server: Send API request
Note over React: React 18 lacks async support<br/>so isPending = false at this point
Note over React: React 19 keeps<br/>isPending = true until async completes
Server-->>React: Return response
React->>React: Update state, reset form
React->>React: isPending = false
React-->>Form: Re-render with new stateuseActionState — loading, error, and success in a single hook
const [state, dispatch, isPending] = useActionState(actionFn, initialState);actionFn takes the shape (prevState, formData) => Promise<newState>. The first rule of this hook is that you must return the same shape of state from every code path. Whether the result is a success, failure, or validation error, the type must be the same for both TypeScript and React to handle it without confusion.
I initially returned { success: true } on success and { error: '...' } on failure — different shapes — and experienced both type errors and UI flickering at the same time. It's safer to fix the initial state shape as a schema and fill that structure in every branch.
// Return the same shape from every code path
async function signupAction(
prevState: { error: string | null; success: boolean },
formData: FormData
) {
const name = formData.get("name") as string;
if (!name) {
return { error: "Please enter your name.", success: false };
}
try {
await saveUser(name);
return { error: null, success: true };
} catch {
return { error: "A server error occurred.", success: false };
}
}isPending is the third return value — a feature absent from the experimental useFormState in React 18. With this value, you can disable a button during submission or show a spinner without any additional state.
useFormStatus — Child components subscribe to form state without prop drilling
const { pending, data, method, action } = useFormStatus();It takes no arguments, and must be called inside a child component of a <form> element. It doesn't have to be in the same file; the parent <form> just needs to exist somewhere in the tree. Calling it outside a <form> will cause pending to always be false — this is a pretty common pitfall.
Of the four return values, pending is the one most used in practice. data is the FormData currently being submitted, method is the HTTP method ("get" or "post"), and action is a reference to the action function passed to the form. data is useful for distinguishing which form is submitting when multiple forms share a layout, or for immediately using submission data in an optimistic update.
// Placing this inside any <form> automatically subscribes it to the form state
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Processing..." : label}
</button>
);
}useOptimistic — Update the UI immediately without waiting for a server response
const [optimisticState, addOptimistic] = useOptimistic(
actualState,
(currentState, optimisticValue) => newState
);The second argument is the key. When you call addOptimistic(value), that value is passed as optimisticValue to this function, which immediately computes the new state. Once the transition completes, optimisticState automatically resolves back to the actual actualState.
There is one important prerequisite. addOptimistic must be called inside a transition context. The examples below work because passing a function to <form action={fn}> causes React to automatically start a transition. Calling it inside a plain onClick handler will produce a warning and won't behave as expected. If you need to use onClick, wrap it with startTransition.
One more thing: automatic rollback is not provided. If the API fails, the transition ends with actualState unchanged, so the optimistically displayed content quietly reverts to its previous state. You'll need separate handling to notify the user that something went wrong.
flowchart TD
A[User action triggered] --> B[Call addOptimistic]
B --> C[optimisticState updated immediately]
C --> D[Send server API request]
D --> E{Server response}
E -->|Success| F[Update actualState]
E -->|Failure| G[actualState unchanged]
F --> H[optimisticState auto-resolves to actualState]
G --> I[optimisticState auto-reverts to actualState]
I --> J[Show error notification]
H --> K[Done]
J --> KPractical Application
Basic Form Submission — Signup Form
Let's look at the three hooks cooperating for the first time. useActionState manages state, and useFormStatus delivers the loading state to the button.
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
type FormState = {
error: string | null;
success: boolean;
};
const initialState: FormState = { error: null, success: false };
async function signupAction(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { error: "Please fill in all fields.", success: false };
}
try {
await registerUser({ name, email });
return { error: null, success: true };
} catch {
return { error: "An error occurred during registration.", success: false };
}
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Registering..." : "Sign Up"}
</button>
);
}
export function SignupForm() {
const [state, dispatch] = useActionState(signupAction, initialState);
if (state.success) {
return <p>Registration complete!</p>;
}
return (
<form action={dispatch}>
<input name="name" type="text" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
<SubmitButton />
</form>
);
}There's a reason SubmitButton is extracted into a separate component. Because useFormStatus subscribes to the parent <form> of the component where it's rendered, calling it directly inside SignupForm won't work. This separation is not optional — it's required.
Like/Unlike Button — Immediate feedback with useOptimistic
import { useState } from "react";
import { useOptimistic } from "react";
async function toggleLike(
postId: string
): Promise<{ count: number; isLiked: boolean }> {
const res = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
return res.json();
}
export function LikeButton({
postId,
initialLikes,
initialIsLiked,
}: {
postId: string;
initialLikes: number;
initialIsLiked: boolean;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(currentLikes: number, delta: number) => currentLikes + delta
);
// <form action={...}> opens a transition, which makes the addOptimisticLike call work
async function handleLike() {
const delta = isLiked ? -1 : 1;
addOptimisticLike(delta);
try {
const updated = await toggleLike(postId);
setLikes(updated.count);
setIsLiked(updated.isLiked);
} catch {
// actualState(likes) is unchanged, so optimisticLikes automatically reverts
// In a real app, add user feedback such as an error toast
}
}
return (
<form action={handleLike}>
<button type="submit">
{optimisticLikes} {isLiked ? "❤️" : "🤍"}
</button>
</form>
);
}In addOptimisticLike(delta), delta is passed into the second argument function (currentLikes, delta) => currentLikes + delta and computed immediately. +1 or -1 is passed depending on the like state, and once the server responds, the value is confirmed with the actual count.
Server Actions + useActionState — Creating a Post in Next.js 15
In a Next.js environment, you can connect a server function directly to useActionState. A major advantage is Progressive Enhancement support: form submission works via default HTML behavior even before JS has hydrated.
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
type PostFormState = {
error: string | null;
success: boolean;
};
export async function createPostAction(
prevState: PostFormState,
formData: FormData
): Promise<PostFormState> {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
if (!title?.trim()) {
return { error: "Please enter a title.", success: false };
}
try {
await db.post.create({ data: { title, content } });
revalidatePath("/posts");
return { error: null, success: true };
} catch {
return { error: "Failed to save the post.", success: false };
}
}// app/posts/PostForm.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createPostAction } from "./actions";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Publish"}
</button>
);
}
export default function PostForm() {
const [state, action] = useActionState(createPostAction, {
error: null,
success: false,
});
return (
<form action={action}>
<input name="title" type="text" placeholder="Title" />
<textarea name="content" placeholder="Content" />
{state.error && <p>{state.error}</p>}
{state.success && <p>Post published!</p>}
<SubmitButton />
</form>
);
}Comment List — Combining useOptimistic + useActionState
Using the two hooks together lets you handle "immediate screen update + server state sync + rollback on failure + error display" all at once.
State type design matters here. Rather than using Comment[] directly as the state, it's wrapped as { comments: Comment[]; error: string | null }. This honors the "return the same shape from every code path" principle emphasized in the useActionState section, while also creating a channel to deliver error messages all the way to the render.
import { useActionState, useOptimistic } from "react";
import { useFormStatus } from "react-dom";
type Comment = { id: string; text: string; author: string };
type CommentState = { comments: Comment[]; error: string | null };
async function postComment(text: string): Promise<Comment> {
const res = await fetch("/api/comments", {
method: "POST",
body: JSON.stringify({ text }),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("Failed to save comment");
return res.json();
}
async function addCommentAction(
prevState: CommentState,
formData: FormData
): Promise<CommentState> {
const text = formData.get("text") as string;
try {
const newComment = await postComment(text);
return { comments: [...prevState.comments, newComment], error: null };
} catch {
return { comments: prevState.comments, error: "Failed to post comment." };
}
}
function CommentSubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Posting..." : "Post Comment"}
</button>
);
}
export function CommentSection({
initialComments,
}: {
initialComments: Comment[];
}) {
const [state, dispatch] = useActionState(addCommentAction, {
comments: initialComments,
error: null,
});
const [optimisticComments, addOptimistic] = useOptimistic(
state.comments,
(current: Comment[], newComment: Comment) => [...current, newComment]
);
// Connecting directly to <form action={dispatch}> leaves no opportunity to call addOptimistic.
// This wrapper runs inside the transition opened by <form action={...}>,
// so the addOptimistic call is valid, and dispatch(formData) triggers the actual action.
// Temporary comments (temp-*) persist until addCommentAction completes and state.comments is updated.
async function handleSubmit(formData: FormData) {
const text = formData.get("text") as string;
addOptimistic({ id: `temp-${Date.now()}`, text, author: "Me" });
dispatch(formData);
}
return (
<div>
<ul>
{optimisticComments.map((c) => (
<li
key={c.id}
style={{ opacity: c.id.startsWith("temp-") ? 0.6 : 1 }}
>
<strong>{c.author}</strong>: {c.text}
</li>
))}
</ul>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
<form action={handleSubmit}>
<input name="text" type="text" placeholder="Enter a comment" />
<CommentSubmitButton />
</form>
</div>
);
}Giving temporary comments opacity: 0.6 is a small touch that lets users visually recognize that an item "hasn't been saved to the server yet." On failure, state.error is populated and state.comments is unchanged, so the temporary comment disappears from the list and an error message is shown.
Pros and Cons
What you gain
| Item | Description |
|---|---|
| Reduced boilerplate | Consolidates isLoading, error, and success state into a single useActionState |
| Eliminates prop drilling | useFormStatus lets children subscribe directly to parent form state |
| Instant UX | useOptimistic updates the UI immediately regardless of network latency |
| Server integration | Progressive Enhancement support when combined with Server Functions |
| No external dependencies | Simple forms are self-contained with React core alone |
| Automatic form reset | The form resets automatically after a successful submission when using the action prop |
Limitations and Caveats
| Item | Description |
|---|---|
useFormStatus placement constraint |
Must be called inside a child component of a parent <form> |
| Transition context required | addOptimistic only works inside a form action or startTransition |
| Manual error handling | useOptimistic provides no automatic rollback; failures must be handled manually |
| Consistent state shape | useActionState actions must return the same type from every code path |
| Optimistic update suitability | Not suitable for irreversible operations like payments or deletion, or high-failure-rate scenarios |
| Complex forms | Multi-step forms, real-time validation, and nested fields are more efficient with RHF / TanStack Form |
| Server Actions dependency | Requires framework support (e.g., Next.js); replace with a plain async function in client-only environments |
When deciding whether to use native Actions for a form
flowchart TD
A[Start developing a new form] --> B{Complexity}
B -->|Simple 3–5 fields| C[Consider native Actions]
B -->|Complex multi-step form| D[RHF or TanStack Form]
C --> E{Real-time validation needed?}
E -->|No| F[useActionState + useFormStatus]
E -->|Yes| G[RHF combined with useActionState]
F --> H{Optimistic UI needed?}
H -->|Yes| I[Add useOptimistic]
H -->|No| J[Done]
I --> J
G --> J
D --> JCommon Mistakes in Practice
Calling useFormStatus in the same component. It won't work if it's at the same level as the <form>. It must be extracted into a child component.
Calling addOptimistic inside a plain onClick handler. There's no transition context, so a warning is generated and the optimistic update won't work. If you need to use onClick, wrap it with startTransition.
Returning different types from different branches in a useActionState action. Returning a string on success and null on failure causes not only TypeScript errors but also unexpected UI behavior. It's safer to fix the initial state shape as a schema and fill that shape on every code path.
Applying optimistic updates to irreversible operations. For operations like payments or permanent deletion, an optimistic update followed by a failure can confuse users. It's recommended to apply optimistic updates only to operations with low failure rates that are easy to retry.
Closing Thoughts
The problem each hook solves is clear. useActionState bundles async state into a single hook, eliminating separate useState calls for loading, error, and success. useFormStatus lets children subscribe to form state without props. useOptimistic makes the UI respond instantly before a server response arrives. The three hooks can each be used independently, but together they compensate for each other's constraints.
There's no need to change everything at once. If you're starting today, here's the order I'd recommend.
Step 1 — Consolidate existing form state with useActionState. Start by bundling the loading, error, and success states you were managing with multiple useState calls into one. The difference is immediately noticeable.
Step 2 — Extract the submit button into a useFormStatus component. Once you experience firsthand how form state flows down to children without Context, this pattern will naturally come to mind whenever you build a new form.
Step 3 — Apply useOptimistic to simple, fast actions like likes and follows. Start with operations that have low failure rates and are easy to retry, get a feel for optimistic UI, then gradually expand to more complex scenarios.
References
- React v19 Official Release Notes — react.dev
- useActionState Official API Docs — react.dev
- useOptimistic Official API Docs — react.dev
- Server Functions Official Docs — react.dev
- React 19 Deep Dive — Forms & Actions — DEV Community
- useActionState in React: A practical guide — LogRocket Blog
- React 19 useOptimistic Deep Dive — DEV Community
- React 19 Actions — FreeCodeCamp
- How to Use the Optimistic UI Pattern — FreeCodeCamp
- React 19 in Mid-2026: Trends, Patterns & Migration — Techglock
- State of React 2025-2026 Key Takeaways — Strapi
- Composable Form Handling in 2025 — Makers' Den
- React 18 to 19 Migration Complete Guide — Bacancy
- Exploring New Hooks in React 19 — Manuel Sanchez Dev
- Deep Dive React 19 Hooks — Medium