Throwing vs. Returning responses in Remix

When you code a loader/action (we'll call them data functions from now) in Remix, you can either return a response or throw a response, but what's the difference?

Returning a response in a data function causes the default export of the route to render unless the response is a redirect, in which case Remix follows the redirect to the new location.

export async function loader({ request }: LoaderArgs) {
  let user = await authorize(request);
  if (!user) return redirect("/login");
  return json({ user });
}

export default function Route() {
  let { user } = useLoaderData<typeof loader>();
  return <h1>{user.name}</h1>;
}

Throwing a response can cause two effects, depending on whether the response is a redirect.

  1. If you throw a redirect response, Remix will follow the redirect as usual.
  2. If you throw a non-redirect response, Remix will render the CatchBoundary component of the route (or nearest parent route) and pass the response data to the UI with the useCatch hook.
export async function loader({ request, params }: LoaderArgs) {
  let user = await authorize(request);
  if (!user) throw redirect("/login"); // we can throw or redirect here
  let post = await getUserBlogPostById(params.postId, { user });
  if (!post) throw json({ error: "Post not found" }, { status: 404 });
  return json({ user, post });
}

export default function Route() {
  let { user, post } = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>{post.title}</h1>
      <p>Author {user.name}</p>
    </main>
  );
}

export function CatchBoundary() {
  // Here, we need to pass the ThrownResponse type, which receives the possible status codes and data as generics
  let caught = useCatch<ThrownResponse<404, { error: string }>>();
  return (
    <main>
      <h1>{caught.data.error}</h1>
      <p>Status {caught.status}</p>
    </main>
  );
}

So you should throw a response (non-redirect) when you want to render the CatchBoundary.

The CatchBoundary can then render, for example, a 404 page, which will allow you to keep the default export for the happy path UI and put any error UI in the CatchBoundary component.

Returning or throwing a redirect is essentially the same. The benefit comes when you move code to another function.

In our examples, this authorize function receives the request, checks if the user is authenticated, and returns the user object or null. Then, we must manually check if the user is null and return or throw a redirect response.

export async function authorize(request: Request) {
  let session = await sessionStorage.getSession(request.headers.get("Cookie"));
  let user = session.get("user");
  if (!user) return null;
  return user;
}

We can refactor the authorize function to throw a redirect to the login route directly inside the function instead.

export async function authorize(request: Request) {
  let session = await sessionStorage.getSession(request.headers.get("Cookie"));
  let user = session.get("user");
  if (!user) throw redirect("/login");
  return user;
}

And now, we can simplify our loader function.

export async function loader({ request, params }: LoaderArgs) {
  let user = await authorize(request);
  let post = await getUserBlogPostById(params.postId, { user });
  if (!post) throw json({ error: "Post not found" }, { status: 404 });
  return json({ user, post });
}

So why throw a redirect instead of returning it?

If you return it, your data functions need to check if the return value is a response and return it immediately or is a user who uses the data.

export async function loader({ request, params }: LoaderArgs) {
  let user = await authorize(request);
  if (user instanceof Response) return user; // check if it's a response object
  // here, we're sure a user is a user object
  let post = await getUserBlogPostById(params.postId, { user });
  if (!post) throw json({ error: "Post not found" }, { status: 404 });
  return json({ user, post });
}

If you throw the response, the data function will stop running immediately. If we can authenticate the user, your data function will only receive the user data, making your code more straightforward.