useActionState & useFormStatus: The Revolution of Form Management

Chris is building a nickname change page.
It is a very simple feature. You type a new nickname in the input field, click the 'Change' button, and send it to the server.
However, to implement this simple feature, Chris is declaring as many as three states (useState) and writing a handler function covered in try-catch-finally.
// ❌ Chris's exhausting daily routine (Boilerplate)
function NicknameForm() {
const [nickname, setNickname] = useState('');
const [isLoading, setIsLoading] = useState(false); // Loading state
const [error, setError] = useState<string | null>(null); // Error state
const [success, setSuccess] = useState(false); // Success state
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
setSuccess(false);
try {
await updateNickname(nickname);
setSuccess(true);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false); // Loading finished
}
};
return (
<form onSubmit={handleSubmit}>
<input value={nickname} onChange={e => setNickname(e.target.value)} />
<button disabled={isLoading}>
{isLoading ? 'Changing...' : 'Change'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
}"Is this mess of code really necessary just to make one form? The logic for handling loading and catching errors is the same every time..."
The React team was aware of this pain. React 19 introduces useActionState and useFormStatus, which automate form submission and asynchronous state management. It is time to escape the hell of useState and try-catch.
1. useActionState: Automatically Managing Action Results
useActionState is a hook that wraps an asynchronous function (action) and automatically manages its execution result (state) and progress status (pending).
(Note: It was called useFormState in the early Canary versions, but was renamed to useActionState to indicate it can be used for general actions beyond just forms.)
Usage
const [state, formAction, isPending] = useActionState(actionFn, initialState);Refactoring: Deleting useState
Let's change Chris's code. First, separate the asynchronous logic into a pure function.
// actions.ts
// Receives previous state (prevState) and form data (formData).
export async function updateNicknameAction(prevState: any, formData: FormData) {
const nickname = formData.get('nickname') as string;
try {
await updateNickname(nickname); // API Call
return { success: true, message: "Change complete!" };
} catch (err) {
return { success: false, message: "Error occurred: " + err.message };
}
}Now, in the component, you can wipe out useState and use just useActionState.
// NicknameForm.tsx
import { useActionState } from 'react';
import { updateNicknameAction } from './actions';
function NicknameForm() {
// ✅ Manage loading, error, and result states all at once
const [state, formAction, isPending] = useActionState(updateNicknameAction, null);
return (
<form action={formAction}>
<input name="nickname" required />
{/* isPending automatically toggles true/false */}
<button disabled={isPending}>
{isPending ? 'Changing...' : 'Change'}
</button>
{state?.message && <p>{state.message}</p>}
</form>
);
}The try-catch is gone, and so is setIsLoading(true). React detects the start and end of the action, toggles isPending, and puts the returned value into state. The code has been cut in half.
2. useFormStatus: Stop Props Drilling!
When building forms, you often want to separate the 'Submit Button' into a separate component (SubmitButton) to apply a design system or for reuse.
However, if you separate the button, it loses access to the loading state (isPending). Eventually, you have to pass it down via props from the parent form.
// ❌ Props Drilling occurs
function NicknameForm() {
const [state, formAction, isPending] = useActionState(...);
return (
<form action={formAction}>
{/* Must pass loading state to child button manually */}
<SubmitButton isLoading={isPending} />
</form>
);
}useFormStatus solves this annoyance. This hook acts like Context.
If called from any child component inside a <form>, it can access the state of "whether the parent form I belong to is currently submitting."
SubmitButton Component
// SubmitButton.tsx
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
// Subscribe to parent form's status. (No Props needed!)
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Processing...' : 'Submit'}
</button>
);
}Cleaned Parent Component
// NicknameForm.tsx
function NicknameForm() {
const [state, formAction] = useActionState(updateNicknameAction, null);
return (
<form action={formAction}>
<input name="nickname" />
{/* Just place it without Props, and you're done */}
<SubmitButton />
<p>{state?.message}</p>
</form>
);
}Caveat: useFormStatus only works when rendered inside (as a child of) the <form>. It cannot be used within the component that defines the form itself (NicknameForm).
3. Why is this a Revolution? (Progressive Enhancement)
The reason these hooks are revolutionary isn't just because they reduce code. It's because they support "Progressive Enhancement."
When used with frameworks like Next.js, you can make forms work even before JavaScript is loaded in the browser, or in environments where JS is disabled.
useActionState serves as a bridge connecting these two worlds (MPA and SPA). Of course, even in pure client-side React (Vite, etc.), the benefit of simplifying logic remains powerful.
Key Takeaways
Now form state management is automated. But we are not done yet.
When a user posts a comment, waiting for the server response to refresh the list creates a 0.5-second delay.
We tried implementing Optimistic Updates with TanStack Query in Part 3, but now we can perform this magic using only React's built-in hooks without external libraries.
Continuing in: "useOptimistic: Implementing Optimistic Updates Without useState."