File-based routing with React Location — Data loaders

Route-based data loaders with React Location and Vite

Cowritten with Marian Molina December 28, 2021

⌁ Check out Generouted if you want to use client-side file-based routing. It provides more features than what’s covered here, multiple frameworks support and multiple routers integrations!


In the previous couple of posts we talked about how to setup file-based routing for client-side React applications with React Router and Vite. We covered the base setup for file-based routing then added route-based code-splitting to enable on-demand loading. Finally we added route-based pre-loading by using a custom Link component.

Today we’ll take a look at how we can have the same setup with React Location instead of React Router. React Location introduces an interesting new approach for client-side routing inspired by full-stack React frameworks like Next.js and Remix.

React Location makes the previous file-based routing setup even simpler. It also comes with a lot of features that offer a better user/dev experience. In this post we’ll highlight one of them, loader functions. Those functions will run and return some data to their routes before rendering.

By the end of this post, you’ll be able to define your routes with data loaders following this pattern:

// src/pages/users/[id].tsx export const loader = async ({ params: { id } }) => { return getUser(id) } export default function User() { const { data } = useMatch() return ( <div> <div>{data.email}</div> </div> ) }

You’ll get a better client-side routing experience with very little code to setup.

If it’s your first time reading about file-based routing with client-side React applications, I recommend you to check the previous posts for more in depth explanations.

Getting started

We’ll start by setting up a React project with Vite.

You can scaffold a Vite project with React and TypeScript by using --template react-ts:

npm init vite@latest my-react-app -- --template react-ts

Then add react-location as a dependency:

npm install react-location

Configuring routes

This configuration part is almost identical to what we did in the previous setup with React Router. We used Vite’s import.meta.glob API to load the routes modules following Next.js file-based routing conventions.

We’ll start the same way by loading all modules inside the src/pages directory. Also we’ll have src/pages/_app.tsx and src/pages/404.tsx as preserved routes:

// src/routes.tsx const PRESERVED = import.meta.globEager('/src/pages/(_app|404).tsx') const ROUTES = import.meta.glob('/src/pages/**/[a-z[]*.tsx') const preservedRoutes: Route[] = Object.keys(PRESERVED).reduce((routes, key) => { const path = key.replace(/\/src\/pages\/|\.tsx$/g, '') return { ...routes, [path]: PRESERVED[key]?.default } }, {}) const regularRoutes = Object.keys(ROUTES).map((key) => { const route = ROUTES[key] const path = key .replace(/\/src\/pages|index|\.tsx$/g, '') .replace(/\[\.{3}.+\]/, '*') .replace(/\[(.+)\]/, ':$1') return { path, element: () => route().then((mod) => (mod?.default ? <mod.default /> : <Fragment />)), } })

The code above is basically the same as before, except we’ll load each route component asynchronously by using the element property without using React.lazy or <Suspense> directly. We also added a fallback <Fragment> in case there wasn’t a default export from that route yet.

There’s also pendingElement and errorElement that can be used to handle different route loading states but we’ll not cover those in this post. You can find more details at React Location route elements guide.

Rendering routes

React Location comes with a config-based API to define the routes, so we only need to pass the routes array to React Location’s <Router> as a routes prop. We’ll also append the <NotFound> component to our routes as a fallback route.

To render the routes within our <App> or other layout components, we’ll need to use the <Outlet> component to specify where the routes are rendered:

// src/routes.tsx // ... const App = preservedRoutes?.['_app'] || Fragment const NotFound = preservedRoutes?.['404'] || Fragment const location = new ReactLocation() const routes = [...regularRoutes, { path: '*', element: <NotFound /> }] export const Routes = () => { return ( <Router location={location} routes={routes}> <App> <Outlet /> </App> </Router> ) }

Finally we’ll render the exported Routes component in our application entry:

// src/main.tsx import { StrictMode } from 'react' import { render } from 'react-dom' import { Routes } from './routes' render( <StrictMode> <Routes /> </StrictMode>, document.getElementById('root'), )

Data loaders

React Location introduces this new data loaders feature that enables executing an asynchronous function for each route. If you return data from this function it will be ready for its route before it renders. You can check why should you use route loaders? why are they cool?!

Yes, loader functions are cool and plays very well with file-based routing as each route has its data loader. Having route-level loaders makes managing them easier especially when you start having many routes. It’s also a familiar pattern if you’re using full-stack React frameworks like Remix.

You can define a loader function by adding a loader property to each route object. We can access the matching route information from the loader’s first argument:

const routes = [ { path: '/users/:id', element: <User />, loader: async ({ params: { id } }, options) => { return { user: await getUser(id) } }, }, ]

In the example above, React Location will make sure that the user’s data returned by getUser function is resolved before it renders the route.

You can also wait for a parent route loader to resolve before its child by using the parentMatch from the loader’s second argument:

const routes = [ { path: '/users/:id', element: <User />, loader: async ({ params: { id } }, { parentMatch }) => { return { user: await parentMatch.loaderPromise.then(() => getUser(id)) } }, }, ]

Adding data loaders

In order to use loader functions with file-based routing we’ll need to define them on a route level. In our case, that’s very simple to handle as we lazy-load all the modules with their exports, so we can export a function from each route and use it in our routes array.

We can declare and export a loader function from each route within the src/pages directory and pass the same arguments to it as in the previous example.

Then in the src/routes.tsx configuration we’ll add a loader property for each route object. It will return the loader function with its arguments after loading the route module:

// src/routes.tsx // ... const regularRoutes = Object.keys(ROUTES).map((key) => { const route = ROUTES[key] const path = key .replace(/\/src\/pages|index|\.tsx$/g, '') .replace(/\[\.{3}.+\]/, '*') .replace(/\[(.+)\]/, ':$1') return { path, element: () => route().then((mod) => (mod?.default ? <mod.default /> : <Fragment />)), loader: (...args) => route().then((mod) => mod?.loader?.(...args)), } })

The final pattern will be as following:

// src/pages/users/[id].tsx export const loader = async ({ params: { id } }) => { return getUser(id) } export default function User() { const { data } = useMatch() return ( <div> <div>{data.email}</div> </div> ) }

Final code

The final code should be as following:

// src/routes.tsx import { Fragment } from 'react' import { Outlet, ReactLocation, Route, Router } from 'react-location' const PRESERVED = import.meta.globEager('/src/pages/(_app|404).tsx') const ROUTES = import.meta.glob('/src/pages/**/[a-z[]*.tsx') const preservedRoutes = Object.keys(PRESERVED).reduce((routes, key) => { const path = key.replace(/\/src\/pages\/|\.tsx$/g, '') return { ...routes, [path]: PRESERVED[key]?.default } }, {}) const regularRoutes: Route[] = Object.keys(ROUTES).map((key) => { const route = ROUTES[key] const path = key .replace(/\/src\/pages|index|\.tsx$/g, '') .replace(/\[\.{3}.+\]/, '*') .replace(/\[(.+)\]/, ':$1') return { path, element: () => route().then((mod) => (mod?.default ? <mod.default /> : 'Nothing exported.')), loader: (...args) => route().then((mod) => mod?.loader?.(...args)), } }) const App = preservedRoutes?.['_app'] || Fragment const NotFound = preservedRoutes?.['404'] || Fragment const location = new ReactLocation() const routes = [...regularRoutes, { path: '*', element: <NotFound /> }] export const Routes = () => { return ( <Router location={location} routes={routes}> <App> <Outlet /> </App> </Router> ) }

Additional info ⌁

React Location’s <Link> component supports route-based pre-loading for both its route’s bundle and loader function. It’s not enabled by default though. You’ll need to set the preload prop of the <Link> component to a milliseconds numeric value.

If you find yourself using the preload prop in many places, maybe you can have a custom <Link> component that wraps React Location’s with a default preload value:

// src/components/link.tsx export const Link = (props: LinkProps) => <LocationLink preload={5000} {...props} />

You can also add TypeScript route-level module typing across all routes by defining a Route type for example, using the MakeGenerics type from React Location:

// src/pages/users/[id].tsx import { LoaderFn, MakeGenerics, useMatch } from 'react-location' type User = { name: string; email: string } type Route = MakeGenerics<{ LoaderData: User; Params: { id: string } }> export const loader: LoaderFn<Route> = async ({ params: { id } }) => { return getUser(id) } export default function User() { const { data } = useMatch<Route>() // data: User // ... }

This will add type-checking to the loader function’s both arguments and return value, as well as to the useMatch hook value including data and params properties.

What’s next

You can check the React Location docs for more information about all its features, it was great trying it out. Hope this post makes you interested to learn more about it. We didn’t cover route-level loading or error states, but it should be straight forward to add.

If you want to see a working example of client-side React applications with file-based routing, data loaders, code-splitting and pre-loading, check out Render it should be updated by now.

I would love to hear what you think about this file-based routing setup with data loaders and if you have suggestions to improve it, feel free to leave a comment on the discussion. If you have questions or got stuck at some point I’ll be happy to help.

Share this post if you find it useful and stay tuned for upcoming posts.