Component v-model - 親子双方向バインディングの契約
ロードマップ: Vue.js学習ロードマップ
What(何についてか)
Vue のコンポーネントに対して v-model を適用し、親コンポーネントの state と子コンポーネント内部の入力値を双方向に同期するための仕組みを扱う。Vue 3.4 以降では defineModel() により、この契約を ref のような形で扱える。
Why(なぜ必要か)
フォーム入力コンポーネントや再利用 UI 部品では、親が値を保持しつつ、子がその値を表示・更新できる必要がある。v-model を使うと、単なる props の受け渡しだけでなく、更新通知まで含めた一貫した API を設計できる。
How(どう動くか)
Basic Usage
defineModel() は、親から v-model で渡された値を子コンポーネント内で ref として扱うためのコンパイラマクロである。
<script setup lang="ts">
const model = defineModel<number>({ required: true })
</script>
<template>
<input v-model="model" />
</template>親側では次のように state を保持する。
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const countModel = ref(0)
</script>
<template>
<Child v-model="countModel" />
</template>このとき初期値は子ではなく親が持つ。子の model.value を更新すると、親の countModel も更新される。
Under the Hood
defineModel() は実体として prop + emit の組み合わせに展開される。
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>親の <Child v-model="foo" /> は概念的には次のように解釈される。
<Child
:modelValue="foo"
@update:modelValue="$event => (foo = $event)"
/>v-model は双方向バインディングに見えるが、実体は一方向のデータ受け渡しと更新通知の往復である。
defineModel のオプション
defineModel() には required や default を指定できる。
const model = defineModel<number>({ required: true })
const optionalModel = defineModel<number>({ default: 1 })ただし default を子側に持たせ、親が値を渡さない場合は、親は undefined、子は既定値という非同期状態が起きうる。そのため初期値は親が持つ方が安全である。
v-model Arguments
デフォルトの modelValue 以外の名前で双方向バインディングを作る場合は、引数付き v-model を使う。
親側:
<MyComponent v-model:title="bookTitle" />子側:
<script setup lang="ts">
const title = defineModel<string>('title', { required: true })
</script>この契約は title prop と update:title event に対応する。
Multiple v-model Bindings
1つのコンポーネントに複数の双方向バインディングを持たせることもできる。
<UserName
v-model:first-name="first"
v-model:last-name="last"
/><script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
</script>defineProps() のように1つのオブジェクトへまとめるのではなく、defineModel() は 1 呼び出しごとに 1 系統の双方向契約を表す。
Handling v-model Modifiers
modifier は「値が変わった後に監視する」のではなく、「親に返す直前の値を加工する」ために使う。
<script setup lang="ts">
const [model, modifiers] = defineModel<string>()
function onInput(value: string) {
if (modifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
model.value = value
}
</script>
<template>
<input
:value="model"
@input="onInput(($event.target as HTMLInputElement).value)"
/>
</template>この用途では watch() や watchEffect() は通常不要である。modifier は副作用ではなく、更新前の変換ルールに近いため、入力ハンドラや setter 側で処理する方が責務が明確になる。
Modifiers for v-model with Arguments
引数付き v-model にも modifier を付けられる。
<MyComponent v-model:title.capitalize="myText" /><script setup lang="ts">
const [title, titleModifiers] = defineModel<string>('title')
</script>複数 v-model がある場合も、それぞれに独立した modifier 群を持つ。
<UserName
v-model:first-name.capitalize="first"
v-model:last-name.uppercase="last"
/>Options API → Composition API 差分(補足)
| 項目 | Options API(旧) | Composition API(新) |
|---|---|---|
| 基本実装 | props: ['modelValue'], emits: ['update:modelValue'] を明示 | defineModel() で同等の契約を簡潔に宣言 |
| 値の扱い | modelValue をそのまま使う、または writable computed を定義 | ref と同じ感覚で model.value を扱う |
| modifier 対応 | modelModifiers などの prop を明示的に受ける | const [model, modifiers] = defineModel() で取得 |
| 複数 v-model | props と emits を個別に定義 | defineModel('name') を回線ごとに宣言 |
Key Concepts
| 用語 | 説明 |
|---|---|
defineModel() | コンポーネント v-model を ref として扱うためのコンパイラマクロ |
modelValue | 引数なし v-model のデフォルト prop 名 |
update:modelValue | 引数なし v-model の更新通知 event 名 |
v-model:title | title prop / update:title event を使う名前付き双方向バインディング |
| modifier | capitalize など、子が更新直前の値変換ルールとして扱うフラグ |
全体像
flowchart LR P["Parent state ref"] -->|prop| C["Child defineModel()"] C -->|update event| P C --> I["Native input"] I -->|input value| C
実務上の判断軸
- 初期値は原則として親が持つ。
v-modelは値同期の仕組みであり、副作用処理のためにwatch()を混ぜない。- 複数
v-modelは便利だが、回線が増えるほどコンポーネント責務も重くなるため、本当に 1 つの UI 部品として自然かを確認する。