remix: Throwing Special Exceptions in loaders/actions

When uncaught exceptions are thrown in loaders, actions, or during render, Remix emulates componentDidCatch and renders the ErrorBoundary for the route where the exception was thrown. If there is no boundary there, it “bubbles” up to the nearest parent error boundary.

This behavior could also be useful for:

  • Not Found
  • Redirects

The API would be very simple: you just throw from your loader/action.

Not Found

import { notFound } from "remix";

export function loader({ params }) {
  let project = await db.projects.find(params.projectId);

  if (!project) {
    throw notFound();
  }

  return json(project);
}

// this would render
export function NotFound() {
  let params = useParams();
  return <h1>No project for {params.projectId}.</h1>
}

Like ErrorBoundaries, if this route doesn’t export a NotFound then the nearest parent’s NotFound will render instead, all the way to the root.tsx.

Redirect

Redirects would work the same way, instead of returning them, you can throw them. For example, you could protect a route from visitors without a user session like this:

import { getUserSession } from "../sessions";

export function loader({ request }) {
  let userSession = await getUserSession(request);

  if (!userSession) {
    // return redirect("/login");
    throw redirect("/login");
  }

  return null;
}

Of course, this isn’t really different than today except that you could “throw” instead of return. It’s not obvious at first but this makes abstractions that may redirect far nicer! Instead of the callback flying-v, your code could be much cleaner:

// instead of creating abstractions with a "push" API
export function loader({ request }) {
  return removeTrailingSlash(request, () => {
    return withUserSession(request, session => {
      let projects = await db.projects.findMany({ where: { userId: session.uid } });
      return json(projects);
    }); 
  })
}

// we could create abstractions with a "pull" API which are typically easier to work with
// (like render props -> hooks)
export function loader({ request }) {
  removeTrailingSlash(request);
  let session = await getUserSession(request);
  let projects = await db.projects.findMany({ where: { userId: session.uid } });
  return json(projects);
}

The Gotcha! Apps have to rethrow

There’s one big gotcha here. I experimented with throwing redirects in @reach/router and it was pretty nice, but the problem is that applications need to be mindful to rethrow any exceptions they caught.

export function loader({ request }) {
  try {
    removeTrailingSlash(request);
    let session = await getUserSession(request);
    let projects = await db.projects.findMany({ where: { userId: session.uid } });
    return json(projects);
  } catch (e) {
    // app has to rethrow if it's a special remix exception.
    if (isRemixException(e)) {
      throw e;
    }
    return json(e.message, { status: 500 });
  }
}

If the app doesn’t rethrow a special Remix exception then the whole API breaks.

This was particularly problematic in @reach/router because all of this was happening in the render tree so every application-level componentDidCatch had to deal with it. We’ve thought about this API for Remix for a while but my memory of the problems in @reach/router made me shy away from it.

However, I don’t think it’s as big a risk in Remix, since we aren’t throwing in the render tree, we’re always just one level away from where the exception needs to go. I think this drastically reduces the risk that apps will accidentally swallow these special exceptions. Additionally, since we already have error boundaries for uncaught exceptions, apps aren’t very inclined to wrap the code in their loaders with try/catch, it’s automatic in Remix.

I think the most common try/catch in a loader is unlikely to be a problem because it’s usually not wrapping things that will throw special Remix exceptions like getUserSession or removeTrailingSlash. It’s usually some other API after those things have been dealt with. For example, some database APIs throw when the record isn’t found, that would look like this:

export function loader({ request, params }) {
  // these could throw a special remix exception and everything is fine
  // because the app didn't catch these
  removeTrailingSlash(request);
  let session = await getUserSession(request);

  // after that though, it's unlikely these APIs will be throwing special
  // remix exceptions, so we're good.
  try {
    return json(await db.projects.find(params.id));
  } catch (e) {
    // some DB exception API, not Remix
    if (e.code === "RECORD_NOT_FOUND") {
      // can use the new Remix notFound exception
      throw notFound();
    }

    // otherwise use the ErrorBoundary
    throw e;
  }
}

Links export

The links export will need to know about this in case apps want to load different resources for the not found branch. Simplest thing would be a boolean to the links function:

export function links({ data, notFound }) {
  return notFound ? notFoundLinks : foundLinks;
}

Not Found Status Codes

Not found should probably take any 4xx status code.

Technically these are “client errors” (and 5xx are server errors). So instead of “Not Found” we might want to be more pedantic:

// probably what we'll do
throw notFound();
throw notFound(401):

// but if we wanted to be more pedantic about it:
throw clientError(); // default is 404
throw clientError(401);

Benefits

  • Cleaner loader/action code for any abstractions that redirect
  • Can remove the weird /404 route (especially nice since people are tempted to redirect to 404 which is not at all the point!)
  • Can handle not found cases in context, or at the root level of the app, by simply exporting a NotFound at any level of the route tree
  • Don’t need a bunch of branching code in components/loaders and sending a { notFound: true} to the component

Related: #203

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 6
  • Comments: 19 (13 by maintainers)

Most upvoted comments

Best of both worlds or two worlds? 😜

I agree having their own routes would be nice. At one point we considered like a routes/ and an exceptions/ folder. So you could have global exceptions/{404,401,418,500}.js routes that rendered for any of those status codes.

But we have a strong preference to only have one API for the same use-case. Using route exports lets us have global handling (in root.tsx) as well as contextual (inside the routes). The only real trade off is that you have to branch in root.tsx. Not a big deal.

Another possible name for the exported component could be UserError so it’s not confused with client-side error, ClientError is still nice tho because it’s how 4xx status codes are called so it’s more similar to the standard.

I think for most apps you only need the first two, specially in Remix where most logic is moved to the server, effects would be really simple so maybe you don’t need a try/catch, you will not have a lot of event handlers either so less errors to catch and with TypeScript a lot of possible render errors are removed so custom error boundaries may not be needed either, I used to use error boundaries with RQ to catch errors but I don’t need that now.

Even the ClientError will be most likely used for routes with params in the URL so you can handle a not found, a bad request is most likely to happen only in actions and you can return the errors directly and call useActionData to retrieve it.

this handles more than 404

That makes sense, bit that being the case maybe “Not Found” is the incorrect terminology here if I’m going to use that to handle “unauthorized” as well. I’m that case, the “pedantic” solution your proposed probably makes more sense semantically and rationally. “ClientError” as an export would be best I think.

I’ll also add that so far I’ve been making use of throwing errors to send error messages to my client and that’s worked well. Adding a little check for whether it’s a remix error would not be a problem for me.

So I think this would be a great change 👍

would it make it possible for a route not to handle 404s

Yes, that’s the bubbling, just like ErrorBoundary.

In your root.tsx export a NotFound component and you’ll never need another one if you don’t want contextual not found screens.

(There would no longer be a routes/404.jsx file, the “global not found handler” would just be the export on root)