React Location is a powerful new routing library that is battle-tested for enterprise level React applications. Released in November of 2021, it is made by Tanner Linsley of TanStack. I was really impressed by his work with react-query so I sort of jumped at the chance to use this new library in a rewrite of an existing admin portal that I just started. In this article I’ll first talk about why you might consider switching to react-location, then I’ll show you how to implement it in an existing react application that uses react-router. We’ll also take a look at using it together with React Query.
The main reason for switching to React Location is simply that you get powerful, out-of-the-box features that normally require multiple dependencies. Some of these features include:
With React Location, you can define a route loader for fetching data as soon as there is a route match, then specify what to show for both pending and error states in the same place as the route is defined, effectively keeping all of that out of the component. This is easily one of my favorite features because it removes a lot of boilerplate and keeps your components tidier.
React Location allows you the opportunity to hold application state in the URL. The admin dashboard I am currently working on includes a users table with components for filter and sort. When I’m done, there will be enough filter functions that an administrator can easily spend some time refining a search. An administrator will then often refer back to it, sometimes several times in a day. It is therefore extremely valuable to be able to get back to that state without having to enter those values each time. With react-location’s built-in query param API, user’s can bookmark or save the link for later use.
Setting a preload
parameter on the Link object will preload the route’s data when the user hovers over it in anticipation of the user’s click, provided that it has a route loader in place for that route. This is really handy for data-intensive API calls or slower APIs where the route loader isn’t enough.
There are lot of other features including parallelized component-splitting and nested routing, and the ability to pretty much access anything you need from the hooks provided by React Location. We’ll cover some of these features here, and I’ll be doing a separate post that covers search state with React Location.
If you’re not convinced to switch to React Location yet, you will be once you see how easy it is to work with. The first thing we need to do is add react-location to the project:
npm install react-location
or yarn add react-location
or, in my case, rush add -p react-location
.
Once this is done, we must make some changes to the App.tsx file that wraps the routes with the BrowserRouter
from react-router-dom
. Let’s start by adding some imports:
import {Outlet, ReactLocation, Router, createBrowserHistory} from 'react-location';
Don’t worry, we’ll talk about what each of these imports do and how to use them shortly. If you’re using react-router-dom, you’re probably importing BrowserRouter
from it, so you’re going to want to remove that. Assuming you have a separate Routes component where routes are specified, you’ll likely be importing that into the App component that is wrapped by BrowserRouter
, so you’ll want to replace that import with:
import getRoutes from './Routes';
.
So we’re going from this:
import React from 'react' import { BrowserRouter } from 'react-router-dom' import Routes from './Routes'
To this:
import React from 'react' import { Outlet, ReactLocation, Router, createBrowserHistory } from 'react-location' import getRoutes from './Routes'
You’ll then instantiate the createBrowserHistory()
and ReactLocation
objects:
const history = createBrowserHistory() const location = new ReactLocation({ history })
Now we’re going to modify the App component by removing BrowserRouter
and adding the Router
from react-location:
Before:
<BrowserRouter basename={'/'}> <Routes /> </BrowserRouter>
After:
<Router location={location} routes={getRoutes({ history })}> <Outlet /> </Router>
We also have additional context providers including react-query’s QueryClientProvider
in our app that wrap the Outlet
and Router
, but I removed them to keep things simple. However, you will need the QueryClientProvider
if you plan to use React Location with React Query. We’ll first do some refactoring on the public routes in our Routes component, which currently looks like this:
import * as React from 'react' import { Route, Switch, useHistory } from 'react-router-dom' import { LoadingSpinner } from 'modules/common' import { LogoutCallback, Logout, PrivateArea, Callback, SilentRenew } from 'ux-auth' import { Dashboard } from 'modules/dashboard' export const Routes = () => { const history = useHistory() return ( <Switch> <Route exact path='/callback'> <Callback history={history} /> </Route> <Route exact path='/silentrenew'> <SilentRenew /> </Route> <Route exact path='/logoutcallback'> <LogoutCallback redirectTo={'/'} history={history} /> </Route> <Route exact path='/logout'> <Logout /> </Route> <PrivateArea> <Dashboard /> </PrivateArea> </Switch> ) } export default Routes
In our application we have two sets of routes, one for public and one for private routes. Before React Location, we had the private routes located in a separate PrivateRoutes file, but we can move those to the same location now so the getRoutes
call returns both. We only have one private route right now though as we’re just starting this dashboard project. Here is what the Routes
file looks like after integrating React Location:
import * as React from 'react' import { LoadingSpinner } from 'modules/common' import { LogoutCallback, Logout, PrivateArea, Callback, SilentRenew } from 'ux-auth' import { Dashboard } from 'modules/dashboard' import { UsersView } from '../user-store' import { History } from 'history' const getPublicRoutes = ({ history }: { history: History }) => [ { path: 'callback', element: <Callback pendingElement={<LoadingSpinner />} history={history} /> }, { path: 'logout', element: <Logout pendingElement={<LoadingSpinner />} /> }, { path: 'logoutcallback', element: ( <LogoutCallback pendingElement={<LoadingSpinner />} redirectTo={'/'} history={history} /> ) }, { path: 'silentrenew', element: <SilentRenew pendingElement={<LoadingSpinner />} /> } ] const getPrivateRoutes = () => [ { path: '/', element: ( <PrivateArea pendingElement={<LoadingSpinner />}> <Dashboard /> </PrivateArea> ), children: [ { path: 'users', element: <UsersView /> } ] } ] export const getRoutes = ({ history }: { history: History }) => [ ...getPublicRoutes({ history }), ...getPrivateRoutes() ] export default getRoutes
Once you’ve done this, you’ll need to change any imports of Link
from react-router-dom to import {Link} from 'react-location'
. Since the Link component is operationally the same between react-router-dom and react-location, you won’t need to make any other changes to your navigation Link declarations. However, the Link object in React Location includes some additional parameters/features you may want to take advantage of, one of which is preload
, which we’ll explore later.
Now let’s move to the Dashboard module of our application, which is the private area entry point of the app once authenticated. We’re going to need to put an Outlet
here as well for react-location to render the children of the Dashboard element.
import React from 'react' import Header from './components/Header' import NavBar from './components/NavBar' import { Page, ErrorBoundary } from 'modules/common' import { Outlet, Link } from 'react-location' export function Dashboard() { return ( <ErrorBoundary> <Page.Wrapper> <Header /> <NavBar /> <Page.MainContent> <Outlet /> </Page.MainContent> </Page.Wrapper> </ErrorBoundary> ) }
Believe it or not, that is all we need to do to get the application working with react-location instead of react-router-dom. So let’s move on to the fun stuff!
Route loaders are insanely awesome and are one of the coolest things you can do with React Location. Loaders allow you to start an async query immediately when a route is matched instead of waiting for the route to be rendered before data is available.
Why are Route Loaders so Cool?
- Faster page loads
- Avoid spinner swamps
- Less boilerplate, especially in conjunction with loaders, pending elements, and error states
- Cleaner containers
- Better user experience
- Better developer experience
Route loaders don’t care how you fetch your data. We use react-query in conjuction with Axios, but you can use any means to acquire data, as long as you return a promise that resolves the data object for that route.
We’re going to modify getPrivateRoutes
to use a loader when there is a route match on /Administration/UsersView
const getPrivateRoutes = () => [ { path: '/', element: ( <PrivateArea> <Dashboard /> <PrivateArea /> ), children: [ { path: 'UsersView', element: <UsersView />, loader: async() => ({ usersList: await getUsersList(); }) }, ], }, ];
Adding a child of the UsersView to display an individual user when a user is selected is super easy using nested children:
const getPrivateRoutes = () => [ { path: '/', element: ( <PrivateArea> <Dashboard /> <PrivateArea /> ), children: [ { path: 'users', element: <UsersView />, loader: async() => ({ return { usersList: await getUsersList(), } }), children: [ { path: ":userId", element: <User />, loader: async ({ params: { userId } }) => { return { user: await getUserById(userId), } } } ] }, ], }, ];
Ironically, I came across this “kitchen sink” example that has a very similar example as I was writing this. It also has a lot more great examples of how to use all of the features of React Location so definitely check it out!
One thing that is important to understand about route loaders is that when a route is matched, each loader on that path will run in parallel. For example, if I load the route: /users/[userId], and each of the routes in this path have a loader assigned, then both loaders will run at the same time. While this gives you a nice performance boost, let’s say the data loader for /[userId] depends on the data recieved by /users. If a specific /[userId] page were bookmarked, it would error out if you tried navigating directly to it. In such a scenario we would need to run the loaders in serial, by telling the /[userId] route to wait for the parent’s loader, like so:
const getPrivateRoutes = () => [ { path: '/', element: ( <PrivateArea> <Dashboard /> <PrivateArea /> ), children: [ { path: 'users', element: <UsersView />, loader: async() => ({ return { usersList: await getUsersList(), } }), children: [ { path: ":userId", element: <User />, loader: async ({ params: { userId }}, { parentMatch }) => ({ user: await parentMatch.loaderPromise.then(({ users }) => users.find((user) => user.id === userId) ), }), } ] }, ], }, ];
I use react-query in all my projects, and I highly recommend it, so let’s look at how we can make this same example work with react-query.
We’re going to use the QueryClient
from react-query in our loader. However, since our routes are in a different location from where the QueryClientProvider
is instantiated, we have to pass it to the getRoutes
function that is passed to the Router’s route
prop in our App component.
App.tsx:
const queryClient = new QueryClient(); const history = createBrowserHistory(); const location = new ReactLocation({history}); function App() { return ( <QueryClientProvider client={queryClient}> <Router location={location} routes={getRoutes({queryClient, history})}> <Outlet /> </Router> </QueryClientProvider>
In our getPrivateRoutes
function located in the Routes.tsx file, we’re going to use the QueryClient’s getQueryData
to get the data and pass it the query key that is assigned to this data service so it knows what data we’re looking for. If the data is not already available from react-query, meaning the query has not been run yet during the session, then we’ll use the fetchQuery
call from QueryClient
to get it by passing the query function.
Here is what our getPrivateRoutes
function in the Routes.tsx now looks like using React Query with a Loader for the users view:
const getPrivateRoutes = ({queryClient}: {queryClient: QueryClient}) => [ { path: '/', element: ( <PrivateArea> <Dashboard /> <PrivateArea /> ), children: [ { path: 'users', element: <UsersView />, loader: () => queryClient.getQueryData('users') ?? queryClient.fetchQuery(['users'], () => getUsers() ), }, ], }, ];
Then in getRoutes
, which is what is passed to the Router’s routes
prop, we have the following:
export const getRoutes = ( { queryClient }: { queryClient: QueryClient }, { history }: { history: History } ) => [...getPublicRoutes({ history }), ...getPrivateRoutes({ queryClient })]
Descriptive error messaging is a nice feature that tells you exactly what’s wrong in developer mode without having to open the console. !(‘./RL-error-message.png’)
I knew it was working right away when I ran the app and didn’t even see a loading spinner when the users page loaded. Checking the Network console, I could see that the call was now being made the moment I clicked the link rather than having it run after the component rendered. While the difference is maybe 1-2 seconds on a fast connection, the difference in responsiveness is very noticeable.
Along with loaders, moving pending and error states to the router is one of the most badass features of React Location. A lot of boilerplate code cluttering up your containers can be avoided by using these features.
React Location has a suspense-like pending state when a route is being loaded. Without data requirements or async dependencies, a route’s pending state will never be observed. However, if it does have either of these requirements, you can add a global pending element to be displayed across the application while in this state, have the element triggered by a timeout, or you can specify a location-specific UI.
For our purposes we’ll add a pending element to the route loader to be triggered by a timeout. We’ll use the pendingMs
param to tell the loader when to trigger the loading spinner, which we’ll set to two seconds. We’ll also use the pendingMinMs
param to tell the UI how long to display the spinner for, and we’ll set that to 2 seconds as well. This way, if we get a response in under two seconds, the spinner will never be shown. However, if it is shown, it will display for a minimum of two seconds to avoid “flashing UI syndrome”. We’ll also add an errorElement
to the route to be displayed if there is an error from the request.
const getPrivateRoutes = ({queryClient}: {queryClient: QueryClient}) => [ { path: '/', element: ( <PrivateArea> <Dashboard /> <PrivateArea /> ), children: [ { path: 'users', element: <UsersView />, pendingElement: async () => <LoadingSpinner />, errorElement: async () => ( <WarningAlert>There was an error retrieving the users for this tenant.</WarningAlert> ), PendingMs: 2000, PendingMinMs: 2000, loader: () => queryClient.getQueryData('users') ?? queryClient.fetchQuery(['users'], () => getUsers() ), }, ], }, ];
The last thing we’ll talk about is adding preloaders with React Location. Preloaders allow for the application to query the loader function when the user hovers over the link to that route. Using the React Location Link object, you can specify a preload
parameter, passing it the number of milliseconds to keep the data around.
<Link to={item.linkTo} preload={5000} className='submenuLink'> Users </Link>
This is a really nice feature of React Location that doesn’t require the insallation of another library to accomplish as you would with React Router. This can be especially beneficial for data-intensive API calls or just slow APIs when having the data load asyncronously with the page render might not be enough.
React Location is a badass library that trumps React Router and provides out-of-the-box features that normally require multiple added dependencies. Props to Tanner Linsley for his work putting this together. In my next post, I’ll show how to add search and filter with React Location, which allow you to use the url for holding the UI state so users can save the url and come back to it after closing the session without having to enter search and filter criteria again.