Skip to content

Commit

Permalink
feat: loaderContext
Browse files Browse the repository at this point in the history
Breaking Changes:
- `search` and `hash` are no longer directly available in route loaders
- The route `key` option has been removed

New:
- Added a new optional route option `loaderContext`. It's a function that receives `search` and `hash` and expects you to return any serializable data from them that you require in your loader. This stable-serialized value is used to uniquely identify route matches coming from this route configuration and acts as a dependency tracker for when loaders should rerun, etc.
  • Loading branch information
tannerlinsley committed Sep 28, 2023
1 parent 0e26a51 commit 5fdf465
Show file tree
Hide file tree
Showing 20 changed files with 402 additions and 377 deletions.
27 changes: 9 additions & 18 deletions docs/api/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,23 @@ const myRoute = new Route({
id: string,
// A function to validate the search parameters for the route.
validateSearch: (search: string) => boolean,
// An async function to load or prepare any required prerequisites for the route.
loader: (match: {
// A function to provide any params or search params to the loader context
loaderContext: (opts: {
// The parsed path parameters available from this route and its parents.
params: TAllParams
param: Record<string, string>
// The parsed search parameters available from this route only.
routeSearch: TSearchSchema
// The marged and parsed search parameters available from this route and its parents.
search: TFullSearchSchema
}) =>
// The loader context for this route only. The return value must be serializable.
(TLoaderContext extends JSON.serializable),
// An async function to load or prepare any required prerequisites for the route.
loader: (match: {
// The abortController used internally by the router
abortController: AbortController
// A boolean indicating whether or not the route is being preloaded.
preload: boolean
// The context for this route only.
routeContext: TContext
// The merged context for this route and its parents.
context: TAllContext
context: TAllContext & TLoaderContext
// If there is a parent route, this will be a promise that resolves when the parent route has been loaded.
parentMatchPromise?: Promise<void>
}) =>
Expand All @@ -78,16 +79,6 @@ const myRoute = new Route({
// 🧠: The following options can be set in both the constructor AND via the Route.update method.
//
//
// If a loader is specified, this option will be required. Either return a falsey value if the `pathname` is
// enough to uniquely identify this route. If more is needed, return a unique key for this route via the function.
key?: undefined | null | false | ((loaderContext: {
// The parsed path parameters available from this route and its parents.
params: TAllParams
// The parsed search parameters available from this route only.
search: TFullSearchSchema
}) =>
// An optional but additional unique key for this route to be combined with the full path of this route.
JSON.serializable),
// If true, this route will be matched as case-sensitive
caseSensitive?: boolean,
// If true, this route will be forcefully wrapped in a suspense boundary
Expand Down
123 changes: 77 additions & 46 deletions docs/guide/data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,65 +98,31 @@ const postsRoute = new Route({
})
```

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.key` function, which can optionally be used to help uniquely identify a route match. This is useful for routes that have the same `fullPath` but different `search` or `context` values, e.g. `/posts?page=1` and `/posts?page=2`. In the case above, the pathName is sufficient to uniquely identify the route, so we pass `key: null` to disable the `key` function.
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` Parameters

The `loader` function receives a single object with the following properties:

- `params` - The route's parsed path params
- `search` - The route's search query, parsed, validated and typed **including** inherited search params from parent routes
- `routeSearch` - The route's search query, parsed, validated and typed **excluding** inherited search params from parent routes
- `hash` - The route's hash
- `context` - The route's context object **including** inherited context from parent routes
- `routeContext` - The route's context object, **excluding** inherited context from parent routes
- `context` - The route's context object, which is a merged union of:
- Parent route context
- This route's context as provided by the `beforeLoad` option
- This route's loader context as provided by the `loaderContext` option
- `abortController` - 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

## Using Path Params

The `params` property of the `loader` function is an object containing the route's path params.

```tsx
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()
},
})
```
## Using Router Context

## Using Search Params
The `context` argument passed to the `loader` function is an object containing a merged union of:

The `search` and `routeSearch` properties of the `loader` function are objects containing the route's search params. `search` contains _all_ of the search params including parent search params. `routeSearch` only includes specific search params from this route. In this example, we'll use zod to validate and parse the search params for `/posts/$postId` route, then use them in our loader.

```tsx
import { Route } from '@tanstack/react-router'

const postsRoute = new Route({
getParentPath: () => rootRoute,
path: 'posts',
key: ({ search }) => search.pageIndex,
validateSearch: z.object({
pageIndex: z.number().int().nonnegative().catch(0),
}),
loader: async ({ search: { pageIndex } }) => {
const res = await fetch(`/api/posts?page=${pageIndex}`)
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
},
})
```
- Parent route context
- This route's context as provided by the `beforeLoad` option
- This route's loader context as provided by the `loaderContext` option

## Using Context
Starting 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.

The `context` and `routeContext` properties of the `loader` function are objects containing the route's context. `context` is the context object for the route including context from parent routes. `routeContext` is the context object for the route excluding context from parent routes. In this example, we'll create a function in our route context to fetch posts, then use it in our 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.
Expand Down Expand Up @@ -199,6 +165,71 @@ const router = new Router({
})
```

## Using Path Params

To use path params in your loader, access them via the `params` property on the loader's parameters. Here's an example:

```tsx
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()
},
})
```

## Using Route Context

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:

```tsx
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(),
})
```

## Using Search Params

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.

```tsx
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()
},
})
```

## Using the Abort Signal

The `abortController` property of the `loader` function is an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/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:
Expand Down
8 changes: 2 additions & 6 deletions docs/guide/router-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ const userRoute = new Route({

## Unique Route Context

In addition to the merged context, each route also has a unique context that is stored under the `routeContext` key. This context is not merged with the parent context. This means that you can attach unique data to each route's context. Here's an example using context to create some reusable React Query logic specific to a route:
While the main router context is merged as it descends, each route's unique context is also stored making it a nice place to pass configuration or implementation details specific to your route. Here's an example where we use the route context to generate a title for each route:

```tsx
export const postIdRoute = new Route({
Expand All @@ -211,11 +211,7 @@ export const postIdRoute = new Route({
getTitle: () => `${queryClient.getQueryData(queryOptions)?.title} | Post`,
}
},
loader: async ({
preload,
context: { queryClient },
routeContext: { queryOptions },
}) => {
loader: async ({ preload, context: { queryClient, queryOptions } }) => {
await queryClient.ensureQueryData(queryOptions)
},
component: ({ useRouteContext }) => {
Expand Down
1 change: 0 additions & 1 deletion examples/react/basic/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ const indexRoute = new Route({
const postsRoute = new Route({
getParentRoute: () => rootRoute,
path: 'posts',
key: false,
loader: fetchPosts,
component: ({ useLoader }) => {
const posts = useLoader()
Expand Down
4 changes: 2 additions & 2 deletions examples/react/kitchen-sink-single-file/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -655,8 +655,8 @@ const userRoute = new Route({
}),
// Since our userId isn't part of our pathname, make sure we
// augment the userId as the key for this route
key: ({ search: { userId } }) => userId,
loader: async ({ search: { userId } }) => fetchUserById(userId),
loaderContext: ({ search: { userId } }) => ({ userId }),
loader: async ({ context: { userId } }) => fetchUserById(userId),
component: ({ useLoader }) => {
const user = useLoader()

Expand Down
6 changes: 1 addition & 5 deletions examples/react/wip-with-framer-motion/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,7 @@ const postRoute = new Route({
loaderOptions,
}
},
loader: async ({
context: { loaderClient },
routeContext: { loaderOptions },
preload,
}) => {
loader: async ({ context: { loaderClient, loaderOptions }, preload }) => {
await loaderClient.load({ ...loaderOptions, preload })
},
errorComponent: ({ error }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@ export const postRoute = new Route({
loaderOptions,
}
},
loader: async ({
context: { loaderClient },
routeContext: { loaderOptions },
}) => {
loader: async ({ context: { loaderClient, loaderOptions } }) => {
await loaderClient.load(loaderOptions)
},
errorComponent: ({ error }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import * as React from 'react'
import { ErrorComponent, Router } from '@tanstack/react-router'
import {
ErrorComponent,
FullSearchSchema,
InferFullSearchSchema,
ParseRoute,
Router,
UnionToIntersection,
} from '@tanstack/react-router'

import { rootRoute } from './routes/root'
import { indexRoute } from './routes'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
Link,
Route,
ErrorComponent,
ParseRoute,
RegisteredRouter,
InferFullSearchSchema,
} from '@tanstack/react-router'
import {
createLoaderOptions,
Expand Down Expand Up @@ -73,11 +76,7 @@ export const invoiceRoute = new Route({
loaderOptions,
}
},
loader: async ({
context: { loaderClient },
routeContext: { loaderOptions },
preload,
}) => {
loader: async ({ context: { loaderClient, loaderOptions }, preload }) => {
await loaderClient.load({
...loaderOptions,
preload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ export const userRoute = new Route({
beforeLoad: ({ params: { userId } }) => ({
loaderOpts: createLoaderOptions({ key: 'user', variables: userId }),
}),
loader: async ({
context: { loaderClient },
routeContext: { loaderOpts },
preload,
}) =>
loader: async ({ context: { loaderClient, loaderOpts }, preload }) =>
loaderClient.load({
...loaderOpts,
preload,
Expand Down
16 changes: 4 additions & 12 deletions examples/react/with-loaders-kitchen-sink-single-file/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -475,11 +475,7 @@ const invoiceRoute = new Route({

return { loaderOptions }
},
loader: async ({
preload,
context: { loaderClient },
routeContext: { loaderOptions },
}) => {
loader: async ({ preload, context: { loaderClient, loaderOptions } }) => {
await loaderClient.load({
...loaderOptions,
preload,
Expand Down Expand Up @@ -763,20 +759,16 @@ const userRoute = new Route({
}),
// Since our userId isn't part of our pathname, make sure we
// augment the userId as the key for this route
key: ({ search: { userId } }) => userId,
beforeLoad: ({ search: { userId } }) => {
loaderContext: ({ search: { userId } }) => ({ userId }),
beforeLoad: ({ context: { userId } }) => {
const loaderOptions = createLoaderOptions({
key: 'user',
variables: userId,
})

return { loaderOptions }
},
loader: async ({
context: { loaderClient },
routeContext: { loaderOptions },
preload,
}) => {
loader: async ({ context: { loaderClient, loaderOptions }, preload }) => {
await loaderClient.load({ ...loaderOptions, preload })
},
component: ({ useRouteContext, useLoader }) => {
Expand Down
6 changes: 1 addition & 5 deletions examples/react/with-loaders-ssr/src/routes/posts/$postId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,7 @@ export const postIdRoute = new Route({
loaderOptions,
}
},
loader: async ({
context: { loaderClient },
routeContext: { loaderOptions },
preload,
}) => {
loader: async ({ context: { loaderClient, loaderOptions }, preload }) => {
await loaderClient.load({
...loaderOptions,
preload,
Expand Down
10 changes: 2 additions & 8 deletions examples/react/with-react-query/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,7 @@ const postsRoute = new Route({
beforeLoad: () => {
return { queryOptions: { queryKey: ['posts'], queryFn: fetchPosts } }
},
loader: async ({
context: { queryClient },
routeContext: { queryOptions },
}) => {
loader: async ({ context: { queryClient, queryOptions } }) => {
await queryClient.ensureQueryData(queryOptions)
},
component: ({ useRouteContext }) => {
Expand Down Expand Up @@ -141,10 +138,7 @@ const postRoute = new Route({

return { queryOptions }
},
loader: async ({
context: { queryClient },
routeContext: { queryOptions },
}) => {
loader: async ({ context: { queryClient, queryOptions } }) => {
await queryClient.ensureQueryData(queryOptions)
},
component: ({ useRouteContext }) => {
Expand Down
Loading

0 comments on commit 5fdf465

Please sign in to comment.