Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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).
16 changes: 16 additions & 0 deletions packages/lit-query/src/createInfiniteQueryController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from './accessor.js'
import { createMissingQueryClientError } from './context.js'
import { BaseController } from './controllers/BaseController.js'
import { RendererResult, renderResult, ResultRenderers } from './render.js'

/**
* Options accepted by `createInfiniteQueryController`.
Expand Down Expand Up @@ -59,6 +60,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 +398,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),
},
)
}
19 changes: 19 additions & 0 deletions packages/lit-query/src/createMutationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from './accessor.js'
import { createMissingQueryClientError } from './context.js'
import { BaseController } from './controllers/BaseController.js'
import { RendererResult, renderResult, ResultRenderers } from './render.js'

/**
* Options accepted by `createMutationController`.
Expand Down Expand Up @@ -68,6 +69,17 @@ export type MutationResultAccessor<TData, TError, TVariables, TOnMutateResult> =
>['reset']
/** 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`. */
Comment thread
EskiMojo14 marked this conversation as resolved.
Outdated
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 +368,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),
},
)
}
12 changes: 12 additions & 0 deletions packages/lit-query/src/createQueryController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from './accessor.js'
import { createMissingQueryClientError } from './context.js'
import { BaseController } from './controllers/BaseController.js'
import { RendererResult, renderResult, ResultRenderers } from './render.js'
Comment thread
EskiMojo14 marked this conversation as resolved.
Outdated

/**
* Options accepted by `createQueryController`.
Expand Down Expand Up @@ -47,6 +48,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 +344,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'
50 changes: 50 additions & 0 deletions packages/lit-query/src/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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> {
const renderer = renderers[result.status as TResult['status']]
return renderer ? (renderer(result as any) as any) : (undefined 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