Skip to main content
Version: 2.x

@zenstackhq/swr

info

If you're looking for generating hooks for Tanstack Query, please checkout the @zenstackhq/tanstack-query plugin.

The @zenstackhq/swr plugin generates SWR hooks that call into the CRUD services provided by the server adapters.

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 "swr" version 2.0.0 or above.

Installation

npm install --save-dev @zenstackhq/swr

Context Provider

The plugin generates a React context provider which you can use to configure the behavior of the hooks. The following options are available on the provider:

  • endpoint

    The endpoint to use for the queries. Defaults to "/api/model".

  • fetch

    A custom fetch function to use for the queries. Defaults to the browser's built-in fetch.

Example for using the context provider:

import { FetchFn, Provider as ZenStackHooksProvider } from '../lib/hooks';

// 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);
};

function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
return (
<ZenStackHooksProvider value={{ endpoint: '/api/model', fetch: myFetch }}>
<AppContent />
</ZenStackHooksProvider>
);
}

export default MyApp;

Options

NameTypeDescriptionRequiredDefault
outputStringOutput directory (relative to the path of ZModel)Yes

Example

Here's a quick example with a blogging app. You can find a fully functional Todo app example here.

Schema

/schema.zmodel
plugin hooks {
provider = '@zenstackhq/swr'
output = "./src/lib/hooks"
}

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)
}

Using Query and Mutation Hooks

/src/components/posts.tsx
import type { Post } from '@prisma/client';
import { useFindManyPost, useCreatePost } from '../lib/hooks';

// post list component
const Posts = ({ userId }: { userId: string }) => {
const { trigger: createPost } = 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' },
});

function onCreatePost() {
createPost({
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>
</>
);
};

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. SWR 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 { trigger: create } = useCreatePost({ optimisticUpdate: true });

function onCreatePost() {
create({ ... })
}

When create 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 corresponding useFindMany queries.
  • update mutation updates the item in the query results of useFindXXX queries and their nested reads by matching the item's ID.
  • delete mutation removes the item from the query results of the corresponding useFindMany queries and sets null to useFindUnique and useFindFirst 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, and groupBy 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-published Post 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.

// arguments are query args, query options, and optimisticUpdate
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 data
    • Skip: skip the update
    • ProceedDefault: use the default automatic optimistic behavior.
  • data: The computed data to update the cache with. Only used when kind is Update.

Using Infinite Query

See SWR's documentation for more details.

/src/components/posts.tsx
import type { Post } from '@prisma/client';
import { useInfiniteFindManyPost } from '../lib/hooks';

// post list component with infinite loading
const Posts = ({ userId }: { userId: string }) => {

const PAGE_SIZE = 10;

const { data: pages, size, setSize } = useInfiniteFindManyPost(
(pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.length) {
return null;
}
return {
include: { author: true },
orderBy: { createdAt: 'desc' },
take: PAGE_SIZE,
skip: pageIndex * PAGE_SIZE,
};
}
);

const isEmpty = pages?.[0]?.length === 0;
const isReachingEnd = isEmpty || (pages && pages[pages.length - 1].length < PAGE_SIZE);

return (
<>
<ul>
{pages?.map((posts, index) => (
<React.Fragment key={index}>
{posts?.map((post) => (
<li key={post.id}>
{post.title} by {post.author.email}
</li>
))}
</React.Fragment>
))}
</ul>

{!isReachingEnd && (
<button onClick={() => setSize(size + 1)}>
Load more
</button>
)}
</>
);
};

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.

info

The automatic invalidation is enabled by default, and you can use the revalidate option to opt-out and handle revalidation by yourself.

useCreatePost({ revalidate: false });

Query Key

Query keys serve as unique identifiers for organizing the query cache. The generated hooks use the following query key scheme:

JSON.stringify({ prefix: 'zenstack', model, operation, args, flags })

For example, the query key for

useFindUniqueUser({ where: { id: '1' } })

will be:

JSON.stringify({ 
prefix: 'zenstack',
model: 'User',
operation: 'findUnique',
args: { where: { id: '1' } },
flags: { infinite: false }
})

You can use the generated getQueryKey function to compute it.

Comments
Feel free to ask questions, give feedback, or report issues.

Don't Spam


You can edit/delete your comments by going directly to the discussion, clicking on the 'comments' link below