跳到主要内容
稻草人
プロフィール

EP.02

💻Technology

Vue 3 Composition APIで状態を整理する

Vue 3のComposition APIを使って、画面ロジックと状態を整理する方法。

BY 稻, 草人

Vue 3のComposition APIを使い始めたとき、refやreactiveの文法自体はそれほど難しくありませんでした。しかし画面が大きくなると、検索条件、一覧、ローディング、エラー、モーダル、API通信が一つの<script setup>に集まり、「この値はどこで変わったのか」が追いにくくなります。

この記事では、文法紹介だけではなく、状態をどう分類すると保守しやすいかを実務目線で整理します。

目次

状態を4種類に分ける

自分は画面の値を次の4種類に分けます。

  1. 入力状態:検索キーワード、フォームの値
  2. サーバー状態:APIから取得した一覧や詳細
  3. 表示状態:モーダル、ローディング、エラー
  4. 算出状態:他の値から計算できる件数や絞り込み結果

算出できる値までrefとして別に持つと更新漏れが発生します。件数は手動更新せず、computedにしたほうが安全です。

import { computed, ref } from 'vue'

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

const users = ref<User[]>([])
const keyword = ref('')

const filteredUsers = computed(() => {
  const value = keyword.value.trim().toLowerCase()
  if (!value) return users.value
  return users.value.filter(user =>
    user.name.toLowerCase().includes(value)
  )
})

const resultCount = computed(() => filteredUsers.value.length)

refとreactiveの使い分け

自分は次の基準にすると迷いにくいと感じています。

  • 単一値、配列、後で丸ごと置換する値:ref
  • 一つのフォームとしてまとめて扱うオブジェクト:reactive
  • 他の状態から計算できる値:computed
const selectedId = ref<number | null>(null)
const items = ref<Item[]>([])

const form = reactive({
  title: '',
  categoryId: null as number | null,
  published: false
})

reactiveのプロパティをそのまま分割代入すると、リアクティビティが切れる場合があります。必要ならtoRefs()を使います。

const { title, published } = toRefs(form)

Vue公式ドキュメントでも、reactive()にはプリミティブ値を扱えない、オブジェクト全体を置換しにくい、分割代入で接続が失われるといった制限が説明されています。そのため、迷った場合はrefを基本にすると統一しやすいです。

computedとwatchを分ける

computedは値を作るため、watchは変更をきっかけに副作用を起こすために使います。

const fullName = computed(() =>
  `${lastName.value} ${firstName.value}`
)

watch(
  () => filters.keyword,
  async () => {
    await fetchUsers()
  }
)

入力のたびに通信するとリクエストが増えるため、検索ボタン方式にするか、debounceを入れるかを仕様として決めます。watchの中に表示計算まで入れると処理の入口が見えにくくなります。

API通信の状態を一緒に管理する

const users = ref<User[]>([])
const loading = ref(false)
const errorMessage = ref('')

async function fetchUsers() {
  loading.value = true
  errorMessage.value = ''

  try {
    const response = await fetch('/api/users')
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    users.value = await response.json()
  } catch (error) {
    console.error(error)
    errorMessage.value = 'ユーザー情報の取得に失敗しました。'
  } finally {
    loading.value = false
  }
}

データだけでなく、通信中、失敗、0件を別の状態として扱うことが大切です。

composableへ切り出す基準

コードが長いという理由だけで分割すると、ファイルを行き来する回数が増えます。自分は次の条件で切り出します。

  • 複数コンポーネントで同じ処理を使う
  • API通信と関連状態を一つの機能としてまとめられる
  • タイマーやイベントリスナーの開始・終了管理が必要
  • 表示責任と分けたほうが読みやすい
export function useUsers() {
  const users = ref<User[]>([])
  const loading = ref(false)
  const errorMessage = ref('')

  async function fetchUsers() {
    // API処理
  }

  return { users, loading, errorMessage, fetchUsers }
}

画面内だけで使うモーダル状態までPiniaへ入れる必要はありません。状態は使う場所の近くに置き、複数画面で本当に共有するときだけグローバルへ広げます。

DOM更新のタイミング

状態を変更してもDOMはその場で同期更新されません。変更後にフォーカス移動やサイズ計測を行うならnextTick()を使います。

isOpen.value = true
await nextTick()
dialogElement.value?.focus()

チェックリスト

  • 同じ意味の状態を複数持っていないか
  • 算出値をcomputedにできないか
  • watchは副作用だけを担当しているか
  • reactiveを不用意に分割代入していないか
  • APIのdata、loading、errorを管理しているか
  • composableの責任が一つにまとまっているか
  • 画面内の状態をグローバルへ出しすぎていないか
  • DOM更新後の処理にnextTick()が必要か

まとめ

Composition APIのメリットは、コードを短くすることより、関連する状態と処理を近くに置けることだと思います。

値が入力状態、サーバー状態、表示状態、算出状態のどれなのかを先に整理すると、ref、computed、watchの役割も決めやすくなります。最初から完璧なcomposableを作らず、責任が見えてから切り出すほうが無理のない設計になります。

参考資料