Skip to content

Commit c9a5a52

Browse files
committed
feat: Add useInfiniteQuery support
1 parent f726e8c commit c9a5a52

25 files changed

Lines changed: 598 additions & 269 deletions

File tree

.changeset/fuzzy-bees-sit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/react-query': minor
3+
---
4+
5+
Add useInfiniteQuery support to @ts-rest/react-query

apps/docs/docs/react-query.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,64 @@ const { body, status } = await client.posts.get.query();
4444
// useQuery hook
4545
const { data, isLoading } = client.posts.get.useQuery();
4646
```
47+
48+
## Infinite Query
49+
50+
One fantastic feature of `react-query` is the ability to create infinite queries. This is a great way to handle pagination.
51+
52+
[Prisma's Docs](https://www.prisma.io/docs/concepts/components/prisma-client/pagination) explain the concepts of cursor and offset pagination fantastically, especially if you use Prisma client with `@ts-rest`
53+
54+
### Cursor Pagination
55+
56+
This is a simple cursor based pagination example,
57+
58+
```typescript
59+
const { isLoading, data, hasNextPage, fetchNextPage } = useInfiniteQuery(
60+
queryKey,
61+
({ pageParam = 1 }) => pageParam,
62+
{
63+
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
64+
getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
65+
}
66+
);
67+
```
68+
69+
### Offset Pagination
70+
71+
This example specifically uses an API with `skip` and `take` query parameters, so this is requires slightly more configuration than a regular query (hence the complicated looking getNextPageParam)
72+
73+
```tsx
74+
const PAGE_SIZE = 5;
75+
76+
export function Index() {
77+
const { isLoading, data, hasNextPage, fetchNextPage } =
78+
api.getPosts.useInfiniteQuery(
79+
['posts'],
80+
({ pageParam = { skip: 0, take: PAGE_SIZE } }) => ({
81+
query: { skip: pageParam.skip, take: pageParam.take },
82+
}),
83+
{
84+
getNextPageParam: (lastPage, allPages) =>
85+
lastPage.status === 200
86+
? lastPage.body.count > allPages.length * PAGE_SIZE
87+
? { take: PAGE_SIZE, skip: allPages.length * PAGE_SIZE }
88+
: undefined
89+
: undefined,
90+
}
91+
);
92+
93+
if (isLoading) {
94+
return <div>Loading...</div>;
95+
}
96+
97+
if (!data) {
98+
return <div>No posts found</div>;
99+
}
100+
101+
const posts = data.pages.flatMap((page) =>
102+
page.status === 200 ? page.body.posts : []
103+
);
104+
105+
//...
106+
}
107+
```

apps/example-express/src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ const completedRouter = s.router(apiBlog, {
4242
status: 200,
4343
body: {
4444
posts,
45-
total: 0,
45+
count: 0,
46+
skip: query.skip,
47+
take: query.take,
4648
},
4749
};
4850
},

apps/example-nest/src/app/post.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class PostController implements ControllerShape {
2323

2424
return {
2525
status: 200 as const,
26-
body: { posts, total: totalPosts },
26+
body: { posts, count: totalPosts, skip, take },
2727
};
2828
}
2929

apps/example-next/pages/api/[...ts-rest].tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { apiNested } from '@ts-rest/example-contracts';
22
import { createNextRoute, createNextRouter } from '@ts-rest/next';
3-
import { posts } from '../../server/posts';
3+
import { postsService } from '../../server/posts';
44

55
const postsRouter = createNextRoute(apiNested.posts, {
66
createPost: async (args) => {
7-
const newPost = await posts.createPost(args.body);
7+
const newPost = await postsService.createPost(args.body);
88

99
return {
1010
status: 201,
@@ -31,7 +31,7 @@ const postsRouter = createNextRoute(apiNested.posts, {
3131
};
3232
},
3333
getPost: async ({ params }) => {
34-
const post = await posts.getPost(params.id);
34+
const post = await postsService.getPost(params.id);
3535

3636
if (!post) {
3737
return {
@@ -46,13 +46,18 @@ const postsRouter = createNextRoute(apiNested.posts, {
4646
};
4747
},
4848
getPosts: async (args) => {
49-
const allPosts = await posts.getPosts();
49+
const { posts, count } = await postsService.getPosts({
50+
skip: args.query.skip,
51+
take: args.query.take,
52+
});
5053

5154
return {
5255
status: 200,
5356
body: {
54-
posts: allPosts,
55-
total: allPosts.length,
57+
posts: posts,
58+
count: count,
59+
skip: args.query.skip,
60+
take: args.query.take,
5661
},
5762
};
5863
},

apps/example-next/pages/index.tsx

Lines changed: 52 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
import { apiBlog } from '@ts-rest/example-contracts';
22
import { initQueryClient } from '@ts-rest/react-query';
33
import Link from 'next/link';
4-
import { useEffect } from 'react';
5-
import { useDebounce } from '../hooks/useDebounce';
6-
import { useStore } from '../state';
4+
import classNames from 'classnames';
75

86
export const api = initQueryClient(apiBlog, {
9-
baseUrl: 'http://localhost:3334',
7+
baseUrl: 'http://localhost:4200/api',
108
baseHeaders: {},
119
});
1210

1311
export function Index() {
14-
const { searchString } = useStore();
12+
const PAGE_SIZE = 5;
1513

16-
const { data, isLoading, refetch } = api.getPosts.useQuery(['posts'], {
17-
query: {
18-
take: 5,
19-
skip: 0,
20-
...(searchString !== '' ? { search: searchString } : {}),
21-
},
22-
});
23-
24-
const searchStringDebounced = useDebounce(searchString, 250);
25-
26-
useEffect(() => {
27-
refetch();
28-
}, [refetch, searchStringDebounced]);
14+
const { isLoading, data, hasNextPage, fetchNextPage } =
15+
api.getPosts.useInfiniteQuery(
16+
['posts'],
17+
({ pageParam = { skip: 0, take: PAGE_SIZE } }) => ({
18+
query: { skip: pageParam.skip, take: pageParam.take },
19+
}),
20+
{
21+
getNextPageParam: (lastPage, allPages) =>
22+
lastPage.status === 200
23+
? lastPage.body.count > allPages.length * PAGE_SIZE
24+
? { take: PAGE_SIZE, skip: allPages.length * PAGE_SIZE }
25+
: undefined
26+
: undefined,
27+
}
28+
);
2929

3030
if (isLoading) {
3131
return <div>Loading...</div>;
@@ -35,36 +35,47 @@ export function Index() {
3535
return <div>No posts found</div>;
3636
}
3737

38-
const { posts } = data.body;
38+
const posts = data.pages.flatMap((page) =>
39+
page.status === 200 ? page.body.posts : []
40+
);
3941

4042
return (
41-
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
42-
{posts.map((post) => (
43-
<Link href={`/post/${post.id}`} key={post.id}>
44-
<div className="card bg-base-100 shadow-xl w-full hover:scale-105 transition cursor-pointer">
45-
<div className="card-body">
46-
<div className="flex flex-row justify-between">
47-
<h2 className="card-title">{post.title}</h2>
48-
<div>
49-
<div className="avatar placeholder">
50-
<div className="bg-neutral-focus text-neutral-content rounded-full w-8">
51-
<span className="text-xs">OB</span>
43+
<div>
44+
<div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
45+
{posts.map((post) => (
46+
<Link href={`/post/${post.id}`} key={post.id}>
47+
<div className="card bg-base-100 shadow-xl w-full hover:scale-105 transition cursor-pointer">
48+
<div className="card-body">
49+
<div className="flex flex-row justify-between">
50+
<h2 className="card-title">{post.title}</h2>
51+
<div>
52+
<div className="avatar placeholder">
53+
<div className="bg-neutral-focus text-neutral-content rounded-full w-8">
54+
<span className="text-xs">OB</span>
55+
</div>
5256
</div>
5357
</div>
5458
</div>
55-
</div>
56-
<p>{post.description}?</p>
57-
<div className="card-actions justify-end">
58-
{post.tags.map((tag) => (
59-
<div key={tag} className="badge badge-outline">
60-
Fashion
61-
</div>
62-
))}
59+
<p>{post.description}?</p>
60+
<div className="card-actions justify-end">
61+
{post.tags.map((tag) => (
62+
<div key={tag} className="badge badge-outline">
63+
Fashion
64+
</div>
65+
))}
66+
</div>
6367
</div>
6468
</div>
65-
</div>
66-
</Link>
67-
))}
69+
</Link>
70+
))}
71+
</div>
72+
<button
73+
disabled={!hasNextPage}
74+
className={classNames('btn mt-6', { 'btn-disabled': !hasNextPage })}
75+
onClick={() => fetchNextPage()}
76+
>
77+
Load more
78+
</button>
6879
</div>
6980
);
7081
}

apps/example-next/server/posts.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client';
22

33
const prisma = new PrismaClient();
44

5-
export const posts = {
5+
export const postsService = {
66
createPost: async (data: { title: string; content: string }) => {
77
const newPost = await prisma.post.create({ data });
88

@@ -13,9 +13,10 @@ export const posts = {
1313

1414
return post || null;
1515
},
16-
getPosts: async () => {
17-
const posts = await prisma.post.findMany({});
16+
getPosts: async (args: { skip?: number; take?: number }) => {
17+
const posts = await prisma.post.findMany(args);
18+
const count = await prisma.post.count();
1819

19-
return posts;
20+
return { posts, count };
2021
},
2122
};

libs/example-contracts/src/lib/contract-blog.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,16 @@ export const apiBlog = c.router({
7272
method: 'GET',
7373
path: '/posts',
7474
responses: {
75-
200: z.object({ posts: PostSchema.array(), total: z.number() }),
75+
200: z.object({
76+
posts: PostSchema.array(),
77+
count: z.number(),
78+
skip: z.number(),
79+
take: z.number(),
80+
}),
7681
},
7782
query: z.object({
78-
take: z.string().transform(Number).optional(),
79-
skip: z.string().transform(Number).optional(),
83+
take: z.string().transform(Number),
84+
skip: z.string().transform(Number),
8085
search: z.string().optional(),
8186
}),
8287
summary: 'Get all posts',

libs/ts-rest/core/src/lib/zod-utils.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z, ZodTypeAny } from 'zod';
2+
import { AppRoute } from './dsl';
23

34
export const returnZodErrorsIfZodSchema = (
45
schema: unknown,
@@ -21,3 +22,83 @@ export const returnZodErrorsIfZodSchema = (
2122

2223
return [];
2324
};
25+
26+
const isZodObject = (
27+
body: unknown
28+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29+
): body is z.ZodObject<any, any, any, any> => {
30+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
31+
return (body as z.ZodObject<any, any, any, any>).safeParse !== undefined;
32+
};
33+
34+
export const checkBodySchema = (
35+
body: unknown,
36+
appRoute: AppRoute
37+
):
38+
| {
39+
success: true;
40+
body: unknown;
41+
}
42+
| {
43+
success: false;
44+
error: unknown;
45+
} => {
46+
if (appRoute.method !== 'GET' && appRoute.body) {
47+
if (isZodObject(appRoute.body)) {
48+
const result = appRoute.body.safeParse(body);
49+
50+
if (result.success) {
51+
return {
52+
success: true,
53+
body: result.data,
54+
};
55+
}
56+
57+
return {
58+
success: false,
59+
error: result.error,
60+
};
61+
}
62+
}
63+
64+
return {
65+
success: true,
66+
body: body,
67+
};
68+
};
69+
70+
export const checkQuerySchema = (
71+
query: unknown,
72+
appRoute: AppRoute
73+
):
74+
| {
75+
success: true;
76+
body: unknown;
77+
}
78+
| {
79+
success: false;
80+
error: unknown;
81+
} => {
82+
if (appRoute.query) {
83+
if (isZodObject(appRoute.query)) {
84+
const result = appRoute.query.safeParse(query);
85+
86+
if (result.success) {
87+
return {
88+
success: true,
89+
body: result.data,
90+
};
91+
}
92+
93+
return {
94+
success: false,
95+
error: result.error,
96+
};
97+
}
98+
}
99+
100+
return {
101+
success: true,
102+
body: query,
103+
};
104+
};

0 commit comments

Comments
 (0)