Skip to content

Commit

Permalink
fix: key-less actions and better client
Browse files Browse the repository at this point in the history
  • Loading branch information
tannerlinsley committed Jul 10, 2023
1 parent af853c4 commit d1b60a5
Show file tree
Hide file tree
Showing 10 changed files with 153 additions and 177 deletions.
86 changes: 44 additions & 42 deletions docs/guide/data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ import { Route } from '@tanstack/router'
import { Loader, useLoader } from '@tanstack/react-loaders'

const postsLoader = new Loader({
key: 'posts',
loader: async (params) => {
fn: async (params) => {
const res = await fetch(`/api/posts`)
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
Expand All @@ -75,13 +74,16 @@ const postsLoader = new Loader({
const postsRoute = new Route({
getParentPath: () => rootRoute,
path: 'posts',
async loader() {
// Wait for the loader to finish
fn: async () => {
// Ensure our loader is loaded
await postsLoader.load()

// Return a hook fn we can use in our component
return () => useLoader({ loader: postsLoader })
},
component: () => {
// Access the loader's data, in this case with the useLoader hook
const posts = useLoader({ loader: postsLoader })
component: ({ useLoader }) => {
// Access the hook we made in the loader function (and call it)
const posts = useLoader()()

return <div>...</div>
},
Expand Down Expand Up @@ -111,9 +113,8 @@ import { Route } from '@tanstack/router'
import { Loader, useLoader } from '@tanstack/react-loaders'

const postLoader = new Loader({
key: 'post',
// Accept a postId string variable
loader: async (postId: string) => {
fn: async (postId: string) => {
const res = await fetch(`/api/posts/${postId}`)
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
Expand All @@ -124,11 +125,13 @@ const postRoute = new Route({
getParentPath: () => postsRoute,
path: '$postId',
async loader({ params }) {
// Load our loader
await postLoader.load({ variables: params.postId })
// Return a hook fn we can use in our component
return () => useLoader({ loader: postLoader, variables: params.postId })
},
component: () => {
const { postId } = useParams({ from: postRoute.id })
const posts = useLoader({ loader: postLoader, variables: postId })
component: ({ useLoader }) => {
const posts = useLoader()()

return <div>...</div>
},
Expand All @@ -144,9 +147,8 @@ import { Route } from '@tanstack/router'
import { Loader, useLoader } from '@tanstack/react-loaders'

const postsLoader = new Loader({
key: 'posts',
// Accept a page number variable
loader: async (pageIndex: number) => {
fn: async (pageIndex: number) => {
const res = await fetch(`/api/posts?page=${pageIndex}`)
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
Expand All @@ -160,14 +162,13 @@ const postsRoute = new Route({
pageIndex: z.number().int().nonnegative().catch(0),
}),
async loader({ search }) {
// Load our loader
await postsLoader.load({ variables: search.pageIndex })
// Return a hook fn we can use in our component
return () => useLoader({ loader: postsLoader, variables: search.pageIndex })
},
component: () => {
const search = useSearchParams({ from: postsRoute.id })
const posts = useLoader({
loader: postsLoader,
variables: search.pageIndex,
})
component: ({ useLoader }) => {
const posts = useLoader()()

return <div>...</div>
},
Expand All @@ -176,7 +177,7 @@ const postsRoute = new Route({

## Using Context

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 `loaderClient` and inject it into our router's context. We'll then use that client in our `loader` function and our component.
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 TanStack Loader `loaderClient` instance and inject it into our router's context. We'll then use that client in our `loader` function and our component.

> 🧠 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 `getContext` option.
Expand All @@ -185,16 +186,15 @@ import { Route } from '@tanstack/router'
import { Loader, useLoader } from '@tanstack/react-loaders'

const postsLoader = new Loader({
key: 'posts',
loader: async () => {
fn: async () => {
const res = await fetch(`/api/posts`)
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
},
})

const loaderClient = new LoaderClient({
getLoaders: () => [postsLoader],
getLoaders: () => ({ postsLoader }),
})

// Use RootRoute's special `withRouterContext` method to require a specific type
Expand All @@ -205,17 +205,19 @@ const rootRoute = RootRoute.withRouterContext<{
loaderClient: typeof loaderClient
}>()()

// Notice how our postsRoute reference context to get the loader client
// Notice how our postsRoute references context to get the loader client
// This can be a powerful tool for dependency injection across your router
// and routes.
const postsRoute = new Route({
getParentPath: () => rootRoute,
path: 'posts',
async loader({ context }) {
await context.loaderClient.getLoader({ key: 'posts' }).load()
const { postsLoader } = context.loaderClient
await postsLoader.load()
return () => useLoader({ loader: postsLoader })
},
component: () => {
const posts = useLoader({ key: 'posts' })
component: ({ useLoader }) => {
const posts = useLoader()()

return <div>...</div>
},
Expand All @@ -241,9 +243,8 @@ import { Route } from '@tanstack/router'
import { Loader, useLoader } from '@tanstack/react-loaders'

const postsLoader = new Loader({
key: 'posts',
// Accept a page number variable
loader: async (pageIndex: number, { signal }) => {
fn: async (pageIndex: number, { signal }) => {
const res = await fetch(`/api/posts?page=${pageIndex}`, { signal })
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
Expand All @@ -256,26 +257,26 @@ const postsRoute = new Route({
async loader({ signal }) {
// Pass the route's signal to the loader
await postsLoader.load({ signal })
return () => useLoader({ loader: postsLoader })
},
component: () => {
const posts = useLoader({ loader: postsLoader })
component: ({ useLoader }) => {
const posts = useLoader()()

return <div>...</div>
},
})
```

## Using the `prefetch` flag
## Using the `preload` flag

The `prefetch` property of the `loader` function is a boolean which is `true` when the route is being loaded via a prefetch action. Some data loading libraries may handle prefetching differently than a standard fetch, so you may want to pass `prefetch` to your data loading library, or use it to execute the appropriate data loading logic. Here is an example using TanStack Loader and it's built-in `prefetch` flag:
The `preload` property of the `loader` function is a boolean which is `true` when the route is being loaded via a preload action. 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 Loader and it's built-in `preload` flag:

```tsx
import { Route } from '@tanstack/router'
import { Loader, useLoader } from '@tanstack/react-loaders'

const postsLoader = new Loader({
key: 'posts',
loader: async () => {
fn: async () => {
const res = await fetch(`/api/posts?page=${pageIndex}`)
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
Expand All @@ -285,19 +286,20 @@ const postsLoader = new Loader({
const postsRoute = new Route({
getParentPath: () => rootRoute,
path: 'posts',
async loader({ prefetch }) {
// Pass the route's prefetch to the loader
await postsLoader.load({ prefetch })
async loader({ preload }) {
// Pass the route's preload to the loader
await postsLoader.load({ preload })
return () => useLoader({ loader: postsLoader })
},
component: () => {
const posts = useLoader({ loader: postsLoader })
component: ({ useLoader }) => {
const posts = useLoader()()

return <div>...</div>
},
})
```

> 🧠 TanStack Loaders uses the `prefetch` flag to determine cache freshness vs non-prefetch calls and also to determine if the global `isLoading` or `isPrefetching` flags should be incremented or not.
> 🧠 TanStack Loaders uses the `preload` flag to determine cache freshness vs non-preload calls and also to determine if the global `isLoading` or `isPrefetching` flags should be incremented or not.
## Learn more about TanStack Loaders/Actions!

Expand Down
48 changes: 15 additions & 33 deletions docs/guide/data-mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Similar to data fetching, mutation state isn't a one-size-fits-all solution, so

## TanStack Actions

Just like a fresh Zelda game, we would never send you into the wild without a sword. We've created an extremely lightweight, framework agnostic action/mutation library called TanStack Actions that works really well with Router. It's a great place to start if you're not already using one of the more complex (but more powerful) tools above.
Just like a fresh Zelda game, we would never send you into the wild without a sword (fine... BotW and TotK bend this rule slightly, but since they're the greatest games ever created, we'll let the lore slide a bit). We've created an extremely lightweight, framework agnostic action/mutation library called TanStack Actions that works really well with Router. It's a great place to start if you're not already using one of the more complex (but more powerful) tools above.

## What are data mutations?

Expand All @@ -45,8 +45,7 @@ Let's write a data mutation that will update a post on a server. We'll use TanSt
import { Action } from '@tanstack/actions'

const updatePostAction = new Action({
name: 'updatePost',
async action(post: Post) {
fn: async (post: Post) => {
const response = await fetch(`/api/posts/${post.id}`, {
method: 'PATCH',
body: JSON.stringify(post),
Expand All @@ -66,16 +65,9 @@ Now that we have our action, we can use it in our component. We'll use the `useA
```tsx
import { useAction } from '@tanstack/react-actions'

function PostEditor() {
const params = useParams({ from: postEditRoute.id })
const postLoader = useLoader({
key: 'post',
variables: params.postId,
})

const [postDraft, setPostDraft] = useState<Post>(() => postLoader.state.data)
function PostEditor({ post }: { post: Post }) {
const [postDraft, setPostDraft] = useState<Post>(() => post)
const updatePost = useAction({ action: updatePostAction })

const latestPostSubmission = updatePost.state.latestSubmission

return (
Expand All @@ -98,8 +90,7 @@ So how does my data loader get the updated data? **Invalidation**. When you muta
import { Action } from '@tanstack/actions'

const updatePostAction = new Action({
name: 'updatePost',
async action(post: Post) {
fn: async (post: Post) => {
//...
},
onEachSuccess: () => {
Expand All @@ -113,38 +104,36 @@ const updatePostAction = new Action({

## Invalidating specific data

Again, we'll assume we're using TanStack Actions here, but it's also possible to use the action submission state to invalidate specific data. Let's update our action to invalidate a specific post.
Again, we'll assume we're using TanStack Actions here, but it's also possible to use the action submission state to invalidate specific data. Let's update our action to invalidate a specific post loader instance using the loader's `invalidateInstance` method.

```tsx
import { Action } from '@tanstack/actions'

const updatePostAction = new Action({
name: 'updatePost',
async action(post: Post) {
fn: async (post: Post) => {
//...
},
onEachSuccess: (submission) => {
// Use the submission payload to invalidate the specific post
const post = submission.payload
postsLoader.invalidate({ variables: post.id })
postsLoader.invalidateInstance({ variables: post.id })
},
})
```

## Invalidating entire data sets

It's very common to invalidate an entire subset of data based on a query key when some subset of that data changes e.g. Refetching all posts when a single post is edited. One of the best reasons to do this is that you can never really be sure of the side-effects a mutation will have on server-side data. It could remove/add elements, reorder them, or change their inclusion in specific filtered lists. TanStack Loaders comes with the `invalidateAll` method to invalidate all data for a given query key.
It's very common to invalidate an entire subset of data based on hierarchy when some subset of that data changes e.g. Refetching all posts when a single post is edited. One of the best reasons to do this is that you can never really be sure of the side-effects a mutation will have on server-side data. It could remove/add elements, reorder them, or change their inclusion in specific filtered lists. TanStack Loaders comes with the `invalidate` method to invalidate all data for a given loader.

```tsx
import { Action } from '@tanstack/actions'

const updatePostAction = new Action({
name: 'updatePost',
async action(post: Post) {
fn: async (post: Post) => {
//...
},
onEachSuccess: (submission) => {
postsLoader.invalidateAll()
postsLoader.invalidate()
},
})
```
Expand All @@ -156,14 +145,8 @@ When mutations are in flight, successful, or failed, it's important to display t
```tsx
import { useAction } from '@tanstack/react-actions'

function PostEditor() {
const params = useParams({ from: postEditRoute.id })
const postLoader = useLoader({
key: 'post',
variables: params.postId,
})

const [postDraft, setPostDraft] = useState<Post>(() => postLoader.state.data)
function PostEditor({ post }: { post: Post }) {
const [postDraft, setPostDraft] = useState<Post>(() => post)
const updatePost = useAction({ action: updatePostAction })

// Get the latest submission
Expand Down Expand Up @@ -218,12 +201,11 @@ This is a great place to reset your old mutation/actions states. We'll use TanSt

```tsx
const updatePostAction = new Action({
name: 'updatePost',
async action(post: Post) {
fn: async (post: Post) => {
//...
},
onEachSuccess: (submission) => {
postsLoader.invalidateAll()
postsLoader.invalidate()
},
})

Expand Down
5 changes: 4 additions & 1 deletion examples/react/kitchen-sink-multi-file/src/actionClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { updateInvoiceAction } from './routes/dashboard/invoices/invoice'
import { createInvoiceAction } from './routes/dashboard/invoices/invoices'

export const actionClient = new ActionClient({
getActions: () => [createInvoiceAction, updateInvoiceAction],
getActions: () => ({
createInvoiceAction,
updateInvoiceAction,
}),
})

declare module '@tanstack/react-actions' {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ export const invoiceLoader = new Loader({
})

export const updateInvoiceAction = new Action({
key: 'updateInvoice',
action: patchInvoice,
fn: patchInvoice,
onEachSuccess: async ({ payload }) => {
await invoiceLoader.invalidateInstance({
variables: payload.id,
Expand Down Expand Up @@ -70,7 +69,7 @@ export const invoiceRoute = new Route({
state: { data: invoice },
} = useLoader()()
const search = useSearch()
const action = useAction({ key: updateInvoiceAction.key })
const action = useAction({ action: updateInvoiceAction })
const navigate = useNavigate()

const [notes, setNotes] = React.useState(search.notes ?? ``)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { Action, useAction } from '@tanstack/react-actions'
import { Route } from '@tanstack/router'

export const createInvoiceAction = new Action({
key: 'createInvoice',
action: postInvoice,
fn: postInvoice,
onEachSuccess: async () => {
await invoicesLoader.invalidate()
},
Expand All @@ -17,7 +16,7 @@ export const invoicesIndexRoute = new Route({
getParentRoute: () => invoicesRoute,
path: '/',
component: function InvoicesHome() {
const action = useAction({ key: createInvoiceAction.key })
const action = useAction({ action: createInvoiceAction })

return (
<>
Expand Down
Loading

0 comments on commit d1b60a5

Please sign in to comment.