Data loading is a common concern for web applications and is extremely related to routing. When loading any page for your app, it's ideal if all of the async requirements for those routes are fetched and fulfilled as early as possible and in parallel. The router is the best place to coordinate all of these async dependencies as it's usually the only place in your app that knows about where users are headed before content is rendered.
You may be familiar with getServerSideProps
from Next.js or loaders
from Remix/React-Router. TanStack Router is designed with similar functionality to preload assets on a per-route basis in parallel and optionally store and retrieve it in your components as well.
Most application routers, if they support route loading at all, will fetch data for new routes matches as they enter the application during navigation. Consider the following navigation flow:
/posts/123
pathname.
/
, /posts
and /posts/$postId
load in parallel:
/
/posts
/posts/$postId
(with postId
=== 123
)/posts/456
pathname
/
and /posts
loaders are skipped because they have already been loaded.$postId
match detects a change in params to 456
and the following loaders are called:
/posts/$postId
(with postId
=== 123
)/
pathname
/
loader is skipped because it has already been loaded./posts
pathname
/
loader is skipped because it has already been loaded./posts
match is detected as new and the following loaders are called:
/posts
From the flow above, you'll notice that
Infinity
until it is no longer in use (or invalidated)Defaults are what make tools great, but as you might have guessed, there are plenty of ways to configure route match caching and garbage collection. Let's go over some concepts and terminology.
maxAge
- The maximum amount of time in milliseconds a route match should be considered "fresh". Defaults to Infinity
.gcMaxAge
- The amount of time in milliseconds an unused/inactive route match will be held in memory before it is garbage collected. Defaults to 0
.preloadMaxAge
- The amount of time in milliseconds an unused/preloaded route match will be held in memory before it is garbage collected. Defaults to 10_000
.Similar to TanStack Query, TanStack Router has some awesome caching utilities built-in, but contrary to Query, Router has the following defaults:
maxAge: Infinity
gcMaxAge: 0
This means that, by default, all route matches are cached forever until they are no longer in use at which point they are garbage collected immediately. This is a great default for most applications using the router as a data fetcher since it compliments nested routing patterns and meets the user at their expectations that only the parts of the screen change will be loaded. These options can be changed though!
The following options can modify the caching behavior of a route match:
maxAge
gcMaxAge
preloadMaxAge
The following options can modify the default behavior of all route matches:
defaultMaxAge
defaultGcMaxAge
defaultPreloadMaxAge
Route loaders are functions that are called when a route match is loaded. They are called with a single parameter which is an object containing many helpful properties. We'll go over those in a bit, but first, let's look at an example of a route loader:
import { Route } from '@tanstack/react-router'
const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', loader: async () => { // Load our posts const res = await fetch(`/api/posts`) if (!res.ok) throw new Error('Failed to fetch posts') return res.json() },})
The data returned from the loader is stored in a unique RouteMatch
that is identified by the route's fullPath
and optionally, the result of the routeOptions.loaderContext
function, which is required to use any path params, search params or hash in a loader. This ensures that routes with the same fullPath
but different variables e.g. /posts?page=1
and /posts?page=2
are stored separately. In the case above, the pathName is sufficient to uniquely identify the route and it doesn't need any access to path params, search params or hash, so loaderContext
is not required.
loader
ParametersThe loader
function receives a single object with the following properties:
context
- The route's context object, which is a merged union of:
beforeLoad
optionloaderContext
optionabortController
- The route's abortController. Its signal is cancelled when the route is unloaded or when the loader
call becomes outdated.Using these parameters, we can do a lot of cool things. Let's take a look at a few examples
The context
argument passed to the loader
function is an object containing a merged union of:
beforeLoad
optionloaderContext
optionStarting at the very top of the router, you can pass an initial context to the router via the context
option. This context will be available to all routes in the router and get copied and extended by each route as they are matched. This happens by passing a context to a route via the beforeLoad
option. This context will be available to all child routes of the route. Finally, you can pass a context to a route via the loaderContext
option. This context will be available to the route's loader.
In this example, we'll create a function in our route context to fetch posts, then use it in our loader.
🧠Context is a powerful tool for dependency injection. You can use it to inject services, loaders, and other objects into your router and routes. You can also additively pass data down the route tree at every route using a route's
beforeLoad
option.
import { Route } from '@tanstack/react-router'
const fetchPosts = async () => { const res = await fetch(`/api/posts?page=${pageIndex}`) if (!res.ok) throw new Error('Failed to fetch posts') return res.json()}
// Create a new routerContext using new RouterContext<{...}>() class and pass it whatever types you would like to be available in your router context.const routerContext = new RouterContext<{ fetchPosts: typeof fetchPosts}>()
// Then use the same routerContext to create your root routeconst rootRoute = routerContext.createRootRoute()
// Notice how our postsRoute references context to get our fetchPosts function// This can be a powerful tool for dependency injection across your router// and routes.const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', loader({ context: { fetchPosts } }) => fetchPosts(),})
const routeTree = rootRoute.addChildren([postsRoute])
// Use your routerContext to create a new router// This will require that you fullfil the type requirements of the routerContextconst router = new Router({ routeTree, context: { // Supply the fetchPosts function to the router context fetchPosts, },})
To use path params in your loader, access them via the params
property on the loader's parameters. Here's an example:
import { Route } from '@tanstack/react-router'
const postRoute = new Route({ getParentPath: () => postsRoute, path: '$postId', loader: ({ params: { postId } }) => { const res = await fetch(`/api/posts/${postId}`) if (!res.ok) throw new Error('Failed to fetch posts') return res.json() },})
Passing down global context to your router is great, but what if you want to provide context that is specific to a route? This is where the beforeLoad
option comes in. The beforeLoad
option is a function that runs right before attempting to load a route and receives the same loader
parameters. Beyond its ability to redirect potential matches, it can also return an object that will be merged into the route's context. Let's take a look at an example where we provide fetchPosts
to our route context via the beforeLoad
option:
import { Route } from '@tanstack/react-router'
const fetchPosts = async () => { const res = await fetch(`/api/posts?page=${pageIndex}`) if (!res.ok) throw new Error('Failed to fetch posts') return res.json()}
const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', // Pass the fetchPosts function to the route context beforeLoad: () => ({ fetchPosts }), loader({ context: { fetchPosts } }) => fetchPosts(),})
Search parameters can be accessed via the loaderContext
function. The loaderContext
's search
property contains all of the search params including parent search params, of which you can choose which search params you specifically rely on in your loader. In this example, we'll use zod to validate and parse the search params for the /posts
route that uses pagination, then pass them and use them in our loader.
import { Route } from '@tanstack/react-router'
const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', // Use zod to validate and parse the search params validateSearch: z.object({ pageIndex: z.number().int().nonnegative().catch(0), }), // Pass the pageIndex to the loader context loaderContext: ({ search: { pageIndex } }) => ({ pageIndex }), // Use the pageIndex from context in the loader loader: async ({ context: { pageIndex } }) => { const res = await fetch(`/api/posts?page=${pageIndex}`) if (!res.ok) throw new Error('Failed to fetch posts') return res.json() },})
The abortController
property of the loader
function is an AbortController. Its signal is cancelled when the route is unloaded or when the loader
call becomes outdated. This is useful for cancelling network requests when the route is unloaded or when the route's params change. Here is an example using it with a fetch call:
import { Route } from '@tanstack/react-router'
const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', loader: ({ abortController }) => { const res = await fetch(`/api/posts?page=${pageIndex}`, { signal: abortController.signal, }) if (!res.ok) throw new Error('Failed to fetch posts') return res.json() },})
preload
flagThe preload
property of the loader
function is a boolean which is true
when the route is being preloaded instead of loaded. Some data loading libraries may handle preloading differently than a standard fetch, so you may want to pass preload
to your data loading library, or use it to execute the appropriate data loading logic. Here is an example using TanStack Loaders and its built-in preload
flag:
import { Route } from '@tanstack/react-router'
// Create a new loaderconst postsLoader = new Loader({ key: 'posts', fn: async (params) => { const res = await fetch(`/api/posts`) if (!res.ok) throw new Error('Failed to fetch posts') return res.json() },})
// Create a new loader clientconst loaderClient = new LoaderClient({ loaders: [postsLoader],})
const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', loader: async ({ preload }) => { // Passing the preload flag to the loader client // will enforce slightly different caching behavior // in TanStack Loaders caching logic await loaderClient.load({ key: 'posts', preload }) }, component: ({ useLoader }) => { const { data: posts } = useLoaderInstance({ key: 'posts' })
return <div>...</div> },})
The data returned from the loader can be retrieved a few different ways:
props.useLoader
hookroute.useLoader
hookuseLoader
hookEach is available to allow access to the loader data at different contexts and are in order from simplest to most flexible.
Let's retrieve the data from our loader using the props.useLoader
hook:
import { Route } from '@tanstack/react-router'
const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', loader: async () => { // ... }, component: ({ useLoader }) => { const posts = useLoader() return <div>...</div> },})
By manipulating the maxAge
option, we can create a stale-while-revalidate pattern for our route matches and their loaders. This is useful for routes that may have frequently changing data caused by external events. Let's take a look at an example:
import { Route } from '@tanstack/react-router'
const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', loader: async () => { // Load our posts const res = await fetch(`/api/posts`) if (!res.ok) throw new Error('Failed to fetch posts') return res.json() }, maxAge: 10_000,})
This route's maxAge
is set to 10_000
milliseconds (10 seconds). This means that the route match data will be considered "fresh" for 10 seconds.
Enabling patterns like stale-while-revalidate
is great, but what if we want to know when our data is stale? This is where the match state comes in. Match state is available via a few different hooks:
props.useMatch
route.useMatch
useMatch
They are ordered from simplest to most flexible.
The result of these hooks has a lot of useful information about a route match, but on the topic of stale-while-revalidate
, one property is particularly useful:
isFetching
boolean
This property is true
when the route match is being loaded and false
when it is not. This means that we can use it to display to our users that this particular route's loader data is being refreshed in the background. Let's take a look at an example:
import { Route } from '@tanstack/react-router'
const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', loader: async () => { // ... }, maxAge: 10_000, component: ({ useMatch }) => { const { isFetching } = useMatch() return <div> {isFetching ? Loading... : null} ... </div> },})
By default, route matches are never considered stale and garbage collected when they are no longer in use. This is great mostly for loaders that only change when navigation occurs, but what about loaders that change when user events occur?
Our apps often contain events that could modify the results of our loaders. For example, a user may create a new post, or a user may delete a post. In these cases, we would want to invalidate the loader data for /posts
and /posts/$postId
so the user will see the latest data.
The easiest way to do this is by invalidating all route matches with router.invalidate
:
function App() { const router = useRouter
const mutate = () => { //... some mutation logic router.invalidate() }}
router.invalidate
will invalidate all route matches and by default, reload the currently matched routes. For any route matches that are not currently in use and not garbage collected, they will be marked as invalid and their loader will be called again when they are matched or preloaded.
If you want to invalidate specific route matches, you can use the same router.invalidate
method, but pass a matchId
option to it:
function App() { const router = useRouter
const mutate = () => { //... some mutation logic router.invalidate({ matchId: '/posts' }) }}
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.