Vue.js の props と emit を正しく使う
結論
- props = 親から子へのデータ(読み取り専用)
- emit = 子から親へのイベント通知
- この2つで「データは下に、イベントは上に」という一方向データフローを作る
- Vue 3.4+ なら双方向っぽい書き方は
defineModel()一発で済む
以下、<script setup> + TypeScript を前提に解説します。
props: 親から子へ
基本形
<!-- 親: ParentView.vue -->
<template>
<UserCard :name="user.name" :age="user.age" :is-admin="true" />
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import UserCard from './UserCard.vue'
const user = reactive({ name: '田中太郎', age: 32 })
</script>
<!-- 子: UserCard.vue -->
<template>
<div class="card">
<h2>{{ name }}</h2>
<p>{{ age }}歳</p>
<span v-if="isAdmin">管理者</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
name: string
age: number
isAdmin?: boolean
}>()
</script>
ポイント:
- テンプレート側は kebab-case(
is-admin)、スクリプト側は camelCase(isAdmin)。Vue が自動変換してくれる -
definePropsはコンパイラマクロなので import 不要 -
<template>内では戻り値を受け取らなくてもそのまま使える
デフォルト値
Vue 3.5 以降は分割代入がそのまま使えます(リアクティビティを保ったまま)。
// Vue 3.5+ 推奨
const { name, age = 0, isAdmin = false } = defineProps<{
name: string
age?: number
isAdmin?: boolean
}>()
3.4 以前は withDefaults を使います。
const props = withDefaults(defineProps<{
name: string
age?: number
tags?: string[]
}>(), {
age: 0,
tags: () => [], // 配列・オブジェクトは必ず関数で返す
})
注意: 配列やオブジェクトのデフォルト値を
tags: []と直接書くと、全インスタンスで同じ参照を共有してしまいます。必ずファクトリ関数で。
ランタイムバリデーション(TS を使わない場合)
defineProps({
status: {
type: String,
required: true,
validator: (value) => ['draft', 'published', 'archived'].includes(value),
},
count: {
type: Number,
default: 0,
},
})
TypeScript の型はコンパイル時のみの保証です。API レスポンスなど外部由来の値を受ける可能性があるなら、validator を併用する価値があります。
props は書き換えられない
これが一番ハマるところ。
// NG: 開発時に警告が出る
const props = defineProps<{ count: number }>()
props.count++ // Set operation on key "count" failed: target is readonly.
親が再レンダリングすると子の変更は上書きされるため、Vue はこれを禁止しています。用途別に対処します。
(1) props を初期値として使いたい
const props = defineProps<{ initialCount: number }>()
const count = ref(props.initialCount) // コピーしてローカル state に
(2) props を加工して表示したい
const props = defineProps<{ price: number }>()
const formattedPrice = computed(() => `¥${props.price.toLocaleString()}`)
(3) 親の値そのものを変えたい → emit の出番
オブジェクトの中身は変更「できてしまう」
readonly は浅い(shallow)ため、これは警告なしで通ります。
const props = defineProps<{ user: { name: string } }>()
props.user.name = '変更' // 警告は出ないが、やってはいけない
親の state を子がこっそり書き換えることになり、追跡不能なバグの温床です。変更したいなら emit で親に依頼します。
emit: 子から親へ
基本形
<!-- 子: CounterButton.vue -->
<template>
<button @click="handleClick">+1</button>
</template>
<script setup lang="ts">
const props = defineProps<{ count: number }>()
const emit = defineEmits<{
increment: [value: number]
}>()
function handleClick() {
emit('increment', props.count + 1)
}
</script>
<!-- 親 -->
<template>
<CounterButton :count="count" @increment="count = $event" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
型定義の書き方
Vue 3.3 以降はタプル構文が簡潔でおすすめです。
// 推奨(3.3+)
const emit = defineEmits<{
submit: [payload: { title: string; body: string }]
cancel: []
change: [id: number, value: string]
}>()
// 旧: 呼び出しシグネチャ形式
const emit = defineEmits<{
(e: 'submit', payload: { title: string }): void
(e: 'cancel'): void
}>()
イベント名の命名
<!-- 子: camelCase で emit -->
emit('updateStatus', 'published')
<!-- 親: kebab-case で受ける -->
<StatusForm @update-status="onUpdateStatus" />
DOM の in-template 使用では大文字小文字が区別されないため、テンプレート側は kebab-case が安全です。
バリデーション付き
const emit = defineEmits({
submit: (payload) => {
if (!payload.title) {
console.warn('title は必須です')
return false
}
return true
},
})
v-model: props + emit の糖衣構文
v-model は「modelValue という props を渡し、update:modelValue を emit する」だけの仕組みです。
展開するとこうなる
<!-- これは -->
<SearchInput v-model="keyword" />
<!-- これと同じ -->
<SearchInput
:model-value="keyword"
@update:model-value="keyword = $event"
/>
defineModel(Vue 3.4+)
子側は 1 行で書けます。
<!-- 子: SearchInput.vue -->
<template>
<input v-model="model" type="search" />
</template>
<script setup lang="ts">
const model = defineModel<string>({ default: '' })
</script>
model は普通の ref のように読み書きでき、書き込むと自動で親に emit されます。
複数の v-model
<!-- 親 -->
<DateRangePicker v-model:start="from" v-model:end="to" />
<!-- 子 -->
<script setup lang="ts">
const start = defineModel<string>('start', { required: true })
const end = defineModel<string>('end', { required: true })
</script>
3.4 未満の場合
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const model = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
実践例: 検索フォームコンポーネント
親が state を持ち、子は表示と通知に徹する構成です。
<!-- SearchForm.vue -->
<template>
<form @submit.prevent="handleSubmit">
<input v-model="keyword" type="search" placeholder="キーワード" />
<select v-model="area">
<option value="">エリアを選択</option>
<option v-for="a in areas" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
<button type="submit" :disabled="isLoading">
{{ isLoading ? '検索中...' : '検索' }}
</button>
<button type="button" @click="handleReset">クリア</button>
</form>
</template>
<script setup lang="ts">
type Area = { id: number; name: string }
const { areas, isLoading = false } = defineProps<{
areas: Area[]
isLoading?: boolean
}>()
const emit = defineEmits<{
search: [params: { keyword: string; area: number | '' }]
reset: []
}>()
const keyword = defineModel<string>('keyword', { default: '' })
const area = defineModel<number | ''>('area', { default: '' })
function handleSubmit() {
emit('search', { keyword: keyword.value, area: area.value })
}
function handleReset() {
keyword.value = ''
area.value = ''
emit('reset')
}
</script>
<!-- 親 -->
<template>
<SearchForm
v-model:keyword="keyword"
v-model:area="area"
:areas="areas"
:is-loading="isLoading"
@search="fetchJobs"
@reset="results = []"
/>
<JobList :jobs="results" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
const keyword = ref('')
const area = ref<number | ''>('')
const areas = ref([{ id: 1, name: '東京都' }, { id: 2, name: '石川県' }])
const results = ref([])
const isLoading = ref(false)
async function fetchJobs(params: { keyword: string; area: number | '' }) {
isLoading.value = true
try {
const res = await fetch(`/api/jobs?${new URLSearchParams(params as any)}`)
results.value = await res.json()
} finally {
isLoading.value = false
}
}
</script>
責務の分離:
- 子は「入力を受け取り、検索したいと伝える」だけ。API のことは知らない
- 親が state と副作用を持つ
- 結果として子は Storybook やテストで単体検証しやすくなる
React との対応表
| Vue | React |
|---|---|
defineProps<{...}>() |
function Comp({ ... }: Props) |
| props は readonly | props は immutable(同じ思想) |
emit('submit', payload) |
props.onSubmit(payload) |
defineEmits の型定義 |
props の関数型 onSubmit: (p: P) => void
|
v-model |
value + onChange の組を自分で書く |
defineModel() |
カスタムフックで自作 |
Vue は「イベント」という専用チャンネルを持つのに対し、React はコールバックを props に混ぜる、という違いです。思想は同じ一方向データフローなので、React 経験があれば emit は onXxx コールバックだと思えばほぼ正しく理解できます。
よくある落とし穴
1. props を直接 mutate する
// NG
props.items.push(newItem)
// OK: 親に依頼
emit('add-item', newItem)
2. props の分割代入でリアクティビティを失う(3.5 未満)
// Vue 3.4 以前では NG(値が更新されない)
const { count } = defineProps<{ count: number }>()
// 3.4 以前は props 経由でアクセスする
const props = defineProps<{ count: number }>()
console.log(props.count)
Vue 3.5 以降は分割代入がコンパイラによって props.count に変換されるため問題ありません。使っている Vue のバージョンを確認してください。
3. バケツリレーが 3 階層を超える
props/emit を延々と中継し始めたら設計の見直しサインです。
-
provide/inject(限定的な範囲の共有) - Pinia(アプリ全体の state)
- スロットで構造を親に寄せる
4. emit の後で親の state 更新を前提にする
// NG: emit は同期だが、親の更新は次の tick
emit('increment')
console.log(props.count) // まだ古い値
// OK
await nextTick()
console.log(props.count)
まとめ
- props は読み取り専用。加工は
computed、初期値としてならrefにコピー - 親の値を変えたいときは emit で依頼する
- 入力系コンポーネントは
defineModel()で書くのが最短 - props/emit のバケツリレーが深くなったら Pinia や provide/inject を検討
「データは下へ、イベントは上へ」を守るだけで、状態の変更経路が追跡可能になり、デバッグ時間が大幅に減ります。