今日は Vue3 の composable(コンポーザブル)を触ったので、そのメモです。
そろそろ Nuxt に移りたいのですが、Nuxt は composables/ ディレクトリが前提の作りらしく、ここを飛ばすと後で困りそうだったので先に手を付けました。画像解析の結果を一覧で見せる画面を自分で作れるようになるのが目標です。
まだ途中段階なので、勘違いなどあれば教えていただけるとうれしいです。
過去の学習ログはこちらです。
今日やったこと
一言でいうと「<script setup> の処理を外に切り出して使い回せるようにした」日でした。
- composable が何なのかを公式ドキュメントで確認した
- カウンターの例で、切り出し方の型を覚えた
- 画像解析結果の一覧取得を composable にしてみた
- ライフサイクルを含む composable を試した
まず、composable とは何か
公式ドキュメントのコンポーザブルでは、「Composition API を活用して、状態を持つロジックをカプセル化して再利用する関数」と説明されています。自分の理解では、ただの JavaScript の関数です。ただし中で ref や computed を使っていて、リアクティブな状態ごと持ち出せる関数、という感じです。
コンポーネントを切り出すのは「見た目の再利用」、composable を切り出すのは「処理の再利用」。この対比が一番しっくりきました。
一番小さい例で型を覚える
いきなり実用的なものを書くと混乱しそうだったので、まずカウンターで練習しました。何を返せばいいのか、を確認するコードです。
composables/useCounter.js を作ります。
// composables/useCounter.js
// composable の中でも、コンポーネントの <script setup> と同じように ref が使える。
import { ref, computed } from 'vue'
// 関数名は use から始めるのが Vue の慣習。
export function useCounter(initial = 0) {
// この count は「この関数を呼んだ人ごと」に新しく作られる。
// 2つのコンポーネントで呼ぶと状態は別々。ここが Pinia との一番の違いだと思う。
const count = ref(initial)
// count が変われば、これも自動で更新される。
const isZero = computed(() => count.value === 0)
// 関数の中では .value を付けて中身を触る。
// テンプレート側では .value が不要なので、ここが最初に混乱したところ。
const increment = () => { count.value++ }
const reset = () => { count.value = initial }
// 呼び出し側に渡したいものだけ返す。返さなければ外からは触れない。
return { count, isZero, increment, reset }
}
使う側のコンポーネントです。
<script setup>
// 素の Vue3(Vite)ではパスを書いて import する。
// Nuxt では composables/ 配下が自動 import されるので、この行は不要になるらしい。
import { useCounter } from '../composables/useCounter'
// 分割代入で受け取る。
const { count, isZero, increment, reset } = useCounter(0)
</script>
<template>
<!-- テンプレート内では count.value ではなく count でよい -->
<p>現在の枚数:{{ count }}</p>
<!-- computed の結果もそのまま条件に使える -->
<p v-if="isZero">まだ1枚も選ばれていません</p>
<button @click="increment">追加</button>
<button @click="reset">リセット</button>
</template>
書いてみると本当にただの関数でした。
![]() |
💬「難しい機能かと思ってたけど、切り出して return するだけなんだね。」 |
難しいのは仕組みではなく、「どこまでを切り出すか」の判断のほうだと感じています。
画像解析結果の一覧取得を composable にする
ここからが本題です。解析結果のJSONを取ってきて一覧表示する想定で、取得中・失敗・成功の3状態をどのコンポーネントでも同じように扱いたかったので切り出しました。
// composables/useAnalysisList.js
import { ref } from 'vue'
export function useAnalysisList() {
// 画面に出したい3点セット(データ本体 / 読み込み中表示 / 失敗メッセージ)。
const items = ref([])
const isLoading = ref(false)
const error = ref(null)
const fetchList = async (keyword = '') => {
isLoading.value = true // ここで true にすると、画面が即座にローディング表示に変わる
error.value = null // 前回の失敗表示が残らないようクリア
try {
const res = await fetch(`/api/analysis?keyword=${keyword}`)
if (!res.ok) throw new Error('取得に失敗しました')
items.value = await res.json()
} catch (e) {
// 失敗しても items は前回のまま残る。消したいならここで空配列にする。
error.value = e.message
} finally {
// finally を忘れると、失敗時にスピナーが回り続ける。
isLoading.value = false
}
}
return { items, isLoading, error, fetchList }
}
呼び出し側は、状態の管理を気にしなくてよくなりました。
<script setup>
import { onMounted } from 'vue'
import { useAnalysisList } from '../composables/useAnalysisList'
const { items, isLoading, error, fetchList } = useAnalysisList()
// 画面が表示されたタイミングで最初の1回を取得する
onMounted(() => fetchList())
</script>
<template>
<!-- fetchList('桜') のように呼べば、同じ仕組みで絞り込みもできそう -->
<p v-if="isLoading">読み込み中...</p>
<p v-else-if="error">{{ error }}</p>
<ul v-else>
<!-- key があると、Vue が行の差分を正しく判断できる -->
<li v-for="item in items" :key="item.id">
{{ item.fileName }} / スコア {{ item.score }}
</li>
</ul>
</template>
写真ポートフォリオの一覧でも検索結果画面でも、fetchList に渡す条件を変えるだけで同じ表示ロジックが使い回せそうです。APIをつなぐのはまだこれからですが、形は見えてきました。
ライフサイクルを含む composable
composable の中で onMounted / onUnmounted が使えるのも便利でした。ウィンドウ幅で画像一覧の列数を変えたい、という用途の想定です。
// composables/useWindowWidth.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowWidth() {
const width = ref(0)
const update = () => { width.value = window.innerWidth }
// composable の中でもライフサイクルフックが使える。
// 後片付けまで composable 側に閉じ込められるのが気持ちいい。
onMounted(() => {
update()
window.addEventListener('resize', update)
})
// 解除を忘れると、コンポーネントが消えても監視が残ってしまう。
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return { width }
}
つまずいたところ
一言でいうと「どこまで切り出すか」と「返し方」で迷いました。
1. コンポーネントとの切り分けが分からない
最初、一覧の見た目まで composable に入れようとして手が止まりました。
![]() |
💬「見た目もまとめたくなるけど、それはコンポーネントの仕事だよね。」 |
今は「<template> に書きたくなったらコンポーネント、<script setup> に書いていたものだけ composable」と分けています。単純ですが、この線引きで迷わなくなりました。
2. オブジェクトごと返すとリアクティブが切れる
return { ...state } のようにスプレッドで返したところ、値が更新されなくなりました。分割代入で ref の中身を取り出してしまうと、そこで参照が切れるようです。
暫定的に「ref は ref のまま返す」というルールにしました。reactive を分割代入で渡したい場合は toRefs を使うらしいのですが、まだ試せていません。
3. 状態が共有されるのか、されないのか
useCounter() を2箇所で呼んだとき、状態が共有されると思い込んでいました。実際は呼ぶたびに新しい ref が作られるので別々でした。
![]() |
💬「じゃあ全画面で共有したい値は、どこに置けばいいんだろう。」 |
多分これが Pinia の出番なのだと思います。ref を関数の外(モジュールのトップレベル)に置けば共有できるらしいのですが、SSR だとリクエスト間で状態が漏れる問題があると読んだので、Nuxt に行く前に確認しておきたいです。
今の理解(メモ)
まだふわっとしていますが、今の自分の頭の中ではこう整理しています。
- composable は「リアクティブな状態を持てる、ただの関数」
- 名前は
useから始め、composables/に1ファイル1つ置く - 中では
ref/computed/ ライフサイクルフックがそのまま使える - 呼び出すたびに状態は新しく作られる。共有はしない
- 見た目の再利用はコンポーネント、処理の再利用は composable
- Vue2 の mixin の置き換えで、どこから来た値か追いやすいのが利点らしい
Nuxt との関係でいうと、useFetch や useState といった Nuxt の主要な機能はすべて composable の形をしています。作法が分かっていないと、Nuxt 側の API も「なぜこう書くのか」が分からないまま使うことになりそうです。ここを先にやってよかったと思っています。
次にやりたいこと
次は「共有する状態」と「画面遷移」を確かめたいです。
- Vue Router を触る。Nuxt のファイルベースルーティングの下地なので、動的ルートまではやっておきたい
- Pinia で、複数画面から参照する状態(検索条件など)を扱ってみる
-
useAnalysisListに検索フォームをつなぎ、一覧 → 詳細の遷移まで作る - そのうえで Nuxt に移り、自動 import と
useFetchがどこまで書く量を減らしてくれるか確かめる
![]() |
💬「Nuxtに行く前の宿題が、だいぶはっきりしてきたね。」 |
今日の整理
今日出てきたものを、後から見返せるように整理しておきます。
| 用語 | 役割(今の理解) | 利用イメージ | 次に確認したいこと |
|---|---|---|---|
| composable | 状態付きの処理を切り出す関数 | 一覧取得、ウィンドウ幅の監視 | どこまで細かく分けるべきか |
use〜 命名 |
composable であることを示す慣習 | useAnalysisList |
Nuxt の自動 import との関係 |
ref を返す |
呼び出し側でもリアクティブを保つ | 一覧データ、ローディング状態 |
toRefs を使う場面 |
| ライフサイクル | 後片付けを composable 内に閉じる | resize の監視解除 | SSR ではどう動くか |
| Pinia(未着手) | 画面をまたぐ状態の共有 | 検索条件、ログイン情報 | composable との使い分け |
処理の流れとしては、こう考えています。
おわりに
まだなんとなく動く段階ですが、「コンポーネントに全部書かなくていい」と分かっただけでも見通しがよくなりました。今までは <script setup> がどんどん長くなって、どこに何を書いたか分からなくなっていたので、切り出す先ができたのは大きいです。
もともとは、画像解析の結果をブラウザで見せたい、というところから始めた学習でした。一覧を出して、条件で絞って、詳細を見る。そのくらいの画面なら、composable と Vue Router と Pinia が揃えば形にできそうです。
学習中の内容なので、勘違いや理解不足もあると思います。もし気になる点があれば教えてもらえるとうれしいです。
注意事項
本ブログに掲載している内容は、私個人の見解であり、
所属する組織の立場や戦略、意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますのでご了承ください。
