Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/crisp-sloths-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/lit-query': minor
---

Add render method to controllers based on Tasks API
33 changes: 33 additions & 0 deletions docs/framework/lit/guides/infinite-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,36 @@ if (query.hasNextPage && !query.isFetching) {
## Paginated Alternative

If your UI shows one page at a time, a normal query with a page in the key can be a better fit. The [Pagination example](../examples/pagination) uses `createQueryController`, `placeholderData: keepPreviousData`, prefetching, and mutations to demonstrate that pattern.

## Rendering

For convenience, the controller includes a `render` method that accepts a renderer object with handlers, based on the [Task API](https://lit.dev/docs/data/task/#rendering-tasks). It returns the output of the matching handler:

```ts
render() {
return html`
${this.projects.render({
pending: ({ fetchStatus }) =>
html`<p>${fetchStatus === 'fetching' ? 'Loading...' : 'Idle'}</p>`,
error: ({ error }) => html`<p>Error: ${error.message}</p>`,
success: ({ data }) => html`
${data.pages.map(
(page) => html`
${page.projects.map((project) => html`<p>${project.name}</p>`)}
`,
)}

<button
?disabled=${!this.projects.hasNextPage || this.projects.isFetching}
@click=${() => this.projects.fetchNextPage()}
>
${this.projects.isFetchingNextPage
? 'Loading more...'
: this.projects.hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
`,
})}`
}
```
18 changes: 18 additions & 0 deletions docs/framework/lit/guides/mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,21 @@ private readonly favoriteMutation = createMutationController(
```

For the exact runnable flow, see the [Pagination example](../examples/pagination).

## Rendering

For convenience, the mutation accessor also includes a `render` method, based on the [Task API](https://lit.dev/docs/data/task/#rendering-tasks). It takes an object of renderers for each status, and returns the output of the matching renderer:

```ts
render() {
return html`
<button @click=${() => this.addTodo.mutate({ title: 'New todo' })}>
Add Todo
</button>
${this.addTodo.render({
pending: ({ isIdle }) => isIdle ? nothing : html`<p>Adding...</p>`,
error: ({ error }) => html`<p>Oops, something went wrong: ${error.message}</p>`,
success: ({ data }) => html`<p>Added todo: ${data.title}</p>`,
})}`
}
```
22 changes: 22 additions & 0 deletions docs/framework/lit/guides/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,25 @@ html`<button @click=${() => this.todos.refetch()}>Refetch</button>`
```

For multiple queries that should run at the same time, see [Parallel Queries](./parallel-queries.md).

## Rendering

For convenience, the query accessor includes a `render` method, based on the [Task API](https://lit.dev/docs/data/task/#rendering-tasks). It takes an object of renderers for each status, and returns the output of the matching renderer:

```ts
render() {
return html`
${this.todos.render({
pending: ({ fetchStatus }) =>
html`<p>${fetchStatus === 'fetching' ? 'Loading...' : 'Idle'}</p>`,
error: ({ error }) => html`<p>Oops, something went wrong: ${error.message}</p>`,
success: ({ data }) => html`
<ul>
${data.map((todo) => html`<li>${todo.title}</li>`)}
</ul>
`,
})}`
}
```

The render is provided with the query result, narrowed to the matching state. If no renderer matches, `render` returns [`nothing`](https://lit.dev/docs/templates/conditionals/#conditionally-rendering-nothing).
20 changes: 20 additions & 0 deletions packages/lit-query/src/createInfiniteQueryController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import {
} from './accessor.js'
import { createMissingQueryClientError } from './context.js'
import { BaseController } from './controllers/BaseController.js'
import {
renderResult,
type RendererResult,
type ResultRenderers,
} from './render.js'

/**
* Options accepted by `createInfiniteQueryController`.
Expand Down Expand Up @@ -59,6 +64,14 @@ export type InfiniteQueryResultAccessor<TData, TError> = ValueAccessor<
>['fetchPreviousPage']
/** Removes the controller from its Lit host and unsubscribes observers. */
destroy: () => void
/** Renders the query result using the appropriate renderer from the given set, based on the result's `status`. */
render: <
TRenderers extends ResultRenderers<
InfiniteQueryObserverResult<TData, TError>
>,
>(
renderers: TRenderers,
) => RendererResult<InfiniteQueryObserverResult<TData, TError>, TRenderers>
}

function createPendingInfiniteQueryResult<
Expand Down Expand Up @@ -389,6 +402,13 @@ export function createInfiniteQueryController<
fetchNextPage: controller.fetchNextPage,
fetchPreviousPage: controller.fetchPreviousPage,
destroy: () => controller.destroy(),
render: <
TRenderers extends ResultRenderers<
InfiniteQueryObserverResult<TData, TError>
>,
>(
renderers: TRenderers,
) => renderResult(controller.current, renderers),
},
)
}
23 changes: 23 additions & 0 deletions packages/lit-query/src/createMutationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import {
} from './accessor.js'
import { createMissingQueryClientError } from './context.js'
import { BaseController } from './controllers/BaseController.js'
import {
renderResult,
type RendererResult,
type ResultRenderers,
} from './render.js'

/**
* Options accepted by `createMutationController`.
Expand Down Expand Up @@ -68,6 +73,17 @@ export type MutationResultAccessor<TData, TError, TVariables, TOnMutateResult> =
>['reset']
/** Removes the controller from its Lit host and unsubscribes observers. */
destroy: () => void
/** Renders the mutation result using the appropriate renderer from the given set, based on the result's `status`. */
render: <
TRenderers extends ResultRenderers<
MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
>,
>(
renderers: TRenderers,
) => RendererResult<
MutationObserverResult<TData, TError, TVariables, TOnMutateResult>,
TRenderers
>
}

function createIdleMutationResult<
Expand Down Expand Up @@ -356,6 +372,13 @@ export function createMutationController<
mutateAsync: controller.mutateAsync,
reset: controller.reset,
destroy: () => controller.destroy(),
render: <
TRenderers extends ResultRenderers<
MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
>,
>(
renderers: TRenderers,
) => renderResult(controller.current, renderers),
},
)
}
16 changes: 16 additions & 0 deletions packages/lit-query/src/createQueryController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import {
} from './accessor.js'
import { createMissingQueryClientError } from './context.js'
import { BaseController } from './controllers/BaseController.js'
import {
renderResult,
type RendererResult,
type ResultRenderers,
} from './render.js'

/**
* Options accepted by `createQueryController`.
Expand Down Expand Up @@ -47,6 +52,12 @@ export type QueryResultAccessor<TData, TError> = ValueAccessor<
suspense: () => Promise<QueryObserverResult<TData, TError>>
/** Removes the controller from its Lit host and unsubscribes observers. */
destroy: () => void
/** Renders the query result using the appropriate renderer from the given set, based on the result's `status`. */
render: <
TRenderers extends ResultRenderers<QueryObserverResult<TData, TError>>,
>(
renderers: TRenderers,
) => RendererResult<QueryObserverResult<TData, TError>, TRenderers>
}

function createPendingQueryResult<TData, TError>(): QueryObserverResult<
Expand Down Expand Up @@ -337,6 +348,11 @@ export function createQueryController<
refetch: controller.refetch,
suspense: controller.suspense,
destroy: () => controller.destroy(),
render: <
TRenderers extends ResultRenderers<QueryObserverResult<TData, TError>>,
>(
renderers: TRenderers,
) => renderResult(controller.current, renderers),
},
)
}
2 changes: 2 additions & 0 deletions packages/lit-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,5 @@ export type {
QueryControllerOptions,
QueryControllerResult,
} from './types.js'

export { renderResult } from './render.js'
49 changes: 49 additions & 0 deletions packages/lit-query/src/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export type ResultRenderers<TResult extends { status: string }> = {
[K in TResult['status']]?: (
result: Extract<TResult, { status: K }>,
) => unknown
}

export type RendererResult<
TResult extends { status: string },
TRenderers extends ResultRenderers<TResult>,
> = {
[K in TResult['status']]: TRenderers[K] extends (
result: Extract<TResult, { status: K }>,
) => infer R
? R
: undefined
}[TResult['status']]

/**
* Based on the `status` property of the given `result`, renders the appropriate content using the provided `renderers`. If no renderer is found for the
* current status, renders nothing.
*
* This function is useful for rendering the state of a query result, such as loading, error, or success states, in a declarative way.
* @param result - The result object containing a `status` property that indicates the current state of the query.
* @param renderers - An object mapping possible `status` values to their corresponding rendering functions. Each function receives the result object as an argument and returns the content to be rendered for that status.
* @returns The content returned by the appropriate renderer based on the `status` of the result, or nothing if no renderer is found for that status.
*
* @example
* class TodosView extends LitElement {
* private readonly todos = createQueryController(this, {
* queryKey: ['todos'],
* queryFn: async () => fetch('/api/todos').then((r) => r.json()),
* })
*
* render() {
* const query = this.todos()
* return renderResult(query, {
* pending: () => html`Loading...`,
* error: ({ error }) => html`Error: ${error.message}`,
* success: ({ data }) => html`<ul>${data.map((todo) => html`<li>${todo.title}</li>`)}</ul>`,
* })
* }
* }
*/
export function renderResult<
TResult extends { status: string },
TRenderers extends ResultRenderers<TResult>,
>(result: TResult, renderers: TRenderers): RendererResult<TResult, TRenderers> {
return renderers[result.status as TResult['status']]?.(result as never) as any
}
122 changes: 122 additions & 0 deletions packages/lit-query/src/tests/infinite-and-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,128 @@ describe('createInfiniteQueryController', () => {

host.infinite.destroy()
})

it('renders by current infinite status via infinite.render', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()

const infinite = createInfiniteQueryController(
host,
{
queryKey: ['infinite-render-01'],
initialPageParam: 0,
queryFn: async ({ pageParam }) => Number(pageParam),
getNextPageParam: (lastPage) =>
lastPage < 1 ? lastPage + 1 : undefined,
},
client,
)

const pendingUi = infinite.render({
pending: () => 'pending-ui',
success: () => 'success-ui',
error: () => 'error-ui',
})
expect(pendingUi).toBe('pending-ui')

host.connect()
host.update()
await waitFor(() => infinite().isSuccess)

const successUi = infinite.render({
pending: () => 'pending-ui',
success: (result) => `success-${result.data?.pages.join(',')}`,
error: () => 'error-ui',
})
expect(successUi).toBe('success-0')

infinite.destroy()
})

it('renders error branch via infinite.render when query fails', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()

const infinite = createInfiniteQueryController(
host,
{
queryKey: ['infinite-render-02'],
initialPageParam: 0,
queryFn: async () => {
throw new Error('render-infinite-failed')
},
getNextPageParam: () => undefined,
},
client,
)

host.connect()
host.update()
await waitFor(() => infinite().isError)

const errorUi = infinite.render({
pending: () => 'pending-ui',
success: () => 'success-ui',
error: (result) => result.error.message,
})
expect(errorUi).toBe('render-infinite-failed')

infinite.destroy()
})

it('returns undefined when no renderer matches current infinite query status', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()

const infinite = createInfiniteQueryController(
host,
{
queryKey: ['infinite-render-03'],
initialPageParam: 0,
queryFn: async ({ pageParam }) => Number(pageParam),
getNextPageParam: (lastPage) =>
lastPage < 1 ? lastPage + 1 : undefined,
},
client,
)

// In pending state but no pending renderer provided — should return undefined
const noMatchResult = infinite.render({
error: () => 'error-ui',
})
expect(noMatchResult).toBeUndefined()

host.connect()
host.update()
await waitFor(() => infinite().isSuccess)

// In success state but no success renderer provided — should return undefined
const noSuccessRenderer = infinite.render({
pending: () => 'pending-ui',
error: () => 'error-ui',
})
expect(noSuccessRenderer).toBeUndefined()

infinite.destroy()
})
})

describe('options helpers integration', () => {
Expand Down
Loading