1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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-caseis-admin)、スクリプト側は camelCaseisAdmin)。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 経験があれば emitonXxx コールバックだと思えばほぼ正しく理解できます。


よくある落とし穴

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)

まとめ

  1. props は読み取り専用。加工は computed、初期値としてなら ref にコピー
  2. 親の値を変えたいときは emit で依頼する
  3. 入力系コンポーネントは defineModel() で書くのが最短
  4. props/emit のバケツリレーが深くなったら Pinia や provide/inject を検討

「データは下へ、イベントは上へ」を守るだけで、状態の変更経路が追跡可能になり、デバッグ時間が大幅に減ります。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?