メインコンテンツまでスキップ

Composables: ロジックを関数へ切り出す

コンポーネントが育つと、状態とそれを操作する処理が script setup の中に溜まります。同じ組み合わせを別のコンポーネントでも使いたくなったときの切り出し先が Composable です。

公式は Composable を「Composition API を使って状態を持つロジックをまとめて再利用する関数」と定義しています。実務で use を付ける目安は、リアクティブな状態を作るか、呼び出し元の文脈 (コンポーネントか effect scope) に依存するかです。後者はフックやスコープの後片付けを登録する関数で、呼ぶ位置に制約が付きます。どちらにも当てはまらない関数は素のモジュールで足ります。

この章で学ぶこと​

  • 戻り値をどう返せば、分割代入しても繋がりが残るか
  • 引数を ref でも getter でも素の値でも受け取れる形に書ける
  • 副作用の後片付けを、コンポーネントに縛られない形で登録できる
  • Composable を呼べる位置と、その理由を説明できる
  • 整形関数のように Composable にしなくてよいものを見分けられる

基本の形​

状態を作り、それを操作する関数を添えて返します。

composables/useCounter.ts
import { computed, ref } from 'vue'

export function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)

const increment = () => {
count.value++
}
const reset = () => {
count.value = initial
}

return { count, doubled, increment, reset }
}

名前を use で始める camelCase にするのは慣例です。

components/Counter.vue
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'

const { count, doubled, increment, reset } = useCounter(10)
</script>

呼び出すたびに ref が新しく作られるので、状態はコンポーネントごとに分かれます。共有したい場合は後の節で扱います。

戻り値は ref を入れた素のオブジェクトにする​

公式が示す慣例は「リアクティブでない素のオブジェクトに複数の ref を入れて返す」ことです。こうすると呼び出し側で分割代入しても繋がりが残ります。

reactive() で包んだオブジェクトをそのまま返すと、プロパティ経由なら追えますが分割代入した時点で切れます。取り出した時点で ref が開かれ、素の値になるからです (2 章の reactive の分割代入と同じ理由です)。

内部を reactive で書くなら toRefs を通す​

状態が複数あって関連しているとき、内部は reactive の 1 つのオブジェクトで書くほうが読みやすくなります。この場合は toRefs() を通して返します。

composables/useStepper.ts
import { reactive, toRefs } from 'vue'

export function useStepper(initial = 0, step = 1) {
const state = reactive({ count: initial, step })

const increment = () => {
state.count += state.step
}

// 分割代入しても壊れない形に変換して返す
return { ...toRefs(state), increment }
}

toRefs() はプロパティを指す ref を作るので、分割代入しても書き込みが state に届きます。変換されるのは呼び出した時点で列挙できるプロパティだけなので、あとから足すプロパティには ref が作られません。

戻り値の形として呼び出し側に見える差はありません。内部の状態を ref で並べるか reactive でまとめるかという実装の選択で、どちらでも「素のオブジェクトに ref が入っている」形に揃えます。

呼び出し側が stepper.count の形で書きたいなら、受け取る側で reactive(useStepper()) と包みます。

引数を ref でも getter でも受け取る​

Composable に値を渡すとき、呼び出し側が持っているのは素の値のこともあれば ref のこともあります。6 章では、分割代入した prop を渡せるように Composable は getter を受け取る形で設計すると書きました。これらをまとめて受けるのが MaybeRefOrGetter 型と toValue() です。

toValue() は 3.3 で入った関数で、ref なら値を、関数なら呼んだ戻り値を、それ以外はそのまま返します。

toValue(ref('a')) // 'a'
toValue(() => 'b') // 'b'
toValue('c') // 'c'

引数の変化に追従させたいときは、toValue() を watcher のコールバックの内側で呼びます。正規化の途中で読んだ ref が、watcher の依存として登録されるからです。

watchEffect(() => {
// 内側で呼ぶので url の変化を拾える
load(toValue(url))
})

外側で先に取り出すと watcher は url を依存として持たず、url を書き換えても再実行されません。実挙動で確認しています。

注意が 1 つあります。 MaybeRefOrGetter<T> の実体は T を含む union (素の値・各種 ref・getter) なので、素の値も受け取ります。6 章では「Composable を getter を受け取る形にしておけば、値を渡した呼び出しが型検査で落ちる」と書きましたが、MaybeRefOrGetter にするとその検出は働きません。

const { userId } = defineProps<{ userId: number }>()

// 型検査は通る。しかし userId の変化には追従しない
const { data } = useFetch(`/api/users/${userId}`)

呼び出し側の柔軟さと、間違いを型で止めることは両立しません。追従が前提の引数は () => T に絞るほうが安全で、MaybeRefOrGetter は「静的な値で呼ぶこともある」場合に選びます。

この章の useFetch は公式の例に合わせて MaybeRefOrGetter にしています。固定の URL で呼ぶ用途も想定するからで、その代わり値渡しの間違いは型で止まりません。取得先が常に props から導かれる作りなら、url: () => string に絞るほうが安全です。

副作用の後片付け​

イベントリスナーやタイマーのように外の世界へ登録するものには、外す責任が付いてきます。Composable の中で登録したものは、Composable の中で外します。

composables/useEventListener.ts
import { onMounted, onUnmounted } from 'vue'

export function useEventListener<K extends keyof WindowEventMap>(
event: K,
callback: (event: WindowEventMap[K]) => void,
) {
// window の読み取りをマウント後まで遅らせる
onMounted(() => window.addEventListener(event, callback))
onUnmounted(() => window.removeEventListener(event, callback))
}

window の読み取りを onMounted の中に置いている理由は後の節で扱います。

コンポーネントに縛られない後片付け​

onUnmounted はコンポーネントのフックなので、コンポーネントの外では使えません。onScopeDispose() は現在アクティブな effect scope に後片付けを登録する関数で、公式は再利用可能な関数における onUnmounted の代替と位置づけています。

composables/useTicker.ts
import { onScopeDispose } from 'vue'

export function useTicker(onTick: () => void, interval = 1000) {
// 呼び出し時点で始めるのでサーバー描画でも動き出す (9 章で扱う)
const id = setInterval(onTick, interval)
onScopeDispose(() => clearInterval(id))
}

コンポーネントの setup にはスコープがあるので、この形はコンポーネント内でも後片付けが走ります。加えて effectScope() の中で呼べば scope.stop() で外れるため、コンポーネントの外でも使えます。

この 2 つの届く範囲は、開始のタイミングとトレードオフになります。 onMounted はコンポーネントの中でしか使えないので、開始を onMounted へ遅らせると effectScope() の中では警告が出て一度も始まりません。逆に呼び出し時点で始めれば effectScope() でも動きますが、サーバー描画でも動き出します (9 章で扱います)。

どちらを選ぶかは、その Composable をコンポーネントの外で使うかで決めます。コンポーネント専用と割り切るなら開始を onMounted に閉じ込めます。

ただし onUnmounted と走る順番は違います。 スコープの停止はアンマウントの処理の中で同期に行われ、onUnmounted は描画を流し終えた後にまとめて呼ばれます。親子で両方を並べると順序が逆になります。

親: dispose
子: dispose
子: onUnmounted
親: onUnmounted

dispose は親から子へ、onUnmounted は子から親へ進みます (7 章の順序は onUnmounted の側です)。後片付けの中で子の状態を読むなら、この差が効きます。

アクティブなスコープが無い場所で呼ぶと警告が出て、後片付けは登録されません。 3.5 以降は第 2 引数に true を渡すと警告を消せますが、消えるのは警告だけで、登録されないことは変わりません。

KeepAlive の下で非表示にしただけでは onScopeDispose も走りません。7 章の onUnmounted と同じで、キャッシュに残っている間はスコープが止まらないためです。表示していない間もリソースを手放したいなら onDeactivated を使います。

呼べる位置​

公式は「Composable は <script setup> か setup() の中で、同期に呼ぶ」と書いています。理由は、アクティブなコンポーネントインスタンスが決まる文脈でないとフックを登録できず、watcher をインスタンスに結びつけられないからです。結びついていない watcher はアンマウントで止まらず、リークになります。

この「同期に」には例外があります。

await の後です。 公式は「<script setup> は await の後で Composable を呼べる唯一の場所」と書いています。コンパイラが非同期処理の後でインスタンスの文脈を復元するためです。7 章で扱った機構と同じで、Composable の中で登録したフックも同じように働きます。手書きの async setup() にはこの復元がありません。

ライフサイクルフックの中です。 公式は「場合によっては onMounted() のようなライフサイクルフックの中でも呼べる」と書いています。フックを実行するとき、Vue はインスタンスを現在のものとして戻してから呼ぶので、その中で登録したフックはインスタンスに結びつきます。

ただし間に合うフックと間に合わないフックがあります。onMounted の中で Composable を呼ぶと、その中の onUnmounted は登録されて後で走りますが、onMounted は既に実行中の段なので登録されても走りません。

このとき警告は出ません。 Vue はインスタンスがある限り登録を受け付け、対応するフックが走り終わったかどうかは見ないためです。警告が出るのはインスタンスが無いときだけです。

この経路を使うのは、後片付けの登録だけが目的の場合に限るのが安全です。

サーバー側で壊れる書き方は次章で​

ここまでの書き方は、ブラウザだけで動かすなら問題ありません。サーバーでも描画する構成に置くと壊れるものがいくつかあります。window を呼び出し時点で読む形、onMounted に閉じ込めていない副作用、モジュールスコープに置いた状態です。まとめて 9 章で扱います。

useFetch を書く​

ここまでの点をまとめると、データ取得の Composable はこうなります。

composables/useFetch.ts
import { shallowRef, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'

export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = shallowRef<T | null>(null)
const error = shallowRef<Error | null>(null)
const loading = shallowRef(false)

watchEffect((onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())

data.value = null
error.value = null
loading.value = true

// toValue はコールバックの内側で呼ぶ
fetch(toValue(url), { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<T>
})
.then((json) => {
// 中断された実行は何も書かない
if (controller.signal.aborted) return
data.value = json
})
.catch((e: unknown) => {
if (controller.signal.aborted) return
error.value = e instanceof Error ? e : new Error(String(e))
})
.finally(() => {
if (controller.signal.aborted) return
loading.value = false
})
})

return { data, error, loading }
}

watchEffect の第 1 引数で受ける onCleanup に中断処理を渡しています。url が変わって再実行されるとき、Vue は先に前回の後片付けを呼ぶので、古いリクエストが中断されます。5 章で扱った watcher の後片付けと同じ仕組みです。

書き込みの前に signal.aborted を見ているのは、中断された実行が何も書かないようにするためです。abort() を呼ぶと fetch は AbortError で reject するので、このガードが無いともう不要になった取得のエラーが error に入ります。新しい取得が走っている最中の画面がエラー表示になるわけです。

このガードは、取得を別のラッパーに差し替えて signal を渡し忘れた場合にも効きます。abort() は signal.aborted をその場で true にするので、通信が実際に止まったかどうかとは無関係に判定できます。取り消しが効く条件は 18 章で詳しく扱います。

shallowRef を選んでいるのは、意味と型の両方の理由からです。

意味の上では、取得した JSON は丸ごと差し替える対象で、入れ子のプロパティを個別に書き換えることがありません。ref は中身を深くリアクティブにするので、大きなレスポンスでは無駄な変換になります。

型の上では、戻り値の型を明示したときに差が出ます。Ref<T | null> を返すと宣言した場合、ref<T | null>(null) の戻り値は Ref<UnwrapRef<T> | null, ...> で、型引数に対して UnwrapRef<T> が unknown まで広がるため代入できません。原稿でよく見る as Ref<T | null> はこれを黙らせるためのものです。ShallowRef<T | null> と shallowRef の組み合わせならキャストは要りません。

上のコードのように戻り値の型を書かず推論に任せる場合は、ref でも型検査は通ります。型が理由になるのは注釈を付ける設計を選んだときだけで、注釈なしなら意味の側の理由だけで shallowRef を選びます。

呼び出し側では、prop を getter で渡せば prop の変化で取り直せます。

components/UserProfile.vue
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

type User = { id: number; name: string }

const props = defineProps<{ userId: number }>()

// getter で渡すので userId が変われば取り直す
const { data: user, error, loading } = useFetch<User>(
() => `/api/users/${props.userId}`,
)
</script>

<template>
<p v-if="loading">読み込み中</p>
<p v-else-if="error">{{ error.message }}</p>
<p v-else>{{ user?.name }}</p>
</template>

この Composable をサーバーでも描画する構成に置いたときの挙動は 9 章で扱います。

整形関数を Composable にしない​

値の表示を整えるだけの関数は、Composable にする必要がありません。

utils/format.ts
export const formatCurrency = (value: number, symbol = '¥'): string =>
symbol + value.toLocaleString('ja-JP')

useFormatters() のような形で包んでも得るものがありません。リアクティブな状態も持たず、Vue の文脈にも依存しないので、import するだけで足ります。キャッシュを効かせたいかどうかは呼び出し側の computed で決める話です (5 章)。

冒頭に挙げた目安で見分けます。useCounter は状態を作るので前者です。useEventListener は状態を持ちませんが、フックを登録するので setup の中でしか呼べません。useTicker も状態を持ちませんが、後片付けをスコープに預けるのでアクティブなスコープが要ります。どちらも後者です。formatCurrency はどちらにも当てはまらないので、素の関数のままにします。

まとめ​

  • use を付ける目安は、リアクティブな状態を作るか、呼び出し元の文脈 (コンポーネントか effect scope) に依存するかです。どちらにも当てはまらない整形関数は素のモジュールで足ります
  • 戻り値は素のオブジェクトに ref を入れて返します。内部を reactive で書くなら toRefs() を通します。プロパティ形式で使いたいなら受け取る側で包みます
  • 引数は toValue() で正規化し、toValue() は watcher のコールバックの内側で呼びます。外側で取り出すと引数の変化を追えません
  • MaybeRefOrGetter は素の値も受けるので、追従しない呼び出しを型検査で止められません。 追従が前提の引数は () => T に絞ります
  • 副作用は登録した Composable の中で外します。onScopeDispose() はコンポーネントの外でも使えますが、開始を onMounted へ遅らせるとその範囲を失います。 どちらを取るかの選択です
  • onScopeDispose と onUnmounted は走る順番が逆で、dispose は親から子へ進みます。KeepAlive でキャッシュに残っている間はどちらも走りません
  • 呼ぶのは <script setup> か setup() の中で同期に、が原則です。await の後で呼べるのは <script setup> だけで、コンパイラが文脈を復元します。フックの中でも呼べますが、onMounted の中で登録した onMounted は走らず、警告も出ません
  • データ取得では shallowRef を選びます。深いリアクティビティが不要だからで、キャストの差が出るのは戻り値の型を明示したときだけです
  • 中断は signal.aborted を書き込みの前で見ます。これが無いと、abort() で reject した AbortError が error に入ります。abort() はフラグをその場で立てるので、signal が届かない経路でも同じガードで弾けます
  • ここまではブラウザだけで動かす前提です。サーバーでも描画するなら 9 章の制約が加わります
関連リファレンス

次に読む​