【Vue.js】コンポーネント間でデータをやりとりする方法|props・emit・storeの使い分け

【Vue.js】コンポーネント間でデータをやりとりする方法|props・emit・storeの使い分け Vue.js

Vueのアプリは、多くのコンポーネントに分けて作ります。分けたあとに必ず必要になるのが、コンポーネント間のデータのやり取りです。手段はいくつもありますが、「どれを使うか」で、コードの見通しが大きく変わります。この記事では、Vue 3(<script setup>)を前提に、各手段の基本と、使い分けの基準を整理します。

スポンサーリンク

どれを使うか:判断の流れ

  • 親から子へ値を渡す:props
  • 子から親へ、出来事を知らせる:emit(イベント)
  • 親子で、値を双方向にやり取りする:v-model(defineModel)。詳しくはv-modelの仕組みを完全理解
  • 親が子の中身を差し込む:スロット
  • 兄弟どうしで共有する:まずは、共通の親に状態を引き上げる
  • 特定のツリーの、遠い子孫へ渡す:provide / inject。詳しくはprovide/injectの仕組みと応用例
  • アプリ全体で共有する状態:ストア(Pinia)。詳しくはPiniaを使った状態管理の始め方

基本は、データの流れが一番分かりやすい、props と emit から考えることです。それで不便になったときに、ほかの手段を検討します。

propsで親から子へ値を渡す

子コンポーネントは、definePropsで受け取る値を宣言します。型、必須かどうか、既定値を指定すると、渡し間違いを開発中に警告で気づけます。配列やオブジェクトの既定値は、関数で返す必要があります。

Vue:子で props を宣言する
<!-- ChildComponent.vue -->
<script setup>
const props = defineProps({
  message: { type: String, required: true },              // 必須
  size: { type: String, default: 'medium' },              // 既定値
  tags: { type: Array, default: () => [] }                // 配列・オブジェクトの既定値は、関数で返す
})
</script>

<template>
  <p :class="size">{{ message }}</p>
</template>
Vue:親から値を渡す
<!-- 親コンポーネント -->
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const parentMessage = ref('こんにちは')
</script>

<template>
  <ChildComponent :message="parentMessage" size="large" />
</template>

propsは書き換えない

props は、親から子へ流れる一方向のバインディングです。子で直接書き換えようとすると、Vueが警告を出します。なお、オブジェクトや配列の props は、中のプロパティなら書き換えられてしまいます。ただし、親子が密接に結びついている場合を除き、避けるべきとされています。多くの場合は、子が emit で親に変更を依頼し、親が書き換えます。

初期値として使うだけなら、コピーして自分の状態にします。加工して使うなら、computedを使います。

Vue:propsを書き換えず、コピーかcomputedを使う
<script setup>
import { ref, computed } from 'vue'

const props = defineProps(['initialCount', 'size'])

// NG:props を直接書き換えると、警告が出る
// props.initialCount++

// OK:初期値として使うだけなら、コピーして自分の状態にする
const count = ref(props.initialCount)

// OK:加工して使うなら、computed にする
const normalizedSize = computed(() => props.size.trim().toLowerCase())
</script>

emitで子から親へ知らせる

子が親に出来事を知らせるには、emitでイベントを発行します。発行するイベントはdefineEmitsで宣言します。宣言は任意ですが、推奨されています。宣言しておくと、Vueが既知のリスナーをネイティブのイベントと取り違えず、予期しない動作を避けられるためです。

Vue:子でイベントを発行する
<!-- ChildComponent.vue -->
<script setup>
// 発行するイベントを宣言する(camelCase)
const emit = defineEmits(['itemSelected'])

function select(id) {
  emit('itemSelected', id)       // 追加の引数は、親のリスナーへ渡される
}
</script>

<template>
  <button type="button" @click="select(1)">選ぶ</button>
</template>
Vue:親でイベントを受け取る
<!-- 親コンポーネント -->
<script setup>
function onSelected(id) {
  console.log('選ばれた id:', id)
}
</script>

<template>
  <!-- テンプレートでは、kebab-case で受け取る -->
  <ChildComponent @item-selected="onSelected" />
</template>
  • イベント名は、emitでは camelCase、テンプレートのリスナーでは kebab-case にするのが慣例です。
  • 追加の引数は、親のリスナーへ渡されます。

コンポーネントのイベントは、バブリングしない

DOMのイベントと違い、コンポーネントのイベントは、親へバブリングしません。受け取れるのは、直接の親だけです。孫から祖父へ直接伝えることはできません。階層をまたぐ場合は、間のコンポーネントで中継する、provide / injectやストアなどの共有の仕組みを使う、といった方法をとります。

兄弟どうしは、まず状態を親に引き上げる

兄弟のコンポーネントの間で、データを共有したいとき、すぐにストアを使う必要はありません。共通の親が状態を持ち、一方からのemitで更新し、もう一方へpropsで渡せば足ります。これを「状態の引き上げ」と呼びます。データの流れが、コードを読むだけで追えます。

Vue:共通の親に状態を引き上げる
<!-- 親:共有する状態を持つ -->
<script setup>
import { ref } from 'vue'
import ItemList from './ItemList.vue'
import ItemDetail from './ItemDetail.vue'

const selectedId = ref(null)
</script>

<template>
  <!-- 兄弟の ItemList が選んだ id を、親が受け取り、兄弟の ItemDetail へ渡す -->
  <ItemList :selected-id="selectedId" @select="selectedId = $event" />
  <ItemDetail :item-id="selectedId" />
</template>

ストアで、アプリ全体の状態を共有する

離れたコンポーネントや、ページをまたいで共有する状態は、ストアに持たせます。Vue 3では、公式のストアはPiniaです。関数の形で書く「Setup Store」なら、refとcomputedをそのまま使えます。

JavaScript:stores/cart.js
// stores/cart.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])
  const count = computed(() => items.value.length)

  function add(item) {
    items.value.push(item)
  }

  return { items, count, add }
})
Vue:どのコンポーネントからでも使う
<script setup>
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()
</script>

<template>
  <!-- どのコンポーネントからでも、同じストアを使える -->
  <button type="button" @click="cart.add({ id: 1, name: '商品A' })">追加</button>
  <p>カート:{{ cart.count }}点</p>
</template>

ストアが向く場面、向かない場面

  • 向く:ログイン情報、カート、通知など、アプリ全体で使う状態。複数のページや、離れたコンポーネントから触る状態。
  • 向かない:1つのコンポーネントの中だけで使う状態や、親子の間で完結する値。何でもストアに入れると、どこで値が変わったのか追いにくくなります。

ロジックの共有が目的なら、状態を持つ関数としてのComposable関数もあります。モジュールのスコープに状態を置いて共有する簡易な方法(トースト通知を作る方法で使っています)は、小さな範囲では手軽ですが、公式も、どのコンポーネントからでも自由に書き換えられる状態は、長期的には保守しにくいと注意しています。サーバーサイドレンダリングでは、リクエストをまたいで共有される単一のオブジェクトになる問題もあります。規模が大きくなるなら、Piniaを使いましょう。

よくある質問(FAQ)

Q子コンポーネントで、propsを書き換えてもよいですか?
A直接の書き換えはしないでください。Vueが警告を出します。初期値として使うならコピー、加工して使うならcomputed、親の値を変えたいなら、emitで親に依頼します。オブジェクトや配列の中身は、書き換えられてしまいますが、避けるのが原則です。
Q孫から祖父に、直接イベントを伝えられますか?
A伝えられません。コンポーネントのイベントはバブリングせず、受け取れるのは直接の親だけです。間のコンポーネントで中継するか、provide / injectやストアを使ってください。
Q兄弟のコンポーネントで値を共有するには?
Aまずは、共通の親に状態を引き上げます。一方がemitで親に伝え、親がもう一方へpropsで渡します。それでも不便なほど、離れている場合に、ストアを検討してください。
QVuexとPiniaは、どちらを使うべきですか?
A新しいアプリには、Vueのコアチームが保守しているPiniaが推奨されています。Vuexは保守モードで、動作はしますが、新しい機能は追加されません。Piniaの使い方はPiniaを使った状態管理の始め方で解説しています。

まとめ

コンポーネント間のやり取りは、propsとemitを基本にして、双方向ならv-model、中身の差し込みならスロット、兄弟なら状態の引き上げ、離れた子孫ならprovide / inject、アプリ全体ならストア、と使い分けます。propsは書き換えず、イベントはバブリングしない、という2つの原則を押さえておくと、データの流れを追いやすく保てます。