Error Handling in React 19 Actions: Complete
Error handling in React 19 actions follows a different philosophy than traditional try-catch. Instead of throwing errors and catching them in the component, you return an error state from the action. This approach is simpler, more composable, and gives you fine-grained control over which errors are display-ready and which should fail loudly.
An action can encounter three types of errors: validation errors (user input issues), server errors (API calls fail), and unexpected errors (bugs). The strategy differs for each. Validation errors and server errors should be returned as state and displayed to the user. Unexpected errors can throw (caught by error boundaries) or be logged. This article covers the complete error-handling strategy and patterns you'll use in production.
Philosophy: Return Errors, Don't Throw
In React 18, you'd write:
async function submitForm(e) {
e.preventDefault();
try {
const res = await fetch("/api/submit");
if (!res.ok) throw new Error("Server error");
// Success
} catch (err) {
setError(err.message); // Manual state update
}
}
React 19's philosophy is declarative: define the error shape in the action and return it:
async function submitForm(prevState, formData) {
try {
const res = await fetch("/api/submit", { body: formData });
if (!res.ok) throw new Error("Server error");
return { success: true };
} catch (err) {
// Return error state instead of throwing
return { error: err.message, success: false };
}
}
The component doesn't manage error state—the action does. The component just displays what the action returns.
Validation Errors: Field-Specific Messages
Most form errors are validation errors: invalid email, missing required field, password too short. Return these as an object keyed by field:
async function registerUser(prevState, formData) {
const email = formData.get("email");
const password = formData.get("password");
const passwordConfirm = formData.get("passwordConfirm");
// Validation
const errors = {};
if (!email || !email.includes("@")) {
errors.email = "Valid email required";
}
if (!password || password.length < 8) {
errors.password = "Password must be at least 8 characters";
}
if (password !== passwordConfirm) {
errors.passwordConfirm = "Passwords do not match";
}
// Return early if validation failed
if (Object.keys(errors).length > 0) {
return { errors, success: false };
}
// Server call
try {
const res = await fetch("/api/register", {
method: "POST",
body: formData
});
if (!res.ok) {
return { error: "Registration failed. Email may be in use.", success: false };
}
const user = await res.json();
return { success: true, user };
} catch (err) {
return { error: err.message, success: false };
}
}
export default function RegisterForm() {
const [state, dispatch, pending] = useActionState(registerUser, {
errors: {},
success: false,
error: null
});
return (
<form action={dispatch}>
<div>
<label>Email</label>
<input name="email" type="email" required disabled={pending} />
{state.errors?.email && (
<p style={{ color: "red", fontSize: "14px" }}>{state.errors.email}</p>
)}
</div>
<div>
<label>Password</label>
<input name="password" type="password" required disabled={pending} />
{state.errors?.password && (
<p style={{ color: "red", fontSize: "14px" }}>{state.errors.password}</p>
)}
</div>
<div>
<label>Confirm Password</label>
<input name="passwordConfirm" type="password" required disabled={pending} />
{state.errors?.passwordConfirm && (
<p style={{ color: "red", fontSize: "14px" }}>{state.errors.passwordConfirm}</p>
)}
</div>
<button disabled={pending}>
{pending ? "Creating account..." : "Register"}
</button>
{state.error && (
<p style={{ color: "red", marginTop: "10px" }}>{state.error}</p>
)}
</form>
);
}
Here, state.errors holds field-specific messages, and state.error is a general message. The component displays both.
Server Errors: Handling API Failures
When the server returns an error (4xx or 5xx), decide whether to show it to the user or log it. User-facing errors (email in use, validation from DB) should be returned. Unexpected server errors (500) could be logged to a service:
async function updateProfile(prevState, formData) {
try {
const res = await fetch(`/api/users/${prevState.userId}`, {
method: "PATCH",
body: formData
});
// 4xx errors—likely user-facing
if (res.status === 400) {
const { message } = await res.json();
return { error: message, success: false };
}
// 409 Conflict—e.g., email already in use
if (res.status === 409) {
return { error: "This email is already registered", success: false };
}
// 5xx errors—log and show generic message
if (!res.ok) {
console.error(`Server error: ${res.status}`);
return { error: "Something went wrong. Please try again.", success: false };
}
const updated = await res.json();
return { success: true, profile: updated };
} catch (err) {
// Network error
console.error("Network error:", err);
return { error: "Network error. Check your connection.", success: false };
}
}
This pattern differentiates between expected errors (return to user) and unexpected errors (log and show generic message).
Throwing Errors: When to Let Them Propagate
You don't always return errors. If an error is truly unexpected or represents a bug, throw it so an error boundary catches it:
async function criticalOperation(prevState, formData) {
// This should never happen—throw to error boundary
if (!formData.get("required_field")) {
throw new Error("Critical: required field missing from form");
}
try {
const res = await fetch("/api/critical");
if (!res.ok) throw new Error("API error");
return { success: true };
} catch (err) {
// Expected error—return state
return { error: "Operation failed", success: false };
}
}
Reserve throwing for truly unexpected conditions. Most errors should be returned as state.
Async Validation (Real-Time Checks)
Validation that requires a server call (check email availability, username uniqueness) can be async:
async function checkEmailAvailability(email) {
const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
const { available } = await res.json();
return available;
}
async function registerUser(prevState, formData) {
const email = formData.get("email");
// Sync validation first
if (!email.includes("@")) {
return { errors: { email: "Invalid email" }, success: false };
}
// Async validation
const available = await checkEmailAvailability(email);
if (!available) {
return { errors: { email: "Email already in use" }, success: false };
}
// Continue with registration...
const res = await fetch("/api/register", { body: formData });
if (!res.ok) {
return { error: "Registration failed", success: false };
}
return { success: true };
}
The user sees the error instantly if email is taken, without a separate API call.
Error Logging and Monitoring
Log errors to an external service (Sentry, DataDog, etc.) for monitoring:
async function submitForm(prevState, formData) {
try {
const res = await fetch("/api/submit", { body: formData });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { success: true };
} catch (err) {
// Log to monitoring service
if (typeof window !== "undefined") {
console.error("Form submission error:", err);
// In production, send to Sentry, etc.
// Sentry?.captureException(err);
}
return { error: "Submission failed. Please try again.", success: false };
}
}
Log the technical error but return a friendly message to the user.
Clearing Errors After User Input
Reset error state when the user modifies a field, so they don't see stale errors while typing:
export default function Form() {
const [state, dispatch, pending] = useActionState(action, { errors: {} });
const [formValues, setFormValues] = useState({});
function handleChange(e) {
const { name, value } = e.target;
setFormValues(prev => ({ ...prev, [name]: value }));
// Clear error for this field
setFormValues(prev => ({
...prev,
errors: { ...prev.errors, [name]: null }
}));
}
return (
<form action={dispatch}>
<input
name="email"
onChange={handleChange}
disabled={pending}
/>
{state.errors?.email && <p>{state.errors.email}</p>}
</form>
);
}
Alternatively, use a ref and clear the error when the input's value changes.
Key Takeaways
- Return errors from actions as state objects; don't throw for expected errors.
- Use
errorsobject for field-specific validation errors,errorfor general failures. - Log unexpected errors but return friendly, user-facing messages.
- Validate on the client first (sync validation), then the server (async validation, business rules).
- Clear errors when the user edits a field, or show stale errors while re-typing.
Frequently Asked Questions
Should I validate on the client or server?
Both. Client validation gives instant feedback; server validation prevents tampering and enforces business rules. Always validate on the server.
How do I distinguish between form errors and network errors?
Use try-catch around fetch to catch network errors. Check response.ok for HTTP errors. Return different error messages for each case.
Can I combine field errors and general errors?
Yes. Return { errors: {...}, error: "...", success: false }. Display field errors next to inputs and general errors at the top of the form.
What if the user submits the form twice?
The button should be disabled while pending (disabled={pending}). If they do submit twice, the second submission overwrites the first. Consider tracking a submission ID if you need to prevent duplicates server-side.
How do I show a toast notification on error?
Create a hook or context to manage toast state. When the action returns an error, dispatch a toast. Many UI libraries (React Toastify, Sonner, etc.) provide hooks for this.