DEV Community

Cover image for React Router Data Mode: Part 3 - Loaders and data fetching

React Router Data Mode: Part 3 - Loaders and data fetching

Let's move on to the third part of this tutorial series. In this case, we'll look at a concept that comes from Remix and can now also be found in React Router: loaders.

If you're coming from the previous tutorial, you can leave the project as is, but if you want to make sure everything is clean or start from the same point, you can run the following commands:

# Repository link https://github.com/kevinccbsg/react-router-tutorial-devto
git reset --hard
git clean -d -f
git checkout 02-loaders-detail-page
Enter fullscreen mode Exit fullscreen mode

What are loaders?

Loaders are a mechanism that React Router provides to send information to our components. They are functions that we add in our route definitions.

Here's an example:

createBrowserRouter([
  {
    path: "/",
    loader: async () => {
      // return data from here
      return { records: await getSomeRecords() };
    },
    Component: MyRoute,
  },
]);
Enter fullscreen mode Exit fullscreen mode

In loaders, we add everything we consider necessary to load for that page. When working with React Router, we need to start thinking in terms of pages, since all data loading and mutation is organized at that level.

In our contacts page, we're currently using a hardcoded array of data, which isn't realistic. So, we'll replace it with a call to an API we've created using json-server.
We won't go into detail about what json-server is (it's basically a quick way to simulate a REST API with a JSON file), just note that from now on we'll test the app with the command:

npm run serve:dev
Enter fullscreen mode Exit fullscreen mode

This command starts both the API with json-server and the frontend.

In the src/api folder are all the methods that call the API using Axios. Since this series is about React Router, we won't go into detail about that part.

Now that we've covered the workflow, let's start the refactor. Normally in React, to load data in a component, we use something like:

const [contacts, setContacts] = useState<Contact[]>([]);

useEffect(() => {
  fetchContacts()
    .then((data) => setContacts(data))
}, []);
Enter fullscreen mode Exit fullscreen mode

With React Router, this changes, as we'll use a loader defined in AppRoutes.tsx:

import { createBrowserRouter } from "react-router";
import ContactsPage from "./pages/Contacts";
import ContactForm from "./pages/ContactForm";
import { fetchContacts } from "@/api/contacts";

const AppRoutes = createBrowserRouter([
  {
    path: "/",
    // this property
    loader: async () => {
      const contacts = await fetchContacts();
      return { contacts };
    },
    Component: ContactsPage,
    children: [
      {
        path: "contacts/new",
        Component: ContactForm,
      },
    ],
  },
  {
    path: "/about",
    element: <div>About</div>,
  },
  {
    path: "*",
    element: <div>Not Found</div>,
  },
]);

export default AppRoutes;
Enter fullscreen mode Exit fullscreen mode

Although this works, and we could then access the data using a React Router hook, I don't recommend doing it this way. The routes file can grow a lot, and later we'll need to solve a typing issue with TypeScript, for which this approach isn't ideal.

It's better to create a separate file, for example: src/pages/loader.tsx:

import { fetchContacts } from "@/api/contacts";

export const loadContacts = async () => {
  const contacts = await fetchContacts();
  return { contacts };
};
Enter fullscreen mode Exit fullscreen mode

This way, the AppRoutes.tsx file is much cleaner:

import { createBrowserRouter } from "react-router";
import ContactsPage from "./pages/Contacts";
import ContactForm from "./pages/ContactForm";
import { loadContacts } from "./pages/loader";

const AppRoutes = createBrowserRouter([
  {
    path: "/",
    loader: loadContacts,
    Component: ContactsPage,
    children: [
      {
        path: "contacts/new",
        Component: ContactForm,
      },
    ],
  },
  {
    path: "/about",
    element: <div>About</div>,
  },
  {
    path: "*",
    element: <div>Not Found</div>,
  },
]);

export default AppRoutes;
Enter fullscreen mode Exit fullscreen mode

If we navigate to the web app, we'll see in the "Network" tab that the API call is made.

network tab with the call

But the data is still hardcoded in the UI. How do we retrieve the info?

We use the useLoaderData hook from React Router:

const { contacts } = useLoaderData();
Enter fullscreen mode Exit fullscreen mode

This hook always returns what the loader defined in the route for the component returns. In this case, an object with contacts.

However, if we use this in a TypeScript project, we'll get a typing error. To fix it, we write it like this:

const { contacts } = useLoaderData<typeof loadContacts>();
Enter fullscreen mode Exit fullscreen mode

So the Contacts component ends up like this:

import { Link, Outlet, useLoaderData } from "react-router";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { loadContacts } from "./loader";

const ContactsPage = () => {
  const { contacts } = useLoaderData<typeof loadContacts>();
  return (
    <div className="h-screen grid grid-cols-[300px_1fr]">
      {/* Sidebar */}
      <div className="border-r p-4 flex flex-col gap-4">
        <Button className="w-full" variant="secondary" asChild>
          <Link to="/contacts/new" viewTransition>
            New
          </Link>
        </Button>
        <ScrollArea className="flex-1">
        <div className="flex flex-col gap-1 mt-4">
          {contacts.map(contact => (
            <Button
              key={contact.id}
              className="justify-start"
              asChild
            >
              <Link to={`/contacts/${contact.id}`} viewTransition>
                {contact.firstName} {contact.lastName}
              </Link>
            </Button>
          ))}
        </div>
      </ScrollArea>
      </div>
      {/* Detail View */}
      <div className="p-8">
        <Outlet />
      </div>
    </div>
  );
};

export default ContactsPage;
Enter fullscreen mode Exit fullscreen mode

That's why it's more convenient to have a loader file per page, as it improves type management and keeps everything more separated.

With this, we're doing the same as we did with useEffect, but in the recommended way for React Router.


Finally, you may have noticed a warning in the terminal:

hydrate fallback warning

This is because we don't have a loading screen yet. We can simulate a delay in the API to see the problem. In src/api/contacts.ts, uncomment the delay function and modify fetchContacts:

export const fetchContacts = async () => {
  const response = await api.get<Contact[]>('/contacts');
  await delay(2000); // Simulate network latency
  return response.data;
};
Enter fullscreen mode Exit fullscreen mode

When we reload, we'll see the page stays blank for a few seconds until the data loads. This is because there's no loading state. React Router allows us to handle this in several ways, but in this post we'll use the HydrateFallback property.

In this project, we already have the ContactsSkeletonPage component, so we add it like this:

import { createBrowserRouter } from "react-router";
import ContactsPage from "./pages/Contacts";
import ContactForm from "./pages/ContactForm";
import { loadContacts } from "./pages/loader";
import ContactsSkeletonPage from "./Layouts/HomeSkeleton";

const AppRoutes = createBrowserRouter([
  {
    path: "/",
    loader: loadContacts,
    HydrateFallback: ContactsSkeletonPage,
    Component: ContactsPage,
    children: [
      {
        path: "contacts/new",
        Component: ContactForm,
      },
    ],
  },
  {
    path: "/about",
    element: <div>About</div>,
  },
  {
    path: "*",
    element: <div>Not Found</div>,
  },
]);

export default AppRoutes;
Enter fullscreen mode Exit fullscreen mode

And with this, we now have a loading screen. The best part is that this can be applied per page, and each one loads independently without blocking the others.

We'll see this in more detail in the next post about loaders.
Without a doubt, this is one of the most important and powerful parts of React Router, and it will be even more so when we combine it with actions.

See you in part 4!

Top comments (0)