Let’s be honest: handling forms in React has historically been… tedious.
If you’ve been writing React for more than a few years, you have likely written thousands of lines of boilerplate code just to toggle isLoading booleans, catch errors, and manage input values. It’s 2025, and the ecosystem has finally matured. With React 19, the core team has fundamentally shifted how we handle data mutations through Actions.
This isn’t just syntactic sugar. Actions provide a primitive way to handle async transitions, automatically managing pending states and optimistic updates, and they work seamlessly on both the client and the server (if you are using a framework like Next.js or Remix).
In this guide, we are going to refactor a standard “legacy” form into a modern React 19 implementation, stripping away the noise and leaving clean, declarative code.
The Prerequisites #
Before we dive into the code, ensure your environment is ready. You don’t need a massive meta-framework to test this, but you do need the right React version.
- Node.js: v20.0.0 or later (LTS recommended).
- React: v19.0.0 (Stable).
- Bundler: Vite 6+ is recommended for pure React testing.
If you are setting up a fresh sandbox:
npm create vite@latest react-19-actions -- --template react
cd react-19-actions
npm install
npm install react@latest react-dom@latestThe “Old School” Pain #
To appreciate the solution, let’s briefly look at the problem. Here is a standard Login form we have all written a hundred times.
// Legacy approach - DO NOT COPY
import { useState } from 'react';
function LegacyLoginForm() {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await loginAPI(email);
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button disabled={isSubmitting}>
{isSubmitting ? 'Loading...' : 'Login'}
</button>
{error && <p>{error}</p>}
</form>
);
}It works, but it’s imperative. You are manually driving the UI state at every step of the async lifecycle. React 19 allows us to stop micromanaging.
Enter useActionState
#
The star of the show in React 19 is the useActionState hook. It acts as a wrapper around an asynchronous action, automatically providing the current state of that action (including the return value of the function) and a pending status.
Here is the architectural flow of how Actions simplify the lifecycle:
Step 1: Defining the Action #
An Action is just an async function. The key difference is the signature. When used with useActionState, the function receives two arguments:
- previousState: The value returned by the last execution of the action.
- formData: The
FormDataobject automatically collected from the<form>.
Let’s build a robust newsletter signup form.
// actions.js
// Simulating an API call
export async function subscribeAction(prevState, formData) {
const email = formData.get('email');
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 1500));
if (!email.includes('@')) {
return {
success: false,
message: 'Please provide a valid email address.',
timestamp: Date.now()
};
}
return {
success: true,
message: `Welcome aboard, ${email}!`,
timestamp: Date.now()
};
}Step 2: Implementing the Component #
Now, let’s wire this up using useActionState.
import { useActionState } from 'react';
import { subscribeAction } from './actions';
const initialState = {
success: false,
message: '',
timestamp: null
};
export default function NewsletterSignup() {
// useActionState returns: [state, formAction, isPending]
const [state, formAction, isPending] = useActionState(
subscribeAction,
initialState
);
return (
<div className="p-6 max-w-md mx-auto bg-white rounded-xl shadow-md">
<h2 className="text-xl font-bold mb-4">Join the DevPro List</h2>
{/*
Notice: No onSubmit. We pass the action directly to the form.
React handles the preventDefault and FormData collection.
*/}
<form action={formAction} className="flex flex-col gap-4">
<label className="flex flex-col">
<span className="text-gray-700">Email Address</span>
<input
name="email"
type="email"
className="border p-2 rounded mt-1"
placeholder="dev@example.com"
disabled={isPending}
required
/>
</label>
<button
type="submit"
disabled={isPending}
className={`p-2 rounded text-white font-semibold transition-colors
${isPending ? 'bg-gray-400' : 'bg-blue-600 hover:bg-blue-700'}`}
>
{isPending ? 'Subscribing...' : 'Subscribe'}
</button>
</form>
{state.timestamp && (
<div className={`mt-4 p-3 rounded ${state.success ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{state.message}
</div>
)}
</div>
);
}Notice what is missing?
- No
useStatefor loading. - No
useStatefor errors. - No manual
e.preventDefault(). - No controlled inputs (
value={email}) required (though you can still use them if needed).
Decoupling UI with useFormStatus
#
In larger applications, your submit button is rarely sitting directly next to your form logic. It might be buried inside a generic <Button> component or a CardActions wrapper.
Passing the isPending state down through props creates “prop drilling” hell. React 19 solves this with useFormStatus. This hook allows a child component to read the status of the parent <form> it is nested inside.
Here is how we refactor the submit button into a reusable component:
import { useFormStatus } from 'react-dom';
function SubmitButton() {
// This hook hooks into the parent <form> automatically
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full bg-indigo-600 text-white p-2 rounded disabled:opacity-50"
>
{pending ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin h-4 w-4 border-2 border-white rounded-full border-t-transparent"></span>
Processing...
</span>
) : (
'Confirm Subscription'
)}
</button>
);
}Now, your main form is even cleaner:
<form action={formAction}>
<input name="email" ... />
<SubmitButton />
</form>Comparison: React 18 vs React 19 #
Let’s look at the hard data. Why should you refactor?
| Feature | React 18 (Legacy) | React 19 Actions |
|---|---|---|
| State Management | Manual useState for loading/error |
Automatic via useActionState |
| Data Collection | Controlled inputs (onChange) |
Native FormData (Uncontrolled) |
| Form Submission | onSubmit handler |
action prop on <form> |
| Pending UI | Manually toggled booleans | isPending or useFormStatus |
| Progressive Enhancement | Requires hydration to work | Works without JS (if using Server Components) |
Best Practices & Common Pitfalls #
While Actions simplify things, they introduce new mental models. Here are the nuances experienced developers need to know.
1. Resetting the Form #
In the old onSubmit model, you would call e.target.reset(). Since Actions bind to the form, resetting isn’t automatic on success. You generally have two options:
- The React Way: Return a
keyin your action state to force a re-render of the form (component reset). - The DOM Way: Use a
refto callreset()inside auseEffectthat watchesstate.success.
// Simple reset pattern
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state.success]);2. Progressive Enhancement #
If you are using a framework like Next.js, defining your action with 'use server' allows the form to work before JavaScript loads. The form submits a standard HTTP POST, the server processes it, and returns the new HTML. React 19 Actions bridge the gap between “Web 1.0 simple forms” and “Web 2.0 interactive apps.”
3. TypeScript Typing #
When using TypeScript, ensure your action signature matches what useActionState expects.
type FormState = {
message: string;
success: boolean;
};
// The signature is (prevState: FormState, formData: FormData)
async function myAction(prevState: FormState, formData: FormData): Promise<FormState> {
// ...
}Conclusion #
React 19 Actions aren’t just a new API; they are a correction. We spent years moving away from the browser’s native capabilities (HTML Forms), re-implementing them in JavaScript. Actions bring us back to the platform, leveraging FormData and native form lifecycles while keeping the interactivity we love.
By adopting useActionState and useFormStatus, you reduce boilerplate, eliminate race conditions in state management, and prepare your codebase for a server-component future.
Next Steps:
- Refactor a simple “Contact Us” form in your current project.
- Explore
useOptimisticto show UI updates immediately while the Action is still pending (e.g., adding a comment to a list instantly). - Check the React Documentation for the latest on Server Actions.
Happy coding!