Vueのcomposableを理解し、不要なリクエストを防ぐ
Vue / Nuxt の開発で「共通のデータ取得ロジックをまとめたい」と思って、composable
を作ることはよくあります。
たとえばこんなコード
// useUsers.ts
export function useUsers() {
const users = ref([])
onMounted(async () => {
const data = await $fetch('/api/users')
users.value = data || []
})
return { users }
}
「これを使えば、呼ぶだけでデータが取れて便利そう!」
──そう思って A.vue と B.vue の両方で useUsers() を呼ぶとAPIは何回呼ばれるでしょうか?
いいかんじにcomposableが1回だけにしてくれたりするんでしょうか?
答え:同じAPIが2回呼ばれます。
composableは呼び出すたびに新しく実行される
個人の感覚ですが、さきほどのコードを走らせた時に
「えっ、composableって共通化の仕組みでしょ?なんで2回も動くの?自動でやってくれないの?」
と思う人が実は多いように思います。
実際には、composableは “関数” です。
呼び出されるたびに中の処理が走り、refも新しく作られます。
const a = useUsers() // 1つ目のインスタンス
const b = useUsers() // 2つ目のインスタンス
a.users と b.users は別のリアクティブ変数。
そのため onMounted 内の処理も2回実行され、同じリクエストが2回発生します。
「ライフサイクルに通信処理を書く」のは理解できるけど注意が必要
ライフサイクルフックにデータ取得を書きたくなるのはある意味自然です。Vueのライフサイクル的に、たとえばonMountedで「マウント時にデータを取る」という処理はしっくり来る考え方でしょう。
でも問題は、呼び出し元のコンポーネントごとに実行されるということ。
つまり、同じページで複数コンポーネントが同じcomposableを呼ぶと、呼び出すコンポーネントの数だけ何度でもAPIを叩くことになってしまい、リクエスト量がn倍になってしまいます。
composableにライフサイクルを書くこと自体は悪くない
ここまで読むと「ライフサイクルフックはcomposableに書かない方がいいのか」と思うかもしれませんが、問題ありませんし、書くべき時もあります。
export function useWindowSize() {
const width = ref(0)
const height = ref(0)
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
update()
window.addEventListener('resize', update)
})
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return { width, height }
}
これはブラウザウィンドウのresizeイベントを処理して利用するためのコードですが、
こうしたUIイベントやDOM依存の処理をライフサイクルで扱うのはよいことです。
危険なのは、通信処理(特にGETリクエスト)などの副作用をライフサイクルに直接書くこと。
「呼ばれるたびに新しいリクエストを発火する」原因になってしまいます。
- ✅ ライフサイクルの利用はOK
- ❌ 通信処理(fetch / useFetch)はNG
こう意識するだけで、composable設計は一気に安定します。
正しい形:通信は使う側に任せる
じゃあどこで通信すればいいのか、という話ですが
composableを「使う側」で明示的に通信するのがわかりやすくかつ安全です。
composableには「何を提供するか」だけを定義し、
「いつ実行するか」は使う側に任せる、という設計にします。
export function useUsers() {
const users = ref([])
async function fetchUsers() {
const data = await $fetch('/api/users')
users.value = data || []
}
return { users, fetchUsers }
}
<!-- A.vue -->
<script setup lang="ts">
const { users, fetchUsers } = useUsers()
onMounted(fetchUsers) // 実行タイミングを“使う側”で制御
</script>
こうすると:
- composableの責務が明確になる(“何を提供するか”だけ)
- 使う側で「いつ動かすか」を制御できる
- 無駄なリクエストも防げる
なぜcomposableが誤解されるのか
composableの役割を誤解する理由は、“関数だけど状態を持つ”という曖昧な立ち位置にあるように思いました。
ふつう「関数」は呼ぶたびに新しい結果を返します。
でもVueのrefやreactiveが関わると、そこに“状態を持ち続けるもの”が生まれる。
結果、「これ1回書けばどこでも共有されるのでは?」という錯覚が起きやすいのではないでしょうか。
さらに「共通化=共有」という言葉のイメージも強いので、
ロジックを再利用したいのか、状態を共有したいのかを混同しやすいのかもしれません。
- composable → ロジックを“再利用”する場所
- store(Piniaなど) → 状態を“共有”する場所
この線を明確に引くとグッとシンプルになります。
ちなみにReactだったら?
ReactのHooks(useUsersなど)も似た構造ですが、挙動は少し違います。
React Hooksも呼ばれるたびに新しい状態を持つので、
Vueのcomposableと同様に呼び出しごとに独立した状態が作られます。
ただしReactでは、関数コンポーネント自体が1つのライフサイクルを持つため、
複数コンポーネントで同じHookを呼ぶケースがそもそも少ない。
また、fetchを直接Hook内に書くより、
useEffectを明示的に使うことが多く、
「いつ通信するか」を呼び出し側で意識しやすい構造になっています。
つまり仕組みは似ているけれど、Vueの方が“暗黙に動く”余地が大きい分、
扱いに注意して、意図しない挙動を防ぐ必要があります。
まとめ
- composableは毎回新しく実行される関数
- 状態(ref)は呼び出しごとに生成される製品
- onMountedでリクエスト処理を書くと、呼び出し回数分リクエストが飛ぶ
- 通信処理は呼び出し側で明示的に実行する
- 状態を共有したいならstoreへ、ロジックを再利用したいならcomposableへ
- ライフサイクルを使うこと自体はOK、通信処理を中に書くのが危険
🪄 StudioはVue Fes Japan 2025、 参加します
StudioはVueコミュニティにこれからも貢献していきたいと思っています。
前年までに引き続き、今年もVue Fes Japan 2025に参加し、今回は学生支援スポンサーとして参加します。
弊社メンバーのLTも行われます。私も行きますので、会場でぜひお会いしましょう!
Discussion