Skip to content

Commit 2feedbb

Browse files
authored
feat: @ts-rest/vue-query (#299)
1 parent 99047c5 commit 2feedbb

34 files changed

Lines changed: 2641 additions & 292 deletions

.changeset/polite-lobsters-fold.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ts-rest/vue-query': minor
3+
---
4+
Adds new `@ts-rest/vue-query` package!
5+
- feat: add support @tanstack/vue-query
6+
- feat: add docs for @tanstack/vue-query feature

apps/docs/docs/vue-query.mdx

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { InstallTabs } from '@site/src/components/InstallTabs';
2+
3+
# Vue Query
4+
5+
## Installation
6+
7+
<InstallTabs packageName="@ts-rest/vue-query @tanstack/vue-query" />
8+
9+
This is a client using `@ts-rest/vue-query`, using `@tanstack/vue-query` under the hood.
10+
11+
## Init TanStack Query Vue plugin
12+
13+
To use the query client in your Vue app, you'll need to initialise the `vue-query` client in your app.
14+
15+
```ts
16+
import { VueQueryPlugin } from '@tanstack/vue-query';
17+
18+
createApp(App).use(VueQueryPlugin).mount('#app');
19+
```
20+
21+
## initQueryClient
22+
23+
The below snippet is how you'd create a query client, this is pretty much the same structure as the `@ts-rest/core` client.
24+
25+
```tsx
26+
import { initQueryClient } from '@ts-rest/vue-query';
27+
28+
export const client = initQueryClient(router, {
29+
baseUrl: 'http://localhost:3333',
30+
baseHeaders: {},
31+
api?: () => ... // <- Optional Custom API Fetcher (see below)
32+
});
33+
```
34+
35+
To customise the API, follow the same docs as the core client [here](/docs/core/custom)
36+
37+
:::tip
38+
39+
By default, ts-rest encodes query parameters as regular URL encoded strings (with support for nested objects, arrays etc) with full decode compatibility from [`qs`](https://www.npmjs.com/package/qs)
40+
41+
To encode query parameters as JSON, you can use the `jsonQuery` option, please note you'll need to configure your backend to support decoding JSON query parameters.
42+
43+
```tsx
44+
export const client = initQueryClient(router, {
45+
...,
46+
jsonQuery: true
47+
});
48+
49+
```
50+
51+
:::
52+
53+
## useQuery and useMutation
54+
55+
Once you've created a client using `initQueryClient`, you may traverse the object (in the exact same structure as your contract layout) to find the query or mutation you want to use.
56+
57+
```tsx
58+
const queryResult = client.posts.get.useQuery(
59+
['posts'], // <- queryKey
60+
() => {
61+
params: {
62+
id: '1';
63+
}
64+
}, // <- Query params, Params, Body etc (all typed)
65+
{ staleTime: 1000 } // <- vue-query options (optional)
66+
);
67+
```
68+
69+
### using dynamic parameters
70+
71+
To use dynamic parameters, you can add a reactive vue `Ref` to the `queryKey` to trigger automatic refetching, when the reference changes.
72+
73+
The following example will refetch the query everytime the `postId` changes and is truthy.
74+
75+
```tsx
76+
const postId = computed(() => selectedPost.value.id);
77+
78+
const queryResult = client.posts.get.useQuery(
79+
['posts', postId], // <- queryKey with reactive ref
80+
(context) => {
81+
params: {
82+
id: postId.value; // or use queryKey passed to context: context.queryKey[1]
83+
}
84+
},
85+
{ enabled: computed(() => !!postId.value) } // <- make sure to use computed values for reactive options
86+
);
87+
```
88+
89+
:::tip
90+
91+
The design philosophy of `@ts-rest/vue-query` is to make it as easy as possible to use `vue-query` with `@ts-rest`. This means that the `useQuery` and `useMutation` hooks are as close to the original `vue-query` hooks as possible, as such we don't abstract away from the query keys or the options. Only the query function itself!
92+
93+
:::
94+
95+
```html
96+
<template>
97+
<div class="App">
98+
<h1>Posts from posts-service</h1>
99+
100+
<div v-if="isLoading">Loading...</div>
101+
<div v-if="error">Error: {{ error }}</div>
102+
103+
<div v-if="data">
104+
<div v-for="post in data.body" :key="post.id">
105+
<h2>{{ post.title }}</h2>
106+
<p>{{ post.content }}</p>
107+
</div>
108+
</div>
109+
</div>
110+
</template>
111+
112+
<script lang="ts" setup>
113+
// Effectively a useQuery hook
114+
const { data, error, isLoading } = client.getPosts.useQuery(['posts']);
115+
116+
// Effectively a useMutation hook
117+
const { mutate, isLoading } = client.posts.create.useMutation();
118+
</script>
119+
```
120+
121+
:::info
122+
123+
When destructing the response from `useQuery` or `useMutation`, remember that ts-rest returns a `status` and `body` property, so you'll need to destructure those as well.
124+
125+
The reason for this is error handling! Please see the [Relevant Docs](/docs/core/errors#client-error-typing)
126+
127+
:::
128+
129+
## Regular Query and Mutations
130+
131+
`@ts-rest/vue-query` allows for a regular fetch or mutation if you want, without having to initialise two different clients, one with `@ts-rest/core` and one with `@ts-rest/vue-query`.
132+
133+
```typescript
134+
// Normal fetch
135+
const { body, status } = await client.posts.get.query();
136+
137+
// useQuery hook
138+
const { data, isLoading } = client.posts.get.useQuery();
139+
```
140+
141+
## useInfiniteQuery
142+
143+
One fantastic feature of `vue-query` is the ability to create infinite queries. This is a great way to handle pagination.
144+
145+
[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`
146+
147+
### Cursor Pagination
148+
149+
This is a simple cursor based pagination example,
150+
151+
```typescript
152+
const { isLoading, data, hasNextPage, fetchNextPage } = useInfiniteQuery(
153+
queryKey,
154+
({ pageParam = 1 }) => pageParam,
155+
{
156+
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
157+
getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
158+
}
159+
);
160+
```
161+
162+
### Offset Pagination
163+
164+
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)
165+
166+
```tsx
167+
const PAGE_SIZE = 5;
168+
169+
export function Index() {
170+
const { isLoading, data, hasNextPage, fetchNextPage } =
171+
client.getPosts.useInfiniteQuery(
172+
['posts'],
173+
({ pageParam = { skip: 0, take: PAGE_SIZE } }) => ({
174+
query: { skip: pageParam.skip, take: pageParam.take },
175+
}),
176+
{
177+
getNextPageParam: (lastPage, allPages) =>
178+
lastPage.status === 200
179+
? lastPage.body.count > allPages.length * PAGE_SIZE
180+
? { take: PAGE_SIZE, skip: allPages.length * PAGE_SIZE }
181+
: undefined
182+
: undefined,
183+
}
184+
);
185+
186+
if (isLoading) {
187+
return <div>Loading...</div>;
188+
}
189+
190+
if (!data) {
191+
return <div>No posts found</div>;
192+
}
193+
194+
const posts = data.pages.flatMap((page) =>
195+
page.status === 200 ? page.body.posts : []
196+
);
197+
198+
//...
199+
}
200+
```

apps/docs/sidebars.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ const sidebars = {
7676
label: '@ts-rest/react-query',
7777
id: 'react-query',
7878
},
79+
{
80+
type: 'doc',
81+
label: '@ts-rest/vue-query',
82+
id: 'vue-query',
83+
},
7984
{
8085
type: 'category',
8186
label: '@ts-rest/nest',
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# example-microservice-web-app-vue
2+
3+
## Running locally
4+
5+
> `nx serve example-microservice-web-app-vue`
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="theme-color" content="#000000" />
7+
<link rel="shortcut icon" type="image/svg" href="/src/assets/favicon.svg" />
8+
<title>Vue App</title>
9+
</head>
10+
<body>
11+
<noscript>You need to enable JavaScript to run this app.</noscript>
12+
<div id="app"></div>
13+
14+
<script src="/src/index.ts" type="module"></script>
15+
</body>
16+
</html>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "web-app-vue",
3+
"version": "0.0.0",
4+
"description": ""
5+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "example-microservice-web-app-vue",
3+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
4+
"projectType": "application",
5+
"sourceRoot": "apps/example-microservice/vue-app/src",
6+
"targets": {
7+
"build": {
8+
"executor": "nx:run-commands",
9+
"options": {
10+
"command": "cd apps/example-microservice/web-app-vue && pnpm exec vite build"
11+
}
12+
},
13+
"serve": {
14+
"executor": "nx:run-commands",
15+
"options": {
16+
"command": "cd apps/example-microservice/web-app-vue && pnpm exec vite"
17+
}
18+
},
19+
"lint": {
20+
"executor": "@nx/linter:eslint",
21+
"options": {
22+
"lintFilePatterns": ["apps/example-microservice/web-app/**/*.tsx"]
23+
}
24+
}
25+
},
26+
"tags": []
27+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<template>
2+
<main>
3+
<h1>Posts from posts-service</h1>
4+
5+
<section>
6+
<h2>Create Post</h2>
7+
<div v-if="isFetching">Loading...</div>
8+
<div v-if="error">Error: {{ error }}</div>
9+
10+
<button @click="createMockPost()">Create mock post</button>
11+
12+
<h3>Latest post</h3>
13+
<Post
14+
v-if="data?.body && isSuccess"
15+
v-bind="data.body"
16+
@delete="onDelete($event)"
17+
/>
18+
19+
<h3>All created posts</h3>
20+
<Post
21+
v-for="post in createdPosts"
22+
:key="post.id"
23+
v-bind="post"
24+
@delete="onDelete($event)"
25+
/>
26+
</section>
27+
28+
<section>
29+
<h2>Infinite query posts</h2>
30+
hasNext: {{ hasNextPage }}
31+
<Post
32+
v-for="post in infinitePosts"
33+
:key="post.id"
34+
v-bind="post"
35+
@delete="onDelete($event)"
36+
></Post>
37+
<button @click="fetchNextPage()">load more</button>
38+
</section>
39+
</main>
40+
</template>
41+
42+
<script lang="ts" setup>
43+
import { computed } from 'vue';
44+
import { client } from './api/client';
45+
import { useCreateMockPost } from './hooks/use-create-post';
46+
import { useInfinitePosts } from './hooks/use-infinite-posts';
47+
import Post from './Post.vue';
48+
49+
const { posts: createdPosts, createMockPost } = useCreateMockPost();
50+
const {
51+
posts: infinitePosts,
52+
fetchNextPage,
53+
hasNextPage,
54+
refetch: refetchInfinite,
55+
} = useInfinitePosts();
56+
57+
const postId = computed(
58+
() => createdPosts.value[createdPosts.value.length - 1]?.id
59+
);
60+
const { data, error, isFetching, isSuccess } = client.getPost.useQuery(
61+
['posts', postId],
62+
() => ({ params: { id: postId.value } }),
63+
{ enabled: computed(() => !!postId.value) }
64+
);
65+
66+
const onDelete = (id: string) => {
67+
const index = createdPosts.value.findIndex((post) => post.id === id);
68+
createdPosts.value.splice(index, 1);
69+
70+
refetchInfinite();
71+
};
72+
</script>
73+
74+
<style scoped>
75+
main {
76+
max-width: 800px;
77+
margin: 0 auto;
78+
}
79+
80+
section {
81+
margin: 8rem 0;
82+
}
83+
</style>

0 commit comments

Comments
 (0)