Alright, we’re getting further and further into this Remix thing. Today we’re looking at error handling. And let me just tell you right off the bat, Remix has some great error handling! So if you’re interested, let’s dive in.
I will be using just the built-in stuff, so you can use my Slowcore Stack, but you can also create an empty default Remix project, or use your own thing.
A bit about errors
Before we jump into coding, I want to talk a bit about errors in general. They
are often treated as something bad and either masked with statements like
try { ... } catch { return; }
or simply ignored. It took some time for me to
realize that an error is the same information as a success. Not what I expect or
want, but a valid, fully-fledged information nonetheless. That’s why it is so
important to both throw good errors and handle them properly.
And that’s what we’re doing today!
Starting with throwing errors
Right, so our project is running. Let’s create a new page, call it hello.tsx
and put there a bit of text. Nothing fancy, just
// ./app/routes/hello.tsx
export default function Hello() {
return <div>Hello there!</div>;
}
Now, let’s throw an error in there. Right in the component.
throw new Error("Well crap");
And, as you can see, we’ve got a huge error stack. Going all the way from React DOM to our file. And notice that we’re actually getting our line and file properly marked. If you remember working with early versions of Webpack, you’ll appreciate this. It could’ve been “Error in line 345”, when the file has five lines.
So we have our stack, cool. But showing these details on production isn’t safe, right? So let’s build and run the app.
On production it only shows that there was an error. That’s really great, one security concern less to worry about. But what now? Can we take it further?
Well, yes and no. As always.
We need to start by exporting an additional component, called ErrorBoundary
right in our route:
export function ErrorBoundary() {
return <div className="bg-red-100 p-10 text-red-900">Error</div>;
}
The main problem is, if you’ll throw the error directly in the document, the
server will render properly (and will render the error), but the client will
omit the head
section almost entirely and by this, you will not get any
styling. In exchange, you’ll get a hydration error, because the DOM tree will
differ.
Okay, so how we can circumvent this?
Most of the time you won’t really throw an error in your component. A function
will throw, an effect, a hook. But writing throw new Error
in the body doesn’t
seem like the best idea anyway.
What you want here is to avoid throwing errors that will be rendered on the server. It’s simply a poor practice to do this, because:
- this error might (and likely is) temporary;
- it leaves user without any possible action (apart from leaving your app);
- it might get indexed.
Instead, you want to render the error only for the client and, ideally, allow the user to seemingly retry.
To throw in the client, simply do it someplace where server doesn’t have access
to. For example, in useEffect
:
export default function Hello() {
useEffect(() => {
throw new Error("asd");
}, []);
return <div>Hello World</div>;
}
Remix is smart enough to catch the error thrown in the effect, so you’re completely fine.
Okay, but what if something inside the component will throw? Something that I cannot control? There is a quick solution for that. Catch the error and update your state!
export default function Hello() {
const [error, setError] = useState<Error | unknown>();
useEffect(() => {
try {
if (Math.random() > 0.5) {
throw new Error("Oh no! I've been thrown in an effect!");
}
} catch (e: unknown) {
setError(e);
}
}, []);
return <div>Hello World</div>;
}
Nothing happens when you enter the page, nothing is thrown. That’s because we caught the error safely. So, what now? We need to add the handling of this state:
if (error instanceof Error) {
throw error;
}
if (typeof error === "string") {
throw new Error(error);
}
And that’s it! Now, if you refresh your page several times, you should randomly see fully styled error, or a proper “Hello World”.
Getting error messages
Throwing and catching errors is cool, but the real gist is to know, what error really occurred. Remix provides some tools to make it happen:
export function ErrorBoundary() {
const error = useRouteError();
let message = "We've encountered a problem, please try again. Sorry!";
if (error instanceof Error) {
message = error.message;
}
if (isRouteErrorResponse(error)) {
message = error.data;
}
return <div className="bg-red-100 p-10 text-red-900">{message}</div>;
}
Cool, that takes care of the message. We can obviously change it, as errors tend to contain rather technical details. But that’s up to you!
Right, but there are two types of errors there, right? How can I know which one’s which? For us now, to learn how to handle this, let’s add some text help:
if (error instanceof Error) {
message = "Error: " + error.message;
}
if (isRouteErrorResponse(error)) {
message = "Route error: " + error.data;
}
Right, but our error is of the first kind, instanceof Error
. How to trigger
the second one?
JavaScript is a funny language. It allows us to throw anything, literally. So to
get an actual route error, we must… throw a proper Response
object in a
server function:
export function loader() {
throw new Response("I am thrown in the loader function", { status: 500 });
}
Unfortunately, this will render on the server. So use this sparingly and only as the last resort.
Recovering from errors
If you go through Sentry or any other error monitoring software, you’ll notice that most errors are rare and happens once. If your server has a hiccup, or if there was a connection error. It’s rarely by an actual bug in the software and more often than not, trying again solves the issue. So, let’s add a “retry” button for our users. Hey, maybe it’ll help!
return (
<div className="bg-red-100 p-10 text-red-900">
{message}
<button
onClick={() => window.location.reload(true)}
className="p-1 bg-white"
>
Try again
</button>
</div>
);
This way, we’re allowing users to refresh the page. Just like pressing “Refresh” in the browser, but instead of making user “press the power button”, we’re handling it internally, making it seems like we know what we’re doing. UX, baby!
I am joking there for a bit, but it’s true. Think of when you need to restart something manually, reach out to the button and press it. And then, think of when your system tells you “Oh no, I’ve crashed, click here to recover.” Feels nicer, doesn’t it?
If you know that your API or database tends to have “breaks”, consider adding a short timeout, just to let the good folks know that you’re rebooting the “machine”:
export function ErrorBoundary() {
const error = useRouteError();
const [restarting, setRestarting] = useState(false);
let message = "We've encountered a problem, please try again. Sorry!";
if (error instanceof Error) {
message = "Error: " + error.message;
} else if (isRouteErrorResponse(error)) {
message = "Route error: " + error.data;
}
function retry() {
setRestarting(true);
setTimeout(() => {
window.location.reload(true);
}, 500);
}
return (
<div className="bg-red-100 p-10 text-red-900">
{restarting ? "Restarting the app..." : message}
<button
onClick={retry}
className="p-1 bg-white disabled:opacity-50"
disabled={restarting}
>
Try again
</button>
</div>
);
}
Error nesting
One of the best things we can do while encountering errors is to handle it gracefully, instead of killing the entire app. That’s why Remix allows us to nest errors. So if our one subpage is broken, we can just render the error there, instead of taking the entire screen.
Let’s add an Outlet
to the hello.tsx
route and create a subpage:
// ./app/routes/hello.tsx
...
export default function Hello() {
...
return (
<div>
Hello World
<hr className="my-10" />
<Outlet />
</div>
);
}
And the subpage:
// ./app/routes/hello.$name.tsx
import { useParams } from "@remix-run/react";
import { useEffect } from "react";
export function ErrorBoundary() {
return <div className="bg-yellow-100 text-yellow-900 p-5 rounded">Error</div>;
}
export default function Hello() {
const { name } = useParams();
return <div>Hello, {name}</div>;
}
If we enter hello/anyname
, we’re getting a good render. But what if one
resource is broken? Literally, let’s call it broken:
export default function Hello() {
const { name } = useParams();
useEffect(() => {
if (name === "broken") {
throw new Error();
}
}, [name]);
return <div>Hello, {name}</div>;
}
That’s great. Error is handled properly just in the children, the main page is intact and can still operate.
Not found!
The oldest tr… I mean, error in the book. The “not found”, the 404, the holy grail of error handling. If you’re handling this correctly, you’re three quarters in.
By default, Remix renders simply a “404 Not Found” page with 404 status. It’s adequate, but we can jazz it up.
“I’ll just pop a 404.tsx
and be done”, you think to yourself. Nah, nothing
like that. This thing isn’t as simple in Remix, as you need to export an
ErrorBoundary
from the root.tsx
file:
export function ErrorBoundary() {
const error = useRouteError();
let message = "";
if (isRouteErrorResponse(error)) {
switch (error.status) {
case 404:
message = "Page not found";
break;
case 500:
message = "Server error";
break;
default:
message = "We've encountered a problem, please try again. Sorry!";
break;
}
}
return (
<div className="h-dvh flex items-center justify-center">{message}</div>
);
}
This is a catch-all for all errors unhanded lower in hierarchy. So for example,
if one of our pages don’t have an error boundary defined, it will go up to its
parent, and so on. And if no page has the boundary, it’ll eventually end up in
root.tsx
. To test this, create any route and throw there. For example:
// ./routes/bam.tsx
export function loader() {
throw new Response("Not found", { status: 404 });
}
export default function NotFound() {
return <div className="text-center p-10">Bam</div>;
}
And that’s that.
—
Error handling is a crucial functionality in any production-grade application. With Remix’s error catching mechanisms and flexible nesting, you can make sure that users are getting the proper experience and can retry, rather than just quit you app altogether.