An async function passed to a React 19 <form> element's action prop rejects because the API returned a validation error. What does React do with that rejection by default, and how should the Action be designed so the user sees an inline field message instead?
answer
- React routes it, does not swallow it
- boundaries unmount the whole subtree
- expected outcome versus exceptional failure
- errors as return value, not exception
- reset only happens on success
basics
~20 sReact does not swallow it: an uncaught rejection inside an Action is surfaced to the nearest error boundary, which replaces the subtree. Treat validation failures as data — catch inside the Action and return an error result the form renders inline.
solid answer
~50 sReact routes the failure rather than absorbing it. Because the Action runs in a transition React controls, an unhandled rejection is surfaced to the nearest error boundary, so a rejected `saveProfile` unmounts the form and shows whatever fallback the boundary renders — the user loses their typed values and gets a generic message. That is right for "the network is down" and badly wrong for "email already taken". The fix is a design decision, not an API: expected failures are *results*, not exceptions. Catch inside the Action and return a serialisable object such as `{ ok: false, errors: { email: 'Already registered' } }`. Routing the Action through `useActionState` makes that returned value the form's state, so the fields can render their own messages, and since React only resets an uncontrolled form after a *successful* action, the user's input survives. Reserve throwing for failures a boundary should genuinely handle.
code
jsx · 16 linesasync function signUp(prevState, formData) {
const email = formData.get('email');
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (res.status === 409) {
return { ok: false, errors: { email: 'Already registered' } };
}
if (!res.ok) {
throw new Error('Sign-up failed');
}
return { ok: true, errors: {} };
}go deeper
Know that a rejection inside a form Action is not ignored — it reaches the nearest error boundary — and that validation messages are something you return and render yourself.
Explain the two paths and their consequences: throwing unmounts the subtree via the boundary, while returning a result keeps the form mounted with the typed values intact because the reset only happens on success.
Draw the expected-versus-exceptional line explicitly, specify the serialisable result shape you would standardise on, and cover boundary placement plus how the returned message actually reaches a screen reader.
Set the convention across teams: one result shape for every Action, an agreed rule for what is allowed to throw, and boundary placement that keeps a single failed mutation from blanking a page.
## Where a rejected Action goes With a hand-written `onSubmit`, an unhandled rejection is just an unhandled promise: it lands in the console and the UI shows nothing. Actions change that. React knows when the Action starts and ends, so it can attribute the failure, and it surfaces an uncaught rejection to the nearest error boundary — the same destination as an error thrown while rendering. That is a genuine improvement (nothing is silently lost) and a genuine hazard (an error boundary is a very large hammer). When the boundary catches, it unmounts the subtree it protects. For a form, that means the fields are gone, the values the user typed are gone with them, and the message they see is whatever generic fallback the boundary renders. In a server-rendered app the underlying error message is redacted in production builds as well, so it is not even useful to the user. ## Two categories of failure The design rule that resolves this is older than React: separate *expected* outcomes from *exceptional* ones. - **Expected** — the email is taken, the coupon expired, the file is too large, the password is too short. These are ordinary business outcomes. The form should stay on screen, keep its values, and explain what to change. Model them as **return values**. - **Exceptional** — the request could not be made, the server returned a 500, a bug threw a `TypeError`. There is no field-level correction. Model them as **throws** and let an error boundary handle them. An Action that throws for a validation error is making a category error, and it shows up as a UI that nukes itself when the user types a duplicate email. ## Returning errors as data ```jsx async function signUp(prevState, formData) { const email = formData.get('email'); const res = await fetch('/api/signup', { method: 'POST', body: JSON.stringify({ email }), headers: { 'Content-Type': 'application/json' }, }); if (res.status === 409) { return { ok: false, errors: { email: 'Already registered' } }; } if (!res.ok) { throw new Error('Sign-up failed'); // exceptional: let the boundary have it } return { ok: true, errors: {} }; } ``` A bare `<form action={fn}>` discards the return value, so the Action has to be routed through `useActionState` for the result to become renderable state. The form then renders `state.errors.email` next to the input. Two properties fall out of this that candidates often miss: - **The values survive.** React resets an uncontrolled form only after a *successful* function action, so returning an error result leaves the fields populated and the user can correct one character rather than retype everything. - **The result must be plain data.** Whatever you return becomes state that may cross a serialisation boundary in a server-rendered setup, so return strings, numbers, arrays and plain objects — not an `Error` instance, a class, or a function. ## Keeping the boundary useful Because exceptional failures now have a real destination, the error boundary above your forms should be deliberate rather than incidental. Place it where losing the subtree is acceptable, give it a retry affordance, and do not wrap the whole application in a single boundary if a form failure would then blank the page. React's error-boundary API is the class-based `getDerivedStateFromError` / `componentDidCatch` pair — one of the few places a class component is still the mechanism. ## Accessibility and the message the user reads An inline error is only useful if it is announced. Associate the message with the input (`aria-describedby`, `aria-invalid`), put it adjacent to the field, and if you render a summary, move focus to it. This is the part of error design that no framework primitive does for you, and it is often what separates a senior answer from a middle one — the mechanics of returning `{ errors }` are easy; knowing that the returned errors have to reach a screen reader is the experience. ## Summary of the judgment The interviewer is checking whether you know that React 19 gave failures a *destination* and that choosing the destination is your job: return for outcomes the user can act on, throw for outcomes only an engineer can.
- Why must the object you return from an Action be plain data?Because it becomes state React may have to carry across a boundary — including a server-to-client boundary in a server-rendered app — so it has to be serialisable. Plain objects, arrays, strings, numbers, booleans and null are safe; `Error` instances, class instances and functions are not, and the failure shows up as a serialisation error rather than a helpful one.
- Where would you place the error boundary that catches the exceptional case?Close enough that losing the subtree is tolerable — around the form's page section rather than the whole application — and with a retry control, since most exceptional failures are transient. A single top-level boundary means one failed mutation blanks the app, which is why boundary placement is a layout decision, not an afterthought.
- Does the form still keep the user's input if you return an error result?Yes. React resets an uncontrolled form only after the action completes successfully, so a returned error leaves the DOM values in place and the user corrects one field. That is one of the practical reasons to prefer returning over throwing: the boundary path unmounts the inputs and everything typed goes with them.
saying these in an interview costs you the question
- Thinks a rejected Action fails silently in the console
- Throws for validation errors and lets a boundary catch them
- Returns an Error instance as the Action's result
- Expects a bare form action's return value to become state
- Assumes React renders field-level messages for you