Redirection to 404 Pages with React Query and React Router

I remember the first time I worked on an application that needed custom 404 handling. I kept thinking, “There must be an elegant way to catch these errors and redirect the user to a stylish ‘Not Found’ page.” As it turns out, React Query and React Router together make this process feel quite intuitive, and I’d love to share how I approach it.

Thank me by sharing on Twitter 🙏

I’m focusing primarily on the error-handling logic and the associated redirection. I’m assuming that the core application is already running and that you have React Query and React Router installed. Since I am not including Node.js setup or any boilerplate creation instructions, I’ll dive directly into how I wire up these packages to ensure that when a resource is not found, the user is taken to a custom 404 page.

My goal is to walk through each relevant step and detail some of the key techniques, along with code snippets that highlight critical points. I find it helpful to break down the process into several clear stages: setting up a custom 404 page, fetching data with React Query, detecting a 404 error, and finally redirecting the user using React Router.

By the end of this post, you’ll see how I handle error states with minimal code repetition. I’m a huge fan of keeping my codebase tidy and consistent, so I’ll also show you a neat little helper function I use to check if an error’s status is 404.

Why a Custom 404 Page Matters

I like to think of a 404 page as an opportunity. It’s not just a random error screen; it’s a chance to display a bit of personality or humor while guiding users back to safer territory. Moreover, giving users feedback about missing content can be a life-saver in terms of user experience, particularly when data dependencies shift or external APIs become outdated.

In many applications, a missing resource indicates that a user ended up on a route that no longer exists or tried to load something the server can’t find. In such scenarios, it’s more user-friendly to have a designated “Not Found” route, complete with relevant messages or navigation links, rather than a generic error. This is why I always prioritize a distinct 404 experience.

Crafting the 404 Page

Before I handle any real data fetching, I like to set up a simple 404 page. Technically, this can be any React component, though I often name it NotFoundPage.jsx or something similar. My 404 page might include a small message, an image, or perhaps a link back to the main content.

For example:

TypeScript
// NotFoundPage.jsx
import React from "react";

function NotFoundPage() {
  return (
    <div>
      <h1>404: Page Not Found</h1>
      <p>It looks like youre lost. This page does not exist.</p>
    </div>
  );
}

export default NotFoundPage;

From here, I configure a route in React Router that points to NotFoundPage. Typically, I do something like:

TypeScript
// App.jsx (or wherever I keep my routes)
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import NotFoundPage from "./NotFoundPage";
import HomePage from "./HomePage";
import MyResourceComponent from "./MyResourceComponent";

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/resource/:id" element={<MyResourceComponent />} />
        <Route path="/404" element={<NotFoundPage />} />
        <Route path="*" element={<NotFoundPage />} />
      </Routes>
    </Router>
  );
}

export default App;

Notice that I often put two things in place: a dedicated route for /404 and a catch-all (*) route that also renders the NotFoundPage. The /404 route is handy when I want to programmatically redirect to a 404 screen but keep a unique path in the address bar. The catch-all is an extra safety net to catch any routes that don’t match.

Installing and Configuring React Query

React Query simplifies data fetching in ways that I appreciate. My typical approach is to wrap the entire application in a QueryClientProvider. That’s usually done at a high level, such as in index.jsx or the main entry point of the app.

I configure a QueryClient that may look like this:

TypeScript
import React from "react";
import ReactDOM from "react-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";

const queryClient = new QueryClient();

ReactDOM.render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
  document.getElementById("root")
);

Once I do this, my entire React application can use useQuery from @tanstack/react-query. I no longer need complicated state management in multiple places just to handle fetching logic or loading states.

Detecting a 404 Error

In my experience, the best approach to detecting 404s is to attach the error response to the error object if res.ok is false. This technique is straightforward:

TypeScript
async function fetchMyData(id) {
  const response = await fetch(`/api/resource/${id}`);
  if (!response.ok) {
    const error = new Error("Failed to fetch data");
    error.response = response;
    throw error;
  }
  return response.json();
}

I’ve added an extra property called error.response = response. That way, my React Query error object contains the exact status code from the server. I can then pick it up in my component.

When I use the useQuery hook, I can examine isError or the error object to see if a 404 code came back. Here’s a basic example of that:

TypeScript
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";

function MyResourceComponent({ id }) {
  const { data, isLoading, isError, error } = useQuery(["resource", id], () => fetchMyData(id));

  if (isLoading) {
    return <p>Loading resource...</p>;
  }

  if (isError && error?.response?.status === 404) {
    return <Navigate to="/404" replace />;
  }

  if (isError) {
    return <p>An error occurred while loading the resource.</p>;
  }

  return (
    <div>
      <h2>Resource Details</h2>
      <p>ID: {data.id}</p>
      <p>Name: {data.name}</p>
      {/* Or any other data fields */}
    </div>
  );
}

async function fetchMyData(id) {
  const response = await fetch(`/api/resource/${id}`);
  if (!response.ok) {
    const error = new Error("Failed to fetch data");
    error.response = response;
    throw error;
  }
  return response.json();
}

export default MyResourceComponent;

What I like about this pattern is how explicit it is. If I detect a 404 in my error status, I return a <Navigate> component to push the user to /404. I also added the replace prop so that I don’t clutter their browser history with a broken route.

Using a Reusable Helper Function

Sometimes, I want to keep my code as concise as possible. That’s why I prefer to write a small helper function that checks whether an error is a 404. This can be a simple one-liner:

TypeScript
// utils.js
export function is404(error) {
  return error?.response?.status === 404;
}

Then, in my component, I can do:

TypeScript
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { is404 } from "./utils";

function MyResourceComponent({ id }) {
  const { data, isLoading, isError, error } = useQuery(["resource", id], () => fetchMyData(id));

  if (isLoading) {
    return <p>Loading resource...</p>;
  }

  if (isError && is404(error)) {
    return <Navigate to="/404" replace />;
  }

  if (isError) {
    return <p>An error occurred while loading the resource.</p>;
  }

  return (
    <div>
      <h2>Resource Details</h2>
      <p>ID: {data.id}</p>
      <p>Name: {data.name}</p>
    </div>
  );
}

This keeps my conditional checks short and sweet. By pulling out the 404 logic, I reduce the duplication of the dreaded error?.response?.status === 404 all over my codebase.

Managing the User Experience

I like to reflect on the overall user experience whenever I implement error handling. Redirecting to a 404 page is just one piece of the puzzle. Ideally, my app’s design clearly communicates that the requested page or resource is unavailable. I might include a quick link back to a homepage or a search function.

When using <Navigate> for the redirection, I appreciate how React Router updates the URL bar for me, so users see a more accurate representation of their current location. On top of that, returning a <Navigate> in my component is a nice, reactive approach. The moment an error is flagged as a 404, the user is taken elsewhere, without any complicated manual navigation calls.

For times when I do want to perform a manual navigation, I can rely on useNavigate from react-router-dom. That might look like this:

TypeScript
import React, { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { is404 } from "./utils";

function MyResourceComponent({ id }) {
  const navigate = useNavigate();
  const { data, isLoading, isError, error } = useQuery(["resource", id], () => fetchMyData(id));

  useEffect(() => {
    if (isError && is404(error)) {
      navigate("/404");
    }
  }, [isError, error, navigate]);

  if (isLoading) {
    return <p>Loading resource...</p>;
  }

  if (isError) {
    return <p>An error occurred while loading the resource.</p>;
  }

  return (
    <div>
      <h2>Resource Details</h2>
      <p>ID: {data.id}</p>
      <p>Name: {data.name}</p>
    </div>
  );
}

Here, I watch for a 404 error in a useEffect. Then I invoke navigate("/404") once I confirm the error is indeed a 404. This approach might come in handy if I need more complex side effects that happen before or after the navigation.

Conclusion

Having a smooth 404 redirection workflow is an essential part of building a robust React application. I find it reassuring that React Query and React Router work so well together in this regard. With a simple helper function and some careful error handling, I can maintain clarity in my code and improve the user’s journey by sending them to a dedicated 404 page.

These techniques give me the confidence that when data fails to load because of a missing resource, I can swiftly redirect the user without them lingering on a half-baked or broken screen. It ensures the user sees a friendly message that outlines exactly what went wrong.

Overall, handling 404s in this manner has allowed me to maintain consistency while adding just enough nuance to accommodate special design preferences or advanced features. In the future, I might enhance my 404 page to include logs for better debugging or add humor to lighten the mood. Nonetheless, the core pattern of using React Query for data fetching and React Router for navigation works perfectly for all sorts of production-level applications. And I always appreciate how these small details, like a dedicated 404 experience, can elevate the overall feel of a website or app.

Share this:

Leave a Reply