@zenstackhq/tanstack-query
If you're looking for generating hooks for SWR, please checkout the @zenstackhq/swr
plugin.
The @zenstackhq/tanstack-query
plugin generates Tanstack Query hooks that call into the CRUD services provided by the server adapters. The plugin currently supports React and Svelte. Vue support is coming soon.
The hooks syntactically mirror the APIs of a standard Prisma client, including the function names and shapes of parameters (hooks directly use types generated by Prisma).
To use the generated hooks, you need to install "tanstack-query" for the target framework with version 4.0.0 or above.
Installation
npm install --save-dev @zenstackhq/tanstack-query
Options
Name | Type | Description | Required | Default |
---|---|---|---|---|
output | String | Output directory (relative to the path of ZModel) | Yes | |
target | String | Target framework to generate for. Choose from "react", "vue", and "svelte". | Yes | |
version | String | Version of TanStack Query to generate for. Choose from "v4" and "v5". | No | v5 |
portable | Boolean | Include TypeScript types needed to compile the generated code in the output directory. Useful when you output into another project that doesn't reference Prisma and ZenStack. You'll still need to install the "@zenstackhq/tanstack-query" package in that project. | No | false |
Hooks Signature
The generated hooks have the following signature convention.
-
Query Hooks
function use[Operation][Model](args?, options?);
[Operation]
: query operation. E.g., "FindMany", "FindUnique", "Count".[Model]
: the name of the model. E.g., "Post".args
: Prisma query args. E.g.,{ where: { published: true } }
.options
: tanstack-query options.
The hook function returns a standard TanStack Query
useQuery
result, plus an addedqueryKey
field.- The
data
field contains the Prisma query result. - The
queryKey
field is the query key used to cache the query result. It can be used to manipulate the cache directly or cancel the query.
-
Mutation Hooks
function use[Operation][Model](options?);
[Operation]
: mutation operation. E.g., "Create", "UpdateMany".[Model]
: the name of the model. E.g., "Post".options
: TanStack-Query options.
The hook function returns a standard TanStack Query
useMutation
result. Themutate
andmutateAsync
functions returned take the corresponding Prisma mutation args as input. E.q.,{ data: { title: 'Post1' } }
.
Context Provider
The generated hooks allow you to control their behavior by setting up context. The following options are available on the context:
-
endpoint
The endpoint to use for the queries. Defaults to "/api/model".
-
fetch
A custom
fetch
function to use for the queries. Defaults to using cross-fetch.
Example for using the context provider:
- React
- Vue
- Svelte
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider as ZenStackHooksProvider } from '../lib/hooks';
import type { FetchFn } from '@zenstackhq/tanstack-query/runtime';
// custom fetch function that adds a custom header
const myFetch: FetchFn = (url, options) => {
options = options ?? {};
options.headers = {
...options.headers,
'x-my-custom-header': 'hello world',
};
return fetch(url, options);
};
const queryClient = new QueryClient();
function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<ZenStackHooksProvider value={{ endpoint: '/api/model', fetch: myFetch }}>
<AppContent />
</ZenStackHooksProvider>
</QueryClientProvider>
);
}
export default MyApp;
<script setup lang="ts">
import { provideHooksContext } from '@/lib/hooks';
import type { FetchFn } from '@zenstackhq/tanstack-query/runtime';
const myFetch: FetchFn = (url, options) => {
options = options ?? {};
options.headers = {
...options.headers,
'x-my-custom-header': 'hello world',
};
return fetch(url, options);
};
provideHooksContext({
endpoint: 'http://localhost:3000/api/model',
fetch: myFetch
});
</script>
<template>
<!-- App Content -->
</template>
<script lang="ts">
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import { setHooksContext } from '$lib/hooks';
import type { FetchFn } from '@zenstackhq/tanstack-query/runtime';
// custom fetch function that adds a custom header
const myFetch: FetchFn = (url, options) => {
options = options ?? {};
options.headers = {
...options.headers,
'x-my-custom-header': 'hello world',
};
return fetch(url, options);
};
setHooksContext({
endpoint: '/api/model',
fetch: myFetch,
});
const queryClient = new QueryClient();
</script>
<div>
<QueryClientProvider client={queryClient}>
<slot />
</QueryClientProvider>
</div>
Next.js's app router renders components as React Server Components (RSC) by default. React context providers are not supported in RSC. If you're configuring the context providers in such a setup, please make sure to wrap them inside a client component, or set the containing layout as client component.
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider as ZenStackHooksProvider } from "@/hooks/generated";
import type { ReactNode } from 'react';
const queryClient = new QueryClient();
export default function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ZenStackHooksProvider value={{ endpoint: '/api/model' }}>
{children}
</ZenStackHooksProvider>
</QueryClientProvider>
);
}
Example
Here's a quick example with a ReactJs blogging app. You can find a fully functional Todo app example here.
Schema
plugin hooks {
provider = '@zenstackhq/tanstack-query'
output = "./src/lib/hooks"
target = "react"
}
model User {
id String @id @default(cuid())
email String
posts Post[]
// everyone can signup, and user profile is also publicly readable
@@allow('create,read', true)
}
model Post {
id String @id @default(cuid())
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
// author has full access
@@allow('all', auth() == author)
// logged-in users can view published posts
@@allow('read', auth() != null && published)
}
App Setup
- React
- Vue
- Svelte
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider as ZenStackHooksProvider } from '../lib/hooks';
const queryClient = new QueryClient();
function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<ZenStackHooksProvider value={{ endpoint: '/api/model' }}>
<AppContent />
</ZenStackHooksProvider>
</QueryClientProvider>
);
}
import { createApp } from 'vue';
import App from './App.vue';
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
createApp(App)
.use(VueQueryPlugin, { queryClient: new QueryClient() })
.mount('#app');
<script setup lang="ts">
import { provideHooksContext } from '@/lib/hooks';
provideHooksContext({
endpoint: 'http://localhost:3000/api/model'
});
</script>
<template>
<!-- App Content -->
</template>
<script lang="ts">
import { setHooksContext } from '$lib/hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
const queryClient = new QueryClient();
setHooksContext({ endpoint: '/api/model' });
</script>
<div>
<QueryClientProvider client={queryClient}>
<slot />
</QueryClientProvider>
</div>
If you're using SvelteKit, you need to make sure the package @zenstackhq/tanstack-query
is not treated as an external dependency by Vite, otherwise you'll encounter errors when the SvelteKit server renders the app. To do this, add the following section into vite.config.js
:
const config = {
...
ssr: {
noExternal: ['@zenstackhq/tanstack-query'],
},
};
export default config;
Using Query and Mutation Hooks
- React
- Vue
- Svelte
import { useFindManyPost, useCreatePost } from '../lib/hooks';
// post list component
const Posts = ({ userId }: { userId: string }) => {
const create = useCreatePost();
// list all posts that're visible to the current user, together with their authors
const { data: posts } = useFindManyPost({
include: { author: true },
orderBy: { createdAt: 'desc' },
});
async function onCreatePost() {
create.mutate({
data: {
title: 'My awesome post',
authorId: userId,
},
});
}
return (
<>
<button onClick={onCreatePost}>Create</button>
<ul>
{posts?.map((post) => (
<li key={post.id}>
{post.title} by {post.author.email}
</li>
))}
</ul>
</>
);
};
<script setup lang="ts">
import { useFindManyPost, useCreatePost } from '@/lib/hooks';
const props = defineProps({
userId: String
});
// list all posts that're visible to the current user, together with their authors
const { data: posts } = useFindManyPost({
include: { author: true },
orderBy: { createdAt: 'desc' },
});
const onCreate = () => {
create.mutate({
data: {
title: 'My awesome post',
authorId: userId,
}
});
};
</script>
<template>
<div>
<button @click="onCreatePost">Create</button>
<ul v-if="posts">
<li v-for="post in posts" :key="post.id">
{{ post.title }} by {{ post.author.email }}
</li>
</ul>
</div>
</template>
<script lang="ts">
import { useFindManyPost, useCreatePost } from '../lib/hooks';
export let userId: string;
// list all posts that're visible to the current user, together with their authors
const query = useFindManyPost({
include: { author: true },
orderBy: { createdAt: 'desc' },
});
const create = useCreatePost();
function onCreatePost() {
$create.mutate({
data: {
title: 'My awesome post',
authorId: userId,
},
});
}
</script>
<div>
<button on:click={onCreatePost}>Create</button>
<ul>
{#each $query.data as post (post.id)}
<li>{post.title} by {post.author.email}</li>
{/each}
</ul>
</div>
Automatic Optimistic Update
Optimistic update is a technique that allows you to update the data cache immediately when a mutation executes while waiting for the server response. It helps achieve a more responsive UI. TanStack Query provides the infrastructure for implementing it.
The ZenStack-generated mutation hooks allow you to opt-in to "automatic optimistic update" by passing the optimisticUpdate
option when calling the hook. When the mutation executes, it analyzes the current queries in the cache and tries to find the ones that need to be updated. When the mutation settles (either succeeded or failed), the queries are invalidated to trigger a re-fetch.
Here's an example:
const { mutate: create } = useCreatePost({ optimisticUpdate: true });
function onCreatePost() {
create({ ... })
}
When mutate
executes, if there are active queries like useFindManyPost()
, the data of the mutation call will be optimistically inserted into the head of the query result.
Details of the optimistic behavior
create
mutation inserts item to the head of the query results of the correspondinguseFindMany
queries.update
mutation updates the item in the query results ofuseFindXXX
queries and their nested reads by matching the item's ID.upsert
mutation first tries to update the item in the query results ofuseFindXXX
queries and their nested reads by matching the item's ID. If the item is not found, it inserts the item to the head of the query results.delete
mutation removes the item from the query results of the correspondinguseFindMany
queries and setsnull
touseFindUnique
anduseFindFirst
query results, by matching the item's ID.
Limitations
- The automatic optimistic update relies on ID matching. It only works for queries that select the ID field(s).
- Non-entity-fetching queries like
count
,aggregate
, andgroupBy
are not affected. - Infinite queries are not affected.
- It doesn't respect filter conditions or access policies that potentially affect the queries under update. For example, for query
useFindManyPost({ where: { published: true }})
, when a non-publishedPost
is created, it'll still be inserted into the query result.
Opt-out
By default, all queries opt into automatic optimistic update. You can opt-out on a per-query basis by passing false
to the optimisticUpdate
option.
const { data } = useFindManyPost({ where: { published: true } }, { optimisticUpdate: false });
When a query opts out, it won't be updated by a mutation, even if the mutation is set to update optimistically.
Fine-Grained Optimistic Update
Automatic optimistic update is convenient, but there might be cases where you want to explicitly control how the update happens. You can use the optimisticUpdateProvider
callback to fully customize how each query cache entry should be optimistically updated. When the callback is set, it takes precedence over the automatic optimistic logic.
useCreatePost({
optimisticUpdateProvider: ({ queryModel, queryOperation, queryArgs, currentData, mutationArgs }) => {
return { kind: 'Update', data: ... /* computed result */ };
}
});
The callback is invoked for each query cache entry and receives the following arguments in a property bag:
queryModel
: The model of the query, e.g.,Post
.queryOperation
: The operation of the query, e.g.,findMany
,count
.queryArgs
: The arguments of the query, e.g.,{ where: { published: true } }
.currentData
: The current cache data.mutationArgs
: The arguments of the mutation, e.g.,{ data: { title: 'My awesome post' } }
.
It should return a result object with the following fields:
kind
: The kind of the optimistic update.Update
: update the cache using the computed dataSkip
: skip the updateProceedDefault
: use the default automatic optimistic behavior.
data
: The computed data to update the cache with. Only used whenkind
isUpdate
.
Using Infinite Query
- React
- Vue
- Svelte
Here's a quick example of using infinite query to load a list of posts with infinite pagination. See Tanstack Query documentation for more details.
import { useInfiniteFindManyPost } from '../lib/hooks';
// post list component with infinite loading
const Posts = () => {
const PAGE_SIZE = 10;
const fetchArgs = {
include: { author: true },
orderBy: { createdAt: 'desc' as const },
take: PAGE_SIZE,
};
const { data, fetchNextPage, hasNextPage } = useInfiniteFindManyPost(fetchArgs, {
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < PAGE_SIZE) {
return undefined;
}
const fetched = pages.flatMap((item) => item).length;
return {
...fetchArgs,
skip: fetched,
};
},
});
return (
<>
<ul>
{data?.pages.map((posts, i) => (
<React.Fragment key={i}>
{posts?.map((post) => (
<li key={post.id}>
{post.title} by {post.author.email}
</li>
))}
</React.Fragment>
))}
</ul>
{hasNextPage && (
<button onClick={() => fetchNextPage()}>
Load more
</button>
)}
</>
);
};
Here's a quick example of using infinite query to load a list of posts with infinite pagination. See Tanstack Query documentation for more details.
<script setup lang="ts">
// post list component with infinite loading
import { useInfiniteFindManyPost } from '@/lib/hooks';
const PAGE_SIZE = 10;
const fetchArgs = {
include: { author: true },
orderBy: { createdAt: 'desc' as const },
take: PAGE_SIZE,
};
const { data, hasNextPage, fetchNextPage } = useInfiniteFindManyPost(
fetchArgs,
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < PAGE_SIZE) {
return undefined;
}
const fetched = pages.flatMap((item) => item).length;
return {
...fetchArgs,
skip: fetched,
};
},
}
);
</script>
<template>
<div>
<ul v-if="data">
<template v-for="(posts, i) in data.pages" :key="i">
<li v-for="post in posts" :key="post.id">
{{ post.title }} by {{ post.author.email }}
</li>
</template>
</ul>
</div>
<button v-if="hasNextPage" @click="() => fetchNextPage()">Load More</button>
</template>
Here's a quick example of using infinite query to load a list of posts with infinite pagination. See Tanstack Query documentation for more details.
<script lang="ts">
// post list component with infinite loading
import { useInfiniteFindManyPost } from '../lib/hooks';
const PAGE_SIZE = 10;
const fetchArgs = {
include: { author: true },
orderBy: { createdAt: 'desc' as const },
take: PAGE_SIZE,
};
const query = useInfiniteFindManyPost(fetchArgs, {
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < PAGE_SIZE) {
return undefined;
}
const fetched = pages.flatMap((item) => item).length;
return {
...fetchArgs,
skip: fetched,
};
},
});
</script>
<div>
<ul>
<div>
{#if $query.data}
{#each $query.data.pages as posts, i (i)}
{#each posts as post (post.id)}
<li>{post.title} by {post.author.email}</li>
{/each}
{/each}
{/if}
</div>
</ul>
{#if $query.hasNextPage}
<button on:click={() => $query.fetchNextPage()}>
Load more
</button>
{/if}
</div>
Advanced
Query Invalidation
The mutation hooks generated by ZenStack automatically invalidates the queries that are potentially affected by the changes. For example, if you create a Post
, the useFindManyPost
query will be automatically invalidated when the mutation succeeds. Invalidation causes cache to be purged and fresh data to be refetched.
The automatic invalidation takes care of nested read, write, and delete cascading.
1. Nested Read
Nested reads are also subject to automatic invalidation. For example, if you create a Post
, the query made by
useFindUniqueUser({ where: { id: userId }, include: { posts: true } });
will be invalidated.
2. Nested Write
Similarly, nested writes also trigger automatic invalidation. For example, if you create a Post
in a nested update to User
like:
updateUser({ where: { id: userId }, posts: { create: { title: 'post1' } } });
The mutation will cause queries like useFindManyPost()
to be invalidated.
3. Delete Cascade
In ZModel, relations can be configured to cascade delete, e.g.:
model User {
...
posts Post[]
}
model Post {
...
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
}
When a User
is deleted, the Post
entities it owns will be deleted automatically. The generated hooks takes cascade delete into account. For example, if you delete a User
, Post
model will be considered as affected and queries like useFindManyPost()
will be invalidated.
The automatic invalidation is enabled by default, and you can use the invalidateQueries
option to opt-out and handle revalidation by yourself.
useCreatePost({ invalidateQueries: false });
Query Key
Query keys serve as unique identifiers for organizing the query cache. The generated hooks use the following query key scheme:
['zenstack', model, operation, args, flags]
For example, the query key for
useFindUniqueUser({ where: { id: '1' } })
will be:
['zenstack', 'User', 'findUnique', { where: { id: '1' } }, { infinite: false }]
You can use the generated getQueryKey
function to compute it.
The query hooks also return the query key as part of the result object.
const { data, queryKey } = useFindUniqueUser({ where: { id: '1' } });
Query Cancellation
You can use TanStack Query's queryClient.cancelQueries
API to cancel a query. The easiest way to do this is to use the queryKey
returned by the query hook.
const queryClient = useQueryClient();
const { queryKey } = useFindUniqueUser({ where: { id: '1' } });
function onCancel() {
queryClient.cancelQueries({ queryKey, exact: true });
}
When a cancellation occurs, the query state is reset and the ongoing fetch
call to the CRUD API is aborted.
FAQ
Next.js error "createContext" is not a function
If you see an error like:
Error in Next.js: "TypeError: (0 , react__WEBPACK_IMPORTED_MODULE_0__.createContext) is not a function"
It's usually due to you're using the hooks in a React server component. Make sure to add the 'use client;'
directive at the top of your component file.