Vue 3のComposition APIを使い始めたとき、refやreactiveの文法自体はそれほど難しくありませんでした。しかし画面が大きくなると、検索条件、一覧、ローディング、エラー、モーダル、API通信が一つの<script setup>に集まり、「この値はどこで変わったのか」が追いにくくなります。
この記事では、文法紹介だけではなく、状態をどう分類すると保守しやすいかを実務目線で整理します。
状態を4種類に分ける
自分は画面の値を次の4種類に分けます。
- 入力状態:検索キーワード、フォームの値
- サーバー状態:APIから取得した一覧や詳細
- 表示状態:モーダル、ローディング、エラー
- 算出状態:他の値から計算できる件数や絞り込み結果
算出できる値まで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を作らず、責任が見えてから切り出すほうが無理のない設計になります。

