## 経緯 [[Vue]]にて、データフェッチ機能をもつコンポーネントを複数利用する画面で、同一の結果が返却されるはずの通信処理が多重化する問題を抱えている。 コンポーネントの外部でデータフェッチ処理をハンドリングするのは、責務が的確でなければ親が肥大するだけなので避けたい。かといって自前でキャッシュ処理を実装したり、[[Nuxt]]の機能に頼るのも避けたい。 [[TanStack Query]]が用途にあってそうなので試してみる。 ## プロジェクト作成 [[Tailwind CSS]]のプロジェクトを作成する。 ```console toki nuxt tanstack-query-sandbox cd tanstack-query-sandbox ``` > [Install Tailwind CSS with Nuxt](https://tailwindcss.com/docs/installation/framework-guides/nuxt) ```console bun add tailwindcss @tailwindcss/vite ``` `nuxt.config.ts` ```ts import tailwindcss from "@tailwindcss/vite"; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: "2025-07-15", devtools: { enabled: true }, ssr: false, css: ["./app/assets/css/main.css"], vite: { plugins: [tailwindcss()], }, }); ``` `app/assets/css/main.css` ```css @import "tailwindcss"; ``` `app/app.vue` ```html <template> <div class="flex items-center justify-center min-h-screen bg-gray-100"> <h1 class="text-3xl font-bold underline">Hello world!</h1> </div> </template> ``` ## ダミーAPIの作成 [[json-server]]を使って簡単なAPIをつくる。 ```console bun init json-server cd json-server bun add [email protected] ``` > [!question]- v0.17.4を指定している理由は? > [[📝json-serverのREST APIにリクエストしてもターミナルにアクセスログが表示されない]] `db.json` ```json { "posts": [ { "id": "1", "title": "a title", "views": 100 }, { "id": "2", "title": "another title", "views": 200 } ], "comments": [ { "id": "1", "text": "a comment about post 1", "postId": "1" }, { "id": "2", "text": "another comment about post 1", "postId": "1" } ], "profile": { "name": "typicode" } } ``` 起動。 ```console bun json-server db.json -p 3001 ``` 動作確認。 ```console $ curl -s "localhost:3001/posts" [ { "id": "1", "title": "a title", "views": 100 }, { "id": "2", "title": "another title", "views": 200 } ] ``` ## データ取得composableの作成 まずは[[TanStack Query]]なしで通常のデータ取得[[Composable]]をつくる。 ```ts export interface Post { id: number; title: string; views: number; } interface OwlError { code: number; message: string; } const OwlError = { from(status: number, error: Error): OwlError { return { code: status, message: error.message }; }, }; const _fetchPosts = async ( limit: number, ): Promise<{ data: Post[]; error: Error | null; response: { status: number }; }> => { const res = await fetch(`http://localhost:3001/posts?limit=${limit}`); const data = await res.json(); // 必ず成功するという前提で return { data, error: null, response: { status: 200 } }; }; export const usePosts = () => { const posts = ref<Post[] | null>(null); const loading = ref(false); const error = ref<OwlError | null>(null); const fetchPosts = async (args: { limit: number }) => { loading.value = true; const { data: res, error: _error, response, } = await _fetchPosts(args.limit); loading.value = false; if (_error) { error.value = OwlError.from(response.status, _error); return; } error.value = null; posts.value = res ?? null; }; return { posts, loading, error, fetchPosts, }; }; ``` `app.vue` で利用する。 ```html <script setup lang="ts"> const { posts, fetchPosts, loading, error } = usePosts(); const handleClick = async () => { await fetchPosts({ limit: 2 }); }; </script> <template> <div class="flex justify-center items-center min-h-screen flex-col"> <div v-if="loading">Loading...</div> <div v-if="error">Error: {{ error.message }}</div> <ul v-if="posts"> <li v-for="post in posts" :key="post.id">{{ post.title }}</li> </ul> <button class="bg-gray-300 p-2 border rounded-md" @click="handleClick"> Refresh Posts </button> </div> </template> ``` この処理は `Refresh Posts` ボタンを押すたびにサーバーへとリクエストされる。 ## [[TanStack Query]]の導入 まずはインストール。 ```console bun add @tanstack/vue-query ``` > [Installation | TanStack Query Vue Docs](https://tanstack.com/query/latest/docs/framework/vue/installation) プラグインとして登録。 `app/plugins/vue-query.ts` ```ts import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query"; export default defineNuxtPlugin((nuxtApp) => { const queryClient = new QueryClient({ defaultOptions: { queries: { /** データが新鮮とみなされる時間 = キャッシュ保持時間 (ミリ秒) */ staleTime: 5 * 1000, /** タブ・ウィンドウにフォーカスし直した時の自動再取得設定 */ // refetchOnWindowFocus: false, /** 定期的にデータを更新したい場合に使用 */ // refetchInterval: 2 * 1000, /** ネットワーク再接続時の自動再取得設定 */ refetchOnReconnect: false, /** 失敗時の自動再試行設定 (数値で回数指定) */ retry: false, }, }, }); nuxtApp.vueApp.use(VueQueryPlugin, { queryClient, }); }); ``` 少し構成を変更し、通信機能つきコンポーネント(Posts)を3つに増やした。 `app.app.vue` ```html <template> <div class="flex justify-center items-center gap-12 p-20"> <Posts /> <Posts /> <Posts /> </div> </template> ``` `app/components/Posts.vue` ```html <script setup lang="ts"> const limit = ref(2); const { posts, error, fetchPosts, isFetching, isPending } = usePosts({ _limit: limit, }); const handleClick = async () => { await fetchPosts(); }; </script> <template> <div v-if="isPending" class="flex justify-center">Pending...</div> <div v-else class="flex justify-center flex-col"> <label for="limit" class="font-bold">Number of Posts to Fetch:</label> <div class="flex gap-4 items-center"> <input type="number" v-model.number="limit" class="border p-1 mt-2 w-20" /> <button class="bg-gray-300 p-2 border rounded-md" @click="handleClick"> refresh </button> </div> <div class="mt-4 flex flex-col items-center gap-4"> <div v-if="isFetching">Loading...</div> <div v-if="error">Error: {{ error.message }}</div> <ul v-if="posts"> <li v-for="post in posts" :key="post.id">{{ post.title }}</li> </ul> </div> </div> </template> ``` [[Composable]]も変更。`fetchPosts` はIFが抽象化されただけでほぼ変わっていない。ここは[[OpenAPI fetch]]のIFを意識したやっつけ実装なので気にせず。 `app/composables/usePosts.ts` ```ts import { useQuery } from "@tanstack/vue-query"; export interface Post { id: number; title: string; views: number; } interface OwlError { code: number; message: string; } const OwlError = { from(status: number, error: Error): OwlError { return { code: status, message: error.message }; }, }; /** * OpenAPI fetchの想定 */ const _fetchPosts = async ( path: string, query: { [key: string]: any }, ): Promise<{ data: Post[]; error: Error | null; response: { status: number }; }> => { const base = "http://localhost:3001"; const params = new URLSearchParams(query).toString(); const res = await fetch(`${base}${path}?${params}`); const data = await res.json(); // 必ず成功するという前提で return { data, error: null, response: { status: 200 } }; }; ``` 一方で `usePosts` は `useQuery` の薄いwrapperになっている。 ```ts export const usePosts = (args: { _limit: Ref<number> }) => { const { data, isPending, isFetching, error, refetch } = useQuery({ queryKey: ["/posts", args._limit], queryFn: async () => { const { data: res, error: _error, response, } = await _fetchPosts("/posts", { _limit: args._limit.value, }); if (_error) { throw OwlError.from(response.status, _error); } return res ?? null; }, }); return { posts: data, isPending, isFetching, error, fetchPosts: refetch, }; }; ``` ![[2026-01-17-16-56-10.avif]] ## ポイント ### queryKeyが同一の状態は同期される limitが2の`usePosts`で通信がエラーになると、他にのlimit2の`usePosts`もエラー扱いになる。つまりこうなる。 ![[2026-01-17-16-59-05.avif]] この要件が許容できないときは、`useQuery` は使わない方が良さそう。 上記は `error` の話だが、`isFetching`, `isPending`, `data` なども同様。一心同体。 ### 補完コンポーネントにはあわないかも? コンボボックスやオートコンプリートなど - アクティブになったら特定ルールに従ってデータを取得 - 文字入力されたら、文字でフィルタリングしたデータを取得 のようなケースでは相性よくないかも? - mountのタイミングで通信はしない - 全く同じリクエストがされるケースは多くなさそう - 一度入力した内容を消した時にあるかもしれないが、そこまでやるか? - 放置しているときにデータを再取得する必要はない - あくまでユーザーのインタラクションが前提となっているフロー ## queryClientだけを使う 状態の同一化は少々オーバーキルな気がするので、[[HTTPクライアント]]としてkeyに対するキャッシュを利用する用途のみを考える。 `app/composables/usePosts.ts` ```ts // 中略 export const usePosts = () => { const queryClient = useQueryClient(); const posts = ref<Post[] | null>(null); const loading = ref(false); const error = ref<OwlError | null>(null); const fetchPosts = async (args: { _limit: number }) => { loading.value = true; try { posts.value = await queryClient.fetchQuery({ queryKey: ["/posts", args], queryFn: async () => { const { data: res, error: _error, response, } = await _fetchPosts("/posts", args); if (_error) { throw OwlError.from(response.status, _error); } return res; }, }); error.value = null; } catch (err: any) { error.value = err; } finally { loading.value = false; } }; return { posts, loading, error, fetchPosts, }; }; ``` これで[[TanStack Query]]導入前と同じインターフェースを実現できる。 ## 結論 **今は導入を見送る。IF変更などのコストやリスクの方が大きい。** もし、使い所があるとするなら、以下のようなコンポーネントかも。 - ページ内で各自が独立に通信して内容を表示する - 各自が自分自身のリロード機能を(手動or自動)で持っている 今は[[Promise (JavaScript)|Promise]]を上手いこと使ってやっているけど、[[TanStack Query]]の方がシンプルになる可能性はある。とはいえ、キャッシュを使うものでなく、mount時のデータフローだけにしか影響ないならオーバーキルな気もする。 同一のリクエストが大量に起こるシーンに遭遇し、スマートな解決策がない場合に限り、[[#queryClientだけを使う]]方法を試してみてもいいかもしれない。