【Vue.js】Composable関数でロジックを共通化する方法|useFetch・useFormの実装例

【Vue.js】Composable関数でロジックを共通化する方法|useFetch・useFormの実装例 Vue.js

Composable関数は、Vue 3のComposition APIを使って、「状態を持つロジック」を切り出し、再利用する関数です。コンポーネントをまたいで同じ処理を書く代わりに、1つの関数にまとめられます。この記事では、基本のルールと、副作用の後始末、そして実用的なuseFormとuseFetchの作り方を、動くコードで整理します。

スポンサーリンク

Composable関数とは

Composable関数は、ref、computed、watchなどを内部で使い、状態とそれを操作する関数を返します。名前はuseで始めるのが慣例です。状態を持たない、ただの計算の関数(日付の整形など)は、Composableではなく、通常のユーティリティ関数です。

  • 名前はuseXxx(キャメルケース)にする
  • <script setup>かsetup()の中で、同期的に呼ぶ
  • 戻り値は、refを含む通常のオブジェクトにする。分割代入しても、リアクティブのままになる

同期的に呼ぶ必要があるのは、Vueが「今どのコンポーネントのsetupが動いているか」を知る必要があるためです。ライフサイクルフックやウォッチャーが、そのコンポーネントに紐づけられます。

基本:状態は呼び出しごとに別になる

Composableの中で作ったrefは、呼び出しごとに別の状態になります。同じComposableを複数回使っても、互いに影響しません。

JavaScript:useCounter
// composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initial = 0) {
  // 関数の中で作った ref は、呼び出しごとに別の状態になる
  const count = ref(initial)

  function increment() {
    count.value++
  }

  // ref を含む、普通のオブジェクトで返す(分割代入しても、リアクティブのまま)
  return { count, increment }
}
Vue:同じComposableを2回呼ぶ
<script setup>
import { useCounter } from './composables/useCounter'

// 同じ Composable を2回呼んでも、状態は別々
const a = useCounter(0)
const b = useCounter(10)
</script>

<template>
  <button @click="a.increment">A:{{ a.count }}</button>
  <button @click="b.increment">B:{{ b.count }}</button>
</template>

状態を共有したいときは、関数の外に置く

呼び出した全員で同じ状態を共有したい場合は、状態を関数の外(モジュールのスコープ)に置きます。ただし、アプリ全体で共有する状態が増えるなら、Piniaを使った状態管理の始め方のようなストアのほうが管理しやすくなります。

JavaScript:共有する状態
// composables/useSharedCounter.js
import { ref } from 'vue'

// 関数の「外」に置くと、呼び出した全員で同じ状態を共有する
const count = ref(0)

export function useSharedCounter() {
  function increment() {
    count.value++
  }
  return { count, increment }
}

副作用は後始末する:useEventListener

イベントリスナーやタイマーのような副作用をComposableの中で使うときは、コンポーネントが破棄されるときに、必ず後始末をします。公式も、onUnmountedでの解除を勧めています。これを忘れると、画面を移動するたびにリスナーが残り、メモリリークの原因になります。

JavaScript:useEventListener
// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  onMounted(() => target.addEventListener(event, callback))
  // 副作用は、コンポーネントが破棄されるときに必ず後始末する
  onUnmounted(() => target.removeEventListener(event, callback))
}
JavaScript:ほかのComposableを組み合わせる
// composables/useWindowWidth.js
import { ref } from 'vue'
import { useEventListener } from './useEventListener'

export function useWindowWidth() {
  const width = ref(window.innerWidth)

  // Composable の中で、別の Composable を呼べる
  useEventListener(window, 'resize', () => {
    width.value = window.innerWidth
  })

  return { width }
}

このように、Composableの中で、ほかのComposableを呼べるので、小さな部品を組み合わせて、複雑な機能を作れます。

useForm:ルールを引数で受け取る汎用版

フォームの検証をまとめるuseFormです。項目名や検証ルールを関数の中に書いてしまうと、特定のフォーム専用になり、再利用できません。ルールを引数で受け取る形にします。

JavaScript:composables/useForm.js
// composables/useForm.js
import { reactive, ref, watch } from 'vue'

// rules:{ 項目名: 値 => エラーメッセージ(なければ空文字) }
export function useForm(initialState, rules) {
  const form = reactive({ ...initialState })
  const errors = reactive({})
  const submitted = ref(false)

  function validate() {
    for (const key of Object.keys(rules)) {
      errors[key] = rules[key](form[key]) || ''
    }
    return Object.values(errors).every(message => !message)
  }

  // 送信を試みたあとは、入力のたびに検証し直す(直したエラーが消える)
  watch(form, () => {
    if (submitted.value) validate()
  })

  // 検証に通ったときだけ、onValid を呼ぶ関数を返す
  function handleSubmit(onValid) {
    return () => {
      submitted.value = true
      if (validate()) onValid({ ...form })
    }
  }

  return { form, errors, handleSubmit }
}
Vue:useFormの使い方
<script setup>
import { useForm } from './composables/useForm'

const { form, errors, handleSubmit } = useForm(
  { name: '', email: '' },
  {
    name: (v) => (v ? '' : '名前は必須です'),
    email: (v) => (/^\S+@\S+$/.test(v) ? '' : 'メールアドレスの形式が正しくありません')
  }
)

const onSubmit = handleSubmit((values) => {
  console.log('送信する値:', values)
})
</script>

<template>
  <form @submit.prevent="onSubmit">
    <div>
      <input v-model="form.name" placeholder="名前">
      <p v-if="errors.name">{{ errors.name }}</p>
    </div>
    <div>
      <input v-model="form.email" placeholder="メールアドレス">
      <p v-if="errors.email">{{ errors.email }}</p>
    </div>
    <!-- 送信ボタンは無効にしない(押せない理由が分からなくなるため) -->
    <button type="submit">送信</button>
  </form>
</template>

送信ボタンを無効にしてはいけない理由

検証の結果で、送信ボタンをdisabledにする書き方をよく見ますが、組み方によっては、ボタンが二度と押せなくなります。エラーのメッセージは、validate()を呼ばないと更新されません。ボタンが無効ではvalidate()を呼べず、入力を直してもエラーが消えない、という詰んだ状態になります。そこで上のコードでは、ボタンは常に押せるようにし、送信を試みた後は、入力のたびに再検証して、直ったエラーが消えるようにしています。

Vue:やってはいけない例
<!-- NG:一度エラーになると、二度と押せなくなる -->
<button :disabled="!isValid">送信</button>

<!-- isValid は errors から作られるが、errors は validate() を呼ばない限り更新されない。
     ボタンが無効だと validate() を呼べないので、入力を直してもエラーが消えず、詰んでしまう -->

フォームの検証の詳しい考え方は入力フォームのバリデーション機能を実装する方法、項目を増減できるフォームは動的フォームの作り方とバリデーション設計をご覧ください。

useFetch:URLの変化に追従する

API取得のuseFetchは、引数をrefやgetterでも受け取れるようにすると、実用的になります。toValueで値を取り出し(Vue 3.3以降)、watchEffectの中で読むと、URLが変わるたびに、再取得されます。

JavaScript:composables/useFetch.js
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  // watchEffect は setup 中に即時実行されるので、最初の描画前に loading が true になる
  watchEffect(async () => {
    const target = toValue(url)   // await より前(同期の部分)で読む。URL が変わると再実行される

    loading.value = true
    data.value = null
    error.value = null
    try {
      const res = await fetch(target)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  })

  return { data, error, loading }
}

これは簡易版です。続けて取得したときに、古い応答が新しい結果を上書きする問題(競合状態)や、取得の中止、状態をstatusで一本化する設計までを扱うなら、非同期APIの状態管理パターンをご覧ください。また、VueUseにもuseFetchがあるため、自作に同じ名前を付けると紛らわしくなります。useApiのように別の名前にするのもおすすめです。

関連記事

よくある質問(FAQ)

QComposableを複数回呼ぶと、状態は共有されますか?
A共有されません。関数の中で作ったrefは、呼び出しごとに別の状態です。共有したいなら、状態を関数の外に置くか、Piniaなどのストアを使ってください。
QComposableは、どこで呼べばよいですか?
A<script setup>かsetup()の中で、同期的に呼びます。非同期の処理の途中や、イベントハンドラの中で呼ぶと、ライフサイクルフックなどが正しく紐づきません。
QComposableの中で、イベントリスナーやタイマーを使ってもよいですか?
A使えますが、onUnmountedで必ず解除してください。解除しないと、コンポーネントが破棄されても残り続け、メモリリークの原因になります。
Q送信ボタンを、検証エラーのときだけ無効にしてもよいですか?
Aおすすめしません。押せない理由が分からなくなり、実装によっては、二度と押せなくなります。ボタンは押せるままにして、送信時にエラーを表示するほうが、使いやすくなります。

まとめ

Composable関数は、状態を持つロジックを、呼び出しごとに独立した形で再利用するための仕組みです。setupの中で同期的に呼び、副作用はonUnmountedで後始末し、固有の項目名はベタ書きせず、引数で受け取るようにすると、本当に使い回せる部品になります。