Introduction
Error handling is architecture.
It is tempting to treat errors as UI states added at the end: show a toast, render an error message, log to console, move on. That works for demos. It does not work for real products.
A serious frontend application needs to decide where errors are detected, how they are classified, what the user sees, what gets logged, and how the system recovers.
The goal is not to show more errors. The goal is to avoid misleading the user.
Fail Fast Means Stop Invalid Work Early
Fail fast is often misunderstood as "crash quickly." In product UI, fail fast means invalid work should be stopped as close to the boundary as possible.
Examples:
- Missing required input should be caught before submit.
- Invalid API response shape should be rejected at the API boundary.
- Unauthorized actions should be blocked before optimistic updates.
- Impossible state transitions should throw during development.
- Misconfigured providers should fail loudly.
Failing fast protects the rest of the system from pretending bad data is valid.
User Errors vs System Errors
Not every error is the same.
User-correctable errors:
- Invalid email.
- Missing field.
- Password too short.
- File too large.
System errors:
- Network failure.
- Server unavailable.
- Unexpected response shape.
- Permission mismatch.
- Expired session.
User errors need guidance. System errors need recovery or escalation.
Do not show the same generic toast for both. "Something went wrong" is sometimes honest, but often lazy in the bad way.
Synchronous Errors
Synchronous errors happen immediately:
- Missing provider.
- Invalid function argument.
- Impossible branch.
- Runtime schema validation failure.
These errors are often developer-facing. In development, they should be loud.
For example:
function useServices() {
const services = React.useContext(ServicesContext);
if (!services) {
throw new Error("ServicesProvider is missing");
}
return services;
}
This is better than returning null and letting the app fail later in a random component.
Fail early with a useful message.
Asynchronous Errors
Asynchronous errors happen across time:
- Fetch fails.
- Mutation fails.
- Token refresh fails.
- Background sync fails.
- Optimistic update is rejected.
These errors need state because the UI must represent what happened.
A mutation state may include:
type MutationState =
| { status: "idle" }
| { status: "pending" }
| { status: "success" }
| { status: "error"; message: string; retryable: boolean };
This is better than a single isLoading boolean and a console error.
The UI can now decide whether to show retry, rollback, sign-in, or support copy.
Error Boundaries Are Not Business Error Handling
React error boundaries are useful for rendering failures. They prevent one broken subtree from taking down the whole app.
But they are not a replacement for domain-level error handling.
An error boundary can show a fallback when a component crashes. It should not be the primary way to handle "payment failed", "form invalid", or "session expired".
Use error boundaries for unexpected UI failures. Use explicit state for expected product errors.
API Boundaries Need Validation
TypeScript does not validate API responses at runtime.
If a backend returns the wrong shape, TypeScript will not save you unless you validate the boundary.
Use a runtime schema where the cost is justified:
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
});
Validate external data once, near the boundary. Inside the app, work with trusted models.
This prevents random components from becoming defensive against every possible API lie.
Recovery Paths
A user-facing error should usually answer three questions:
- What happened?
- What can I do now?
- Did my work survive?
For example, after a failed form submit:
- Preserve entered data.
- Show field errors when available.
- Show form-level error for system failure.
- Allow retry.
- Avoid duplicate submissions.
Data loss is the worst kind of frontend error. Protect user input aggressively.
Logging Without Noise
Not every error needs to be logged as a production incident.
Validation errors are normal. A user typing a bad email is not an exception. A missing provider in production is serious. A failed network request may be expected depending on connectivity.
Classify errors:
- Expected and user-correctable.
- Expected but system-level.
- Unexpected and recoverable.
- Unexpected and critical.
Logging should help diagnosis, not bury real issues under noise.
Conclusion
Good frontend error handling is not a toast library. It is a set of boundaries and recovery rules.
Fail fast when invalid data enters the system. Represent asynchronous errors explicitly. Validate external data where it matters. Use error boundaries for unexpected render failures, not normal business cases. Preserve user work.
The best error handling makes the application feel honest: it does not hide failure, but it also does not make the user pay for developer uncertainty.