Overview
Error states and retry UX are about what users experience when a request fails. A React app should not collapse into a blank page, hide the failure, or force users to guess whether retrying is safe. Good error UX explains what happened, preserves useful context, offers the right recovery action, and avoids making the situation worse.
Failed requests can happen for many reasons:
- The user is offline.
- The server is temporarily unavailable.
- A request times out.
- Authentication expired.
- Authorization failed.
- A resource was not found.
- Validation failed.
- A mutation conflict occurred.
- The client sent invalid data.
- A background refetch failed while stale data is still available.
For interviews, this topic matters because error handling reveals engineering maturity. Strong candidates distinguish validation errors from route errors, expected 404s from unexpected exceptions, foreground failures from background refresh failures, and safe retries from dangerous duplicate mutations.
The practical goal is to design recovery paths: retry, edit and resubmit, sign in again, go back, refresh data, contact support, or keep using stale data while the app recovers.
Core Concepts
Types of Request Failures
Not all failed requests are the same.
Common categories:
- Validation errors: the user can fix input.
- Authentication errors: the user needs to sign in again.
- Authorization errors: the user does not have permission.
- Not found errors: the resource does not exist or is no longer available.
- Conflict errors: the data changed or the action is no longer valid.
- Rate limit errors: the user or app should slow down.
- Network errors: the client could not reach the server.
- Server errors: the server failed unexpectedly.
The UI should respond differently to each category. A field validation error belongs near the field. A missing route resource belongs in a route error boundary. A background refresh failure may only need a toast or stale-data indicator.
Expected Errors vs Unexpected Errors
Expected errors are part of normal product flow:
- Invalid email.
- Missing required field.
- Password too short.
- Username already taken.
- Permission denied for a known action.
- Resource not found.
Unexpected errors are bugs or infrastructure failures:
- Unhandled exception.
- Server crash.
- Invalid response shape.
- Failed route module.
- Unknown thrown value.
Expected validation errors should usually be returned as structured data. Unexpected errors should be logged and handled by an error boundary or generic fallback.
Field Errors, Form Errors, and Page Errors
Match the error location to the scope of the problem.
Field error:
<input
id="email"
name="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email}
</p>
)}
Form-level error:
{formError && (
<p role="alert">
{formError}
</p>
)}
Page-level error:
function ProjectErrorBoundary() {
const error = useRouteError();
return <ErrorPanel error={error} />;
}
Do not show every error as a full-page failure. Keep the error as local as possible.
Route Error Boundaries
Route error boundaries catch route-level failures from route components, loaders, actions, and route APIs.
const router = createBrowserRouter([
{
path: "/projects/:projectId",
loader: projectLoader,
Component: ProjectPage,
ErrorBoundary: ProjectErrorBoundary,
},
]);
Use route error boundaries for:
- Not found resources.
- Unauthorized or forbidden route access.
- Loader failures that prevent the page from rendering.
- Unexpected route component failures.
Do not use route error boundaries for normal form validation. Validation errors are expected user-correctable states and should be returned to the form.
Local Error States
Local components often need local error states for small interactions.
function InlineSave({ task }: { task: Task }) {
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<"idle" | "saving" | "error">("idle");
async function saveTitle(title: string) {
setStatus("saving");
setError(null);
try {
await updateTask(task.id, { title });
setStatus("idle");
} catch {
setStatus("error");
setError("Could not save. Try again.");
}
}
return (
<>
<button disabled={status === "saving"}>Save</button>
{error && <p role="alert">{error}</p>}
</>
);
}
Use local errors when the failure affects one widget, row, or action. Use route boundaries when the route cannot render correctly.
Retry UX
Retry UX gives the user a clear way to attempt the request again.
function ErrorPanel({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return (
<section role="alert">
<h2>Something went wrong</h2>
<p>{message}</p>
<button onClick={onRetry}>Try again</button>
</section>
);
}
Good retry UX:
- Explains what failed in plain language.
- Keeps user input when possible.
- Shows whether retry is in progress.
- Avoids duplicate unsafe mutations.
- Uses backoff for automatic retries.
- Gives alternatives when retry will not help.
Retry should be available when the failure is likely temporary. It should not be the main answer for validation, authorization, or not-found errors.
Safe vs Unsafe Retries
Retrying a read is usually safe:
await fetchProject(projectId);
Retrying a mutation may be unsafe if the server could have completed the first request but the client did not receive the response.
Risky examples:
- Creating an order.
- Charging a payment.
- Sending an email.
- Deleting a record.
- Submitting a one-time action.
Safer mutation retries often require:
- Idempotency keys.
- Server-side duplicate detection.
- Clear confirmation state.
- User confirmation.
- A way to reconcile unknown outcomes.
Interview answer: do not blindly retry all failed requests.
Automatic Retries
Client cache libraries often provide automatic retry for failed queries. Automatic retries are useful for transient network or server issues, especially for reads.
Good automatic retry candidates:
- Temporary network failure.
- 502, 503, or 504 style transient server errors.
- Background refresh.
Poor automatic retry candidates:
- 400 validation errors.
- 401 unauthenticated.
- 403 forbidden.
- 404 not found.
- Non-idempotent mutations.
Use retry limits and backoff. Infinite rapid retries create poor UX and unnecessary load.
Backoff and Retry Delay
Backoff means waiting longer between retry attempts.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
Backoff helps avoid hammering a failing service. It also gives transient problems time to recover.
For user-triggered retry, show a button. For automatic retry, consider showing subtle feedback such as "Reconnecting..." or "Retrying...".
Background Refresh Failures
A background refetch failure is different from an initial page load failure. If the UI already has data, it may be better to keep showing stale data and display a small warning.
function UsersPanel({ users, refreshError }: Props) {
return (
<section>
{refreshError && (
<p role="status">
Could not refresh. Showing the last loaded data.
</p>
)}
<UserList users={users} />
</section>
);
}
Do not replace useful stale content with a full-page error just because a background refresh failed.
Error Boundaries and Reset
When errors are thrown to a boundary, the retry action often needs to reset that boundary and re-run the request.
function BoundaryFallback({ reset }: { reset: () => void }) {
return (
<section role="alert">
<h2>Could not load this section</h2>
<button onClick={reset}>Try again</button>
</section>
);
}
With query libraries and suspense/error-boundary integrations, a reset boundary can tell failed queries to try again on the next render.
Route-level errors may retry through navigation, revalidation, or a route-specific retry button.
Preserving User Input
When a form submission fails, keep the user's input whenever possible.
Good:
- Preserve field values.
- Show field errors.
- Keep focus near the failed field or summary.
- Let the user correct and resubmit.
Bad:
- Clear the whole form on server validation failure.
- Replace the form with a generic error page for fixable input.
- Hide which field failed.
For route actions, return validation data and re-render the same form. For controlled forms, keep local field state unless the submit succeeds.
Retry Buttons and Duplicate Work
Retry buttons should be disabled while retrying.
function RetryButton({
retrying,
onRetry,
}: {
retrying: boolean;
onRetry: () => void;
}) {
return (
<button disabled={retrying} onClick={onRetry}>
{retrying ? "Trying again..." : "Try again"}
</button>
);
}
For mutations, make sure retrying will not duplicate a successful operation. If the outcome is unknown, show a safer message:
We could not confirm whether the payment completed. Please check your orders before trying again.
Offline and Network-Aware UI
Network failures may need different UX from server errors.
Examples:
- "You appear to be offline. Check your connection and try again."
- "Your changes are saved locally and will sync when you reconnect."
- "Could not refresh. Showing cached data."
Offline-capable apps may queue mutations and replay them later. That requires careful conflict handling and user-visible sync state.
Do not promise offline saving unless the app actually persists and synchronizes changes reliably.
Authentication and Authorization Failures
Authentication and authorization failures need specific recovery paths.
401 or expired session:
- Prompt sign-in.
- Preserve intended destination.
- Avoid losing unsaved form data when possible.
403 forbidden:
- Explain that the user lacks permission.
- Do not show a retry button unless permissions might change.
- Offer navigation back to a safe area.
Retrying a forbidden request usually will not help.
Observability and Support
Good error UX is not just frontend state. Production failures need observability.
Useful practices:
- Log unexpected errors.
- Capture request IDs or correlation IDs.
- Show a support-safe error code when appropriate.
- Avoid leaking stack traces or sensitive server details to users.
- Preserve enough context for debugging.
Example user-facing copy:
We could not load this invoice. Try again or contact support with code INV-LOAD-2026.
The code should map to logs or telemetry.
Common Mistakes
Common mistakes include:
- Showing a generic full-page error for field validation.
- Retrying every failure automatically.
- Retrying unsafe mutations without idempotency.
- Clearing user input after validation fails.
- Hiding background refresh failures completely.
- Replacing stale usable data with an error page.
- Showing technical stack traces to users.
- Giving users a retry button for 403 or validation errors.
- Not disabling retry while it is already running.
- Treating route error boundaries as normal control flow.
- Failing to log unexpected request errors.
Best Practices
Use these rules of thumb:
- Classify the error before choosing UX.
- Keep errors as local as possible.
- Use field errors for fixable input.
- Use route boundaries for route-blocking failures.
- Preserve useful stale data during background refresh failures.
- Offer retry for transient read failures.
- Use backoff for automatic retries.
- Be careful retrying mutations.
- Preserve user input on failed submit.
- Provide clear recovery actions and accessible error messages.