Throwing vs. Returning Redirects in React Router
One of the most common questions in React Router is: what’s the difference between throwing and returning a redirect in a loader or action?
Understanding the redirect
Function
The redirect
function in React Router is often misunderstood. Many assume it triggers a side effect that immediately causes navigation. However, it is actually a pure function that returns a new Response
instance.
export const redirect: RedirectFunction = (url, init = 302) => {
let responseInit = init;
if (typeof responseInit === "number") {
responseInit = { status: responseInit };
} else if (typeof responseInit.status === "undefined") {
responseInit.status = 302;
}
let headers = new Headers(responseInit.headers);
headers.set("Location", url);
return new Response(null, { ...responseInit, headers });
};
This code is taken directly from React Router’s source. As you can see, it creates a new Response
instance with the Location
header set to the target URL and a default status of 302
(Found).
Because redirect()
simply returns a Response
, calling redirect("/login")
alone is not enough to trigger navigation. You must return the response from your action or loader so that the router processes it.
export async function action({ request }: Route.ActionArgs) {
return redirect("/login");
}
Throwing a Redirect
React Router also allows you to throw a redirect instead of returning it.
export async function action({ request }: Route.ActionArgs) {
throw redirect("/login");
}
When you throw a redirect, React Router automatically catches it and processes the response, making it equivalent to returning a redirect in many cases.
When Does Throwing Matter?
While both returning and throwing a redirect achieve the same result in most cases, there’s a key difference: throwing stops execution not just in the current function but also in its entire call stack until a try/catch
block handles it.
Consider a loader that checks if a user is authenticated and redirects them to the login page if they are not.
export async function loader({ request }: Route.LoaderArgs) {
let user = await currentUser(request);
if (!user) return redirect("/login");
// ...
}
This works as expected: if the user is not authenticated, the loader returns a redirect response, and React Router navigates to the login page.
But what if we want a helper function that enforces authentication?
Returning a Redirect in a Helper Function
async function requireUser(request: Request, path = "/login") {
let user = await currentUser(request);
if (!user) return redirect(path);
return user;
}
export async function loader({ request }: Route.LoaderArgs) {
let userOrResponse = await requireUser(request);
if (userOrResponse instanceof Response) return userOrResponse;
let user = userOrResponse;
// ...
}
Here, requireUser()
checks if the user is authenticated. If not, it returns a redirect response, which the loader must check and return explicitly.
Throwing a Redirect in a Helper Function
Now, let’s modify requireUser()
to throw the redirect instead:
async function requireUser(request: Request, path = "/login") {
let user = await currentUser(request);
if (!user) throw redirect(path);
return user;
}
export async function loader({ request }: Route.LoaderArgs) {
let user = await requireUser(request);
// ...
}
With this approach:
- If the user is authenticated,
requireUser()
returns the user object. - If the user is not authenticated,
requireUser()
throws a redirect. - The router catches the thrown redirect and processes it without requiring the loader to handle it explicitly.
This pattern allows you to build reusable authentication helpers that automatically redirect users when needed, simplifying your loader and action functions.
Handling Redirects Inside a try/catch
A special case arises when you need to wrap your logic inside a try/catch
block:
export async function loader({ request }: Route.LoaderArgs) {
try {
let user = await requireUser(request);
// ...
} catch (exception) {
// ...
}
}
In this case, if requireUser()
throws a redirect, the catch
block will catch it before the router does, preventing the navigation.
To ensure the router still processes the redirect, you should rethrow the exception:
export async function loader({ request }: Route.LoaderArgs) {
try {
let user = await requireUser(request);
// ...
} catch (exception) {
if (exception instanceof Response) throw exception;
// ...
}
}
This way:
- If
requireUser()
throws a redirect, thecatch
block rethrows it, allowing React Router to handle the navigation. - If
requireUser()
throws any other type of error, you can handle it separately.
Summary
- The
redirect()
function returns aResponse
, which must be returned from a loader/action to trigger navigation. - You can throw a redirect, and React Router will catch it and handle it automatically.
- Throwing stops execution at all levels, making it useful for enforcing authentication with helper functions.
- If you use a
try/catch
block, ensure you rethrow redirects so that React Router can still process them.
By understanding these differences, you can write cleaner, more efficient loaders and actions in your React Router applications.