[Vue.js]子で変更した内容を親に反映させたい

IT

子コンポーネントでオブジェクトのリストを追加・削除して、その内容を親に反映させる方法について調査実装したため、その方法をまとめます。その際に、シンプルな文字列や数値だけの変数の反映方法についても確認できたため、ついでにまとめます。

画面イメージ

まずは、実際の画面のイメージです。①がシンプルな値の反映方法、②・③がオブジェクトのリストの反映方法となっています。

前提条件

nodev18.17.0
npm9.8.1
Vue3.4.5

VueはComposition API単一ファイルコンポーネントで記載しています。

テスト用データの元ネタはポケモン(ポケモンマスターズEX)です。レイアウトについては本質ではありませんのでご容赦ください。

解説

コードについては説明に必要な最小限の個所のみ抜粋しています。全体のコードは「最後に」記載しています。

① v-modelを使用した双方向バインディングの場合

v-modelを使用することで、親子の間にも双方向バインディングを実現できます。もう一度該当箇所を見てみましょう。

//------------------------子-------------------------
<script  setup lang="ts">
const childMsg = defineModel()
</script>

<template>
    <input id="child" v-model="childMsg" />
</template>

//------------------------親-------------------------
<script  setup lang="ts">
const parentMsg = ref('バディーズ')
</script>

<template>
    <p>{{ parentMsg }}</p>
    <ChildComp v-model="parentMsg"/>
</template>

Vue3.4以降ではdefineModel()マクロを使うことが推奨されています。子の2行目でこのマクロを使い、親からv-model経由で使用できる双方向バインディングのpropsを宣言します。また、7行目で親から渡された初期値を表示します。そしてテキストボックスの内容を更新するとその変更が親に反映されます。

親では、12行目で変数に初期値を設定します。そしてその変数を子を呼び出すときにv-modelで渡します。(17行目)

親子でv-modelに渡している変数名が異なっています。defineModel()の裏側ではpropsやemitが定義されており、変数名が異なっていても問題ありません。詳細は以下の「内部の仕組み」をご確認ください。

② 子コンポーネントのイベントを使う場合

ここからはオブジェクトのリストを親子でやり取りする例で考えていきます。子ではリストへの値の追加・削除を行い、その変更内容を親に反映させます。これを実現するためにはイベントを使用します。まずは子を詳しく見ていきます。

<script  setup lang="ts">
defineEmits<{
    (e: 'text-change', buddies: Buddy[]): void
}>()

type Buddy = { id: number, trainer: string, pokemon: string }
const childBuddies = ref<Buddy[]>([])
const add = () => {(略)}
const del = () => {(略)}
</script>

<template>
    <div>
        <button @click="add">+</button>
        <button @click="del">-</button>
    </div>
    <template v-for="buddy in childBuddies" :key="buddy.id">
        <p>トレーナー</p>
        <input :id="`trainer-${buddy.id.toString()}`" v-model="buddy.trainer" @change="$emit('text-change', childBuddies)" />
        <p>ポケモン</p>
        <input :id="`pokemon-${buddy.id.toString()}`" v-model="buddy.pokemon" @change="$emit('text-change', childBuddies)" />
    </template>
</template>

2行目でdefineEmits()マクロを使いイベントを宣言しています。イベント名「text-change」でBuddy[]型の値を受け取るイベントとして宣言しています。そして、23、25行目でイベントを発行しています。その際に$emit()を使い、引数に宣言したイベント名とBuddy[]型の変数を渡しています。イベント発火は@changeにしていますが、ここは好みに合わせて他の物でもいいと思います。

ちなみに上記では省略していますが、+(-)ボタンを押すことでリストの要素を追加・削除しています。そしてv-forでリスト分ループしてテキストボックスを表示させています。またv-modelでオブジェクトのプロパティとテキストボックスに入力された値をバインドさせています。

続いて、子のイベントを受け取る親のコードを見ていきます。

<script  setup lang="ts">
type Buddy = { id: number, trainer: string, pokemon: string }
const buddies = ref<Buddy[]>([])
const updateBuddies = (childBuddies: Buddy[]) => {
    buddies.value = childBuddies
}
</script>

<template>
    <div class="main">
        <div class="parent">
            (略)
        </div>
        <div class="child">
            <h1>子</h1>
            <ChildComp @text-change="updateBuddies" />
        </div>
    </div>
</template>

4行目で子でのイベント発火時に実行する関数を定義します。引数には子で更新したオブジェクトのリストが渡されるので、親で定義しているリストに代入して更新します。

この関数が呼び出されるために、16行目で子コンポーネントを呼ぶ際に「@イベント名=”関数名”」を指定します。

③ propsを使う場合(非推奨)

最後に、非推奨ではありますがpropsを使った方法を紹介します。

<script  setup lang="ts">
import { ref } from 'vue';
// propsとして親からオブジェクトのリストをもらう
const props = defineProps<{
    buddies: Buddy[]
}>()

const childMsg = defineModel()

type Buddy = { id: number, trainer: string, pokemon: string }
const buddyCount = ref(1)
const add = () => {(略)}
const del = () => {(略)}
</script>

<template>
    <div>
        <button @click="add">+</button>
        <button @click="del">-</button>
    </div>
    <template v-for="buddy in buddies" :key="buddy.id">
        <p>トレーナー</p>
        // 親からもらったオブジェクトのリストにv-modelでバインドする
        <input :id="`trainer-${buddy.id.toString()}`" v-model="buddy.trainer" />
        <p>ポケモン</p>
        <input :id="`pokemon-${buddy.id.toString()}`" v-model="buddy.pokemon" />
    </template>
</template>

まずはpropsとして親で作成したBuddy[]型の変数を受け取ります。4行目でそのための宣言を行います。描画する際は、親から受け取ったリストに対して直接、追加・削除やv-modelによる値のバインドを行います。

続いて親です。親はBuddy[]型の変数を子を呼び出す際に渡すだけです。そのほかは②と同じです。

<script  setup lang="ts">
type Buddy = { id: number, trainer: string, pokemon: string }
const buddies = ref<Buddy[]>([])
</script>

<template>
      (略)
        <div class="child">
            <h1>子</h1>
            // 親で作成したオブジェクトのリストを引数として渡す
            <ChildComp :buddies="buddies" />
        </div>
    </div>
</template>

非推奨の理由

propsは一方方向のデータフローになります。親の変更は子に伝わりますが、逆はありません。実際にpropsで受けとった文字列を直接変更しようとするとエラーになります。

しかし、オブジェクトやリストは参照渡しであるため変更可能です。それでも、この方法ですと親と子が密結合になります。Vueのベストプラクティスとして避けるべきで、代わりにイベントを使うことが記載されています。詳細は以下の記事をご確認ください。

最後に

子で変更したデータを親に反映する方法を非推奨も含めて3つ紹介しました。初めは、③の方法でリストを更新できて喜んでいました。しかし、公式ドキュメントを確認していたところ非推奨であることが分かりました。②に比べると実装もシンプルなのですが、ベストプラクティスに沿った形が一番ですね。

最後に①と②を合わせた親と子の全容を載せておきます。

子コンポーネント

<script  setup lang="ts">
import { ref } from 'vue';

const childMsg = defineModel()
defineEmits<{
    (e: 'text-change', buddies: Buddy[]): void
}>()

type Buddy = { id: number, trainer: string, pokemon: string }

const childBuddies = ref<Buddy[]>([])
const buddyCount = ref(1)

const add = () => {
    childBuddies.value.push({
        id: buddyCount.value,
        trainer: '',
        pokemon: ''
    })
    buddyCount.value++
}

const del = () => {
    childBuddies.value.pop()
    if (buddyCount.value > 1) {
        buddyCount.value--
    }
}

</script>

<template>
    <div>
        <span>タイトル : </span>
        <input id="child" v-model="childMsg" />
        <button @click="add">+</button>
        <button @click="del">-</button>
    </div>
    <template v-for="buddy in childBuddies" :key="buddy.id">
        <div class="content">
            <p>トレーナー</p>
            <input :id="`trainer-${buddy.id.toString()}`" v-model="buddy.trainer"
                @change="$emit('text-change', childBuddies)" />
            <p>ポケモン</p>
            <input :id="`pokemon-${buddy.id.toString()}`" v-model="buddy.pokemon"
                @change="$emit('text-change', childBuddies)" />
        </div>
    </template>
</template>

<style>
.content {
    display: grid;
    grid-template-columns: 1fr 1fr 1fr 1fr;
    gap: 3px;
    margin-top: 5px;
    margin-bottom: 5px;
}
</style>

親コンポーネント

<script  setup lang="ts">
import { ref } from 'vue';
import ChildComp from './test/ChildComp.vue';

type Buddy = { id: number, trainer: string, pokemon: string }

const buddies = ref<Buddy[]>([])
const parentMsg = ref('バディーズ')

const updateBuddies = (childBuddies: Buddy[]) => {
    buddies.value = childBuddies
}
</script>

<template>
    <div>
        <div class="main">
            <div class="parent">
                <h1>親</h1>
                <p>{{ parentMsg }}</p>
                <template v-for="buddy in buddies" :key="buddy.id">
                    <div class="sub">
                        <p>{{ buddy.id }}人目</p>
                        <div>{{ buddy.trainer }} & {{ buddy.pokemon }}</div>
                    </div>

                </template>
            </div>
            <div class="child">
                <h1>子</h1>
                <ChildComp :title="title" v-model="parentMsg" @text-change="updateBuddies" />
            </div>
            <div></div>
        </div>
    </div>
</template>

<style>
.main {
    display: grid;
    grid-template-columns: 1fr 2fr 1fr;
    gap: 5px;
}

.sub {
    display: grid;
    grid-template-columns: repeat(2, auto);
    gap: 3px;
    margin-top: 5px;
    margin-bottom: 5px;
}

.parent {
    border: 1px solid black;
}

.child {
    border: 1px solid black;
}
</style>

コメント