When it comes to error handling in Remix there are two categories of errors to deal with:
- Development errors (like rollup not being able to compile)
- Production errors (uncaught errors in loaders or render)
Development Errors
These are errors that prevent a production build from even happening (remix build
would fail). During development we can display messages in the terminal, the browser, or when possible, both places.
The following are development errors that may occur and where the user is notified:
- error in readConfig
- error in terminal, process exits
- dev asset server fails to build on file change (indicates that
remix build
won't work either)
- message in terminal
- message in the browser through a websocket
- app server running, dev asset server offline
- message in terminal
- message in the browser through a websocket
Production Errors
Production errors don't prevent the app from being built and could happen in a production environment. For example, trying to read from an object member that doesn't exist in a loader.
There are two types of errors that can occur:
- An error during render
- An error in data loading
For rendering errors we use componentDidCatch
, but errors thrown during data loading happen in useEffect
, and those errors aren't caught by componentDidCatch
. For these, we'll render the 500.js file.
Specific error handling
For more detailed user feedback than a top level error boundary for rendering and 500.js for uncaught data errors, it's up to the application to catch those errors and deal with them locally.
- In loaders: catch the error and send the information as normal data with the proper status code
- in render: use normal React error handling in the component by wrapping in another component with
componentDidCatch
For loader errors, it would look like this:
export function loader() {
try {
let stuff = db.get("stuff");
return json(stuff);
} catch (error) {
return json({ error: error.message }, { status: 500 });
}
}
The route continues to render as usual, there's just a 500 status code on the fetch (or document request) and the component needs to account for it:
function SomeRoute() {
let data = useRouteData();
if (data.error) {
return <div>Oops, there was an error: {data.error.message}</div>;
}
}
Again, if apps don't catch any errors, loader errors will cause the 500.js file to render, and render errors will propagate up to the <ErrorBoundary>
in the starter template's App.tsx
file.
Future Next Step
This work is not scheduled to be completed right now, but I think it would be a great direction to go.
We can take this a step farther and do better than rendering the 500.js
page with uncaught errors, as well as eliminate the 500.js
and 404.js
pages altogether.
In Suspense, errors in data loading are part of the component lifecycle, so error handling of component errors or data loading errors are all handled in componentDidCatch
. This gives you fine-grained control over uncaught errors.
During browser transition fetch requests, we can emulate this behavior by catching data errors, continuing the render, and throwing the error during the component render phase. We essentially "move" the error from useEffect
to render
.
Unfortunately, componentDidCatch
doesn't work on the server (because the features is stateful by nature). However, we can emulate it on our server rendered document requests by catching the error in a loader, then rerendering again with the error on context. Because we know the route whose loader threw an error, we can also have that route render the "error branch" of code by checking for the error on context and rendering the error branch UI--emulating what happens in the browser when an error is thrown and componentDidCatch
rerenders the error branch. To reiterate: when an error is thrown in the browser, componentDidCatch
catches it and rerenders the error branch. On the server, we try to render once, collect errors, render again and routes with errors take their error branch when they have an error on context--it's like an initial prop.
With this setup, instead of rendering the "500 page" on uncaught errors anywhere, the error will propagate up the React tree to the nearest componentDidCatch
. If none exist between the error and the root of the app, then you still have the same "500 page" behavior.
We could also turn "no match" into an error that also makes it way up to the nearest componentDidCatch
and the error page can handle both cases, completely eliminating our weird 404.js
and 500.js
files.
function App() {
let data = useGlobalData();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<Meta />
<Styles />
</head>
<body className="m-4">
<div data-test-id="content">
<Routes />
</div>
<Scripts />
</body>
</html>
);
}
function ErrorPage({ error }) {
// Would bikeshed this error API ofc
if (error.status === 404) {
return <div>Page not found</div>;
}
return <div>There was an error: {error.message}</div>;
}
export default function Root() {
<ErrorBoundary component={ErrorPage}>
<App />
</ErrorBoundary>;
}
This way all uncaught errors are handled. If routes wanted to handle uncaught errors more specifically, they could export an Error
component. Consider this route:
export async function loader() {
// OOPS! Database is offline and threw an error, and the app didn't catch it!
let project = await db.read("...");
return project;
}
export default function Project() {
let data = useRouteData();
return <div>{/* ... */}</div>;
}
Because the error was not handled by the loader, the top level <ErrorBoundary>
from earlier would handle this error. But if the route exports an Error
component, the error will be handled here, in this route, and any routes above w/o errors will continue to render normally, providing a much better experience for the user than changing the entire UI.
export async function loader() {
// OOPS! Database is offline and threw an error, and the app didn't catch it!
let project = await db.read("...");
return json(project);
}
export function Error({ error }) {
return <div>Oops! There was an error: {error.message}</div>;
}
export default function Project() {
let data = useRouteData();
return <div>{/* ... */}</div>;
}
We could even take the error path for any non-200 response, like 404s. 404s are probably going to need to be handled on every route that fetches data (rather than handled generically like any other non-200 status), so this would be really nice for application code.
export async function loader() {
let project = await db.read("...");
return project === null ? json("", { status: 404 }) : json(project);
}
export function Error({ error }) {
if (error.statusCode === 404) {
return <div>That project wasn't found</div>;
}
return <div>Oops! There was an error: {error.message}</div>;
}
export default function Project() {
let data = useRouteData();
return <div>{/* ... */}</div>;
}
Why not do the future stuff now?
The first bit of work needs to be done regardless. If developers remove the top level <ErrorBoundary>
from the App.tsx
file, then the normal 500.js code needs to be run. In the future that 500.js file will just be internal to Remix, but the code paths are likely to be identical. So it makes sense to get the first batch of work done to make error handling much better than it is today, and then make it even better in the future.