Vue 3: 親→子→孫での双方向データ共有(computed + defineModel)ガイド
このメモは、親 → 子 → 孫 の階層で値を双方向に扱うときの、最小で安全な設計パターンをまとめたものです。
要点は 「親が単一の真実源(SSOT)」、子・孫は defineModel でそのまま受け渡し です。
結論(認識の確認)
親
comptedでget set でプロパティ
↓
子
defineModal で双方向
↓
孫
defineModal で双方向
-
親:
computed({ get, set })で 状態(例:mealItems[...])に直結したプロパティを作り、v-model:xxx="そのcomputed"で子へ渡す。 -
子:
defineModel('xxx')で 親の v-model を双方向で受け取る。そのまま孫へも中継可能。 -
孫:
defineModel('xxx')で 入力 UI を v-model="xxx" に結び、編集が親まで一直線に伝播する。
キー名(例:
currentCustomNote、selectedOptionsなど)は 親・子・孫で一致 させてください。
極小サンプル
Parent.vue
<template>
<!-- 親の computed を v-model で子へ -->
<Child v-model:value="valueProxy" />
</template>
<script setup>
import { reactive, computed } from 'vue'
// 単一の真実源(SSOT)
const state = reactive({ value: 'A' })
// 子に渡す v-model ブリッジ(SSOTに直結)
const valueProxy = computed({
get: () => state.value,
set: (v) => { state.value = v }
})
</script>
Child.vue
<template>
<!-- 孫へ同じキー名で中継 -->
<Grand v-model:value="value" />
</template>
<script setup>
// 親の v-model:value をそのまま受ける(双方向)
const value = defineModel('value', { default: '' })
</script>
Grand.vue
<template>
<!-- 入力UIは普通に v-model -->
<input v-model="value" />
</template>
<script setup>
// 親まで届く双方向の値
const value = defineModel('value', { default: '' })
</script>
なぜこの形が良いのか
- 単一の真実源(SSOT):実データは親が持つ。追跡や保存ロジックが明快。
-
defineModel:親子間のprop+emit('update:...')を自動生成。ボイラープレート激減。 -
拡張しやすい:子で変換が必要なら、
computedラッパーで加工してから孫へ渡せる。
// 例:子で値を加工して孫へ
const raw = defineModel('value', { default: '' })
const bridged = computed({
get: () => normalize(raw.value),
set: (v) => { raw.value = denormalize(v) }
})
// <Grand v-model:value="bridged" />
実務メモ(今回の構成に寄せて)
-
currentCustomNote/selectedOptions/currentMainFood/currentSideFoodなど、
親は computed(get/set) で mealItems や snackItem に直結。
子・孫はdefineModel('同じキー名')で双方向。 -
KeypadModal を子内で完結させると親がスリムになる(
showKeypad/openKeypad等の状態を親から撤去)。 - チップ(選択肢)と自由入力の 結合は保存直前に親で行う と二重ロジックを避けられる。
// 例:保存前に結合
const composeNote = (selectedOpts = [], freeText = '') => {
const chips = (selectedOpts || []).map(o => o.label).join('、')
const free = (freeText || '').trim()
if (chips && free) return `${chips}、${free}`
if (chips) return chips
return free
}
ありがちなハマりどころ
-
キー名不一致:
v-model:xxxとdefineModel('xxx')の xxx が違う と双方向にならない。 -
Vue バージョン:
defineModelは Vue 3.3+ が必要(ビルド環境が3.3以上なら本番のサーバー側は関係なし)。 -
相対パス:
parts/からの相対 import を間違えやすい。例:../../KeypadModal.vue。
まとめ
- 親:computed(get/set) で SSOT に直結 → 子へ
v-modelで渡す。 - 子・孫:
defineModelで双方向を素直に受け、必要ならそのまま中継。 - 複雑な加工はどこか1か所(たとえば親の保存直前)に寄せて、二重ロジックを排除。
これで親→子→孫の v-model チェーンがシンプル&堅牢に動きます。
Vue 3(<script setup>)での defineProps / defineEmits / defineModel の役割と違い
まとめ:
defineProps… 親から 受け取る 値の宣言(下りの一方向 / 読み取り専用)defineEmits… 親へ 通知する イベントの宣言(上りの一方向)defineModel…v-model専用の糖衣構文。対象の prop と update イベント を まとめて 作り、読み書きできる Ref を返す
1. defineProps:親からもらう値(下り)
<script setup lang="ts">
// 親から来る props を宣言(読み取り専用)
const props = defineProps<{
options: string[]
disabled?: boolean
}>()
</script>
- props は 読み取り専用。
props.options.push(...)のように 直接変更しない。 - 変更したいときは 親にイベントで知らせる(=
defineEmits)か、v-modelを使う。
2. defineEmits:親へ知らせるイベント(上り)
<script setup lang="ts">
const emit = defineEmits<{
(e: 'select', value: string): void
(e: 'close'): void
}>()
const onClick = (v: string) => emit('select', v)
</script>
- 子→親に「何が起きたか」を伝える。
-
emit('イベント名', payload)の型/存在をここで宣言する。
3. defineModel(Vue 3.3+):v-model を一行で扱う
<script setup lang="ts">
// v-model:selected を受け取り・更新できる Ref を返す
const modelSelected = defineModel<string[]>('selected', { default: [] })
// デフォルトの v-model(= modelValue / update:modelValue)なら引数省略可
const modelValue = defineModel<string>({ default: '' })
</script>
-
内部で
selectedprop とupdate:selectedイベントを生成し、Refを返す。 - つまり次の 従来コードと同等:
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ selected: string[] }>()
const emit = defineEmits<{ (e:'update:selected', v:string[]):void }>()
const modelSelected = computed({
get: () => props.selected,
set: (v) => emit('update:selected', v),
})
</script>
注意:
defineModel('selected')を使う場合、同じキーselectedをdefinePropsで重複宣言しない。
その v-model 対象に関してはdefineEmitsでupdate:selectedを別途宣言する必要もない。
(他の通常 props や他のイベントは従来どおりdefineProps/defineEmitsを使う)
4. いつ何を使う?(早見表)
| 目的 | 使うもの | 備考 |
|---|---|---|
| 親から値をもらう(読み取りのみ) | defineProps |
props は直接変更しない |
| 親へイベント通知を送りたい | defineEmits |
emit('done', payload) など |
| 親と双方向に値を連携したい | defineModel |
v-model 対象に限る |
| v-model を手作りしたい(3.2以前等) |
defineProps + defineEmits + computed(get/set)
|
defineModel の等価実装 |
5. 親→子→孫の最小例(defineModel 版)
Parent.vue
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const selected = ref<string[]>([]) // 親が唯一の state
const options = ['A','B','C']
</script>
<template>
<Child v-model:selected="selected" :options="options" />
<pre>Parent: {{ selected }}</pre>
</template>
Child.vue
<script setup>
import Grandchild from './Grandchild.vue'
// v-model:selected を 1行で橋渡し
const modelSelected = defineModel<string[]>('selected', { default: [] })
const { options } = defineProps<{ options: string[] }>()
</script>
<template>
<Grandchild v-model:selected="modelSelected" :options="options" />
</template>
Grandchild.vue
<script setup>
const modelSelected = defineModel<string[]>('selected', { default: [] })
const { options } = defineProps<{ options: string[] }>()
const toggle = (o: string) => {
const next = [...modelSelected.value]
const i = next.indexOf(o)
i === -1 ? next.push(o) : next.splice(i, 1)
modelSelected.value = next // ← 親まで反映
}
</script>
<template>
<button v-for="o in options" :key="o" @click="toggle(o)">
{{ o }} {{ modelSelected.includes(o) ? '✅' : '' }}
</button>
</template>
6. よくある落とし穴
-
props を直接変更しない:
props.xxx.push(...)ではなく、必ずemitorv-modelの setter を使う -
defineModelと同名のdefinePropsを併用しない(重複宣言) -
複数の v-model:
defineModel('foo'),defineModel('bar')と複数呼び出し可能 -
デフォルト v-model:
defineModel()(引数なし)はmodelValue/update:modelValueに対応
結論
-
defineProps:下りの宣言(受け取り) -
defineEmits:上りの宣言(通知) -
defineModel:v-model に限り 上り下りを一本化して扱うための便利機能(Ref が返る)
実務では、通常の props / イベントはそのまま、フォーム値などは defineModel で簡潔に、がスッキリしておすすめです。
超シンプル版:Vue 3 の computed(get/set) で 親→子→孫 をつなぐ v-model
ゴール:親だけが state を持ち、子と孫は編集だけ通知する最小実装。
例:A/B/C の3つのタグを選択するシンプル UI。
ディレクトリ(例)
src/
├─ Parent.vue
├─ Child.vue
└─ Grandchild.vue
1) 親:Parent.vue
-
単一の真実源(state)は親だけが持つ:
selected(配列) - 子へは
v-model:selectedとして渡す - 親の
selectedが変わると UI(<pre>)も更新
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
// 親だけが持つ本体の state
const selected = ref([]) // 例: ['A', 'C'] のように入る
// 孫の表示用候補(例)
const options = ['A', 'B', 'C']
</script>
<template>
<div style="display:grid; gap:12px;">
<h2>Parent</h2>
<Child v-model:selected="selected" :options="options" />
<div>
<strong>親の state:</strong>
<pre>{{ selected }}</pre>
</div>
</div>
</template>
2) 子:Child.vue
- state は持たない
- 親からの
v-model:selectedをcomputed(get/set)で橋渡し - 孫へも
v-model:selectedでそのまま渡す
<script setup>
import { computed } from 'vue'
import Grandchild from './Grandchild.vue'
const props = defineProps({
selected: { type: Array, default: () => [] }, // 親から v-model で渡ってくる
options: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:selected'])
// v-model ブリッジ:子は編集だけ親へ通知
const modelSelected = computed({
get: () => props.selected ?? [],
set: (val) => emit('update:selected', val)
})
</script>
<template>
<div style="display:grid; gap:8px;">
<h3>Child</h3>
<Grandchild :options="options" v-model:selected="modelSelected" />
</div>
</template>
3) 孫:Grandchild.vue
- 実際の UI(トグル)を提供
-
配列の変更は
modelSelected.value = nextのみ(props を直接いじらない)
<script setup>
import { computed } from 'vue'
const props = defineProps({
selected: { type: Array, default: () => [] }, // 子から v-model
options: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:selected'])
// 孫でも v-model ブリッジ
const modelSelected = computed({
get: () => props.selected ?? [],
set: (val) => emit('update:selected', val)
})
// クリックで配列をトグル
const toggle = (opt) => {
const next = [...modelSelected.value]
const i = next.indexOf(opt)
if (i === -1) next.push(opt)
else next.splice(i, 1)
modelSelected.value = next // ← ここが唯一の更新ポイント(親まで伝播)
}
</script>
<template>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button
v-for="opt in options" :key="opt"
@click="toggle(opt)"
:style="{
padding:'6px 10px',
border:'1px solid #ccc',
borderRadius:'8px',
background: modelSelected.includes(opt) ? '#16a34a' : '#fff',
color: modelSelected.includes(opt) ? '#fff' : '#333',
cursor:'pointer'
}"
>
{{ opt }}
</button>
</div>
<div style="margin-top:8px;">
<small>孫が受けている値: {{ modelSelected }}</small>
</div>
</template>
動き(ほんとにシンプルに)
-
孫でボタンをクリック →
toggle()がmodelSelected.value = nextをセット - 孫の
modelSelected.set(next)が発火 →emit('update:selected', next) -
子の
modelSelected.set(next)が発火 →emit('update:selected', next) -
親の
v-model:selectedが受け取り、selected(親の state)が更新 - 親の state が下り直し、孫の表示が更新
どの層でも props を直接変更しない、更新は常に set → emit で上に伝える、が鉄則。
おまけ:Vue 3.3+ なら defineModel でさらに短く
子・孫で:
<script setup>
const modelSelected = defineModel('selected', { type: Array, default: () => [] })
</script>
これで computed(get/set) のブリッジを省略できます。振る舞いは同じです。
これで 最小の親→子→孫の v-model 伝播が理解できます。
実案件では、親の保存処理だけで文字列結合などのビジネスロジックを行い、子・孫は「編集だけ通知」に徹すると安定します。
Vue 3: computed の getter/setter を使った 親→子→孫 の v-model ブリッジ入門
目的:親だけが状態を持ち、子と孫は「編集だけ通知」するクリーンなデータフローを作る。
手段:各層でcomputed({ get, set })を使ってv-modelを受け取り/橋渡しする。
ディレクトリ構成(例)
src/
├─ Parent.vue # 親: 単一の真実源(状態の本体を保持)
├─ Child.vue # 子: 親⇄孫の橋渡し。state は持たない
└─ Grandchild.vue # 孫: UI。ユーザー操作→親へ伝播
完成イメージ(データの流れ)
(ユーザー操作) → [孫] modelSelectedOptions.set(next)
└─ emit('update:selectedOptions', next)
↓
[子] modelSelectedOptions.set(next)
└─ emit('update:selectedOptions', next)
↓
[親] activeSelectedOptions.set(next) ← 真の状態を更新
↑
(タブ切替・初期読込などで get が呼ばれ配列が下りてくる)
1) 親: Parent.vue
- 状態は 親だけが持つ(単一の真実源)。
- タブ(例:朝/昼/夕/おやつ)に応じて返す配列を切り替えるため、
activeSelectedOptionsをcomputed(get/set)で実装。 - 子へは
v-model:selectedOptionsとして渡す。
<script setup>
import { ref, computed } from 'vue'
import Child from './Child.vue'
// タブ
const activeMealTime = ref('朝')
// 真の状態(親だけが保持)
const selectedOptions = ref({
ActiveOption: [], // 朝/昼/夕 用の表示中配列
朝: [], 昼: [], 夕: []
})
const snackSelectedOptions = ref([]) // おやつ用
// 子へ渡す v-model のブリッジ(朝/昼/夕/おやつで配列を切り替え)
const activeSelectedOptions = computed({
get: () => activeMealTime.value === 'おやつ'
? snackSelectedOptions.value
: selectedOptions.value.ActiveOption,
set: (val) => {
if (activeMealTime.value === 'おやつ') {
snackSelectedOptions.value = val
} else {
selectedOptions.value.ActiveOption = val
}
}
})
// UI 用(例)
const templateOptions = [
{ id: 1, label: '特変なし' },
{ id: 2, label: '完食' },
{ id: 3, label: 'むせあり' },
]
</script>
<template>
<div>
<div>
<button @click="activeMealTime = '朝'">朝</button>
<button @click="activeMealTime = '昼'">昼</button>
<button @click="activeMealTime = '夕'">夕</button>
<button @click="activeMealTime = 'おやつ'">おやつ</button>
</div>
<!-- 子へは配列そのものを v-model で渡す -->
<Child
:templateOptions="templateOptions"
v-model:selectedOptions="activeSelectedOptions"
/>
<pre>親の状態: {{ activeSelectedOptions }}</pre>
</div>
</template>
2) 子: Child.vue
-
状態は持たない。
computed(get/set)で「孫との橋渡し」をするだけ。 - 孫へも
v-model:selectedOptionsで渡す。
<script setup>
import { computed } from 'vue'
import Grandchild from './Grandchild.vue'
const props = defineProps({
// 親から v-model:selectedOptions で渡ってくる
selectedOptions: { type: Array, default: () => [] },
templateOptions: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:selectedOptions'])
// 親→子の v-model を孫へ橋渡し(子は state を持たない)
const modelSelectedOptions = computed({
get: () => props.selectedOptions ?? [],
set: (val) => emit('update:selectedOptions', val)
})
</script>
<template>
<Grandchild
:templateOptions="templateOptions"
v-model:selectedOptions="modelSelectedOptions"
/>
</template>
3) 孫: Grandchild.vue
- 実際の UI(チップのトグルなど)を提供。
-
配列の編集は
modelSelectedOptions.value = nextとし、setが発火して親へ伝播。
<script setup>
import { computed } from 'vue'
const props = defineProps({
selectedOptions: { type: Array, default: () => [] },
templateOptions: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:selectedOptions'])
// v-model ブリッジ(孫でも同じパターン)
const modelSelectedOptions = computed({
get: () => props.selectedOptions ?? [],
set: (val) => emit('update:selectedOptions', val)
})
// チップのトグル(例)
const toggle = (opt) => {
const next = [...modelSelectedOptions.value]
const i = next.findIndex(o => o.id === opt.id)
if (i === -1) next.push(opt)
else next.splice(i, 1)
modelSelectedOptions.value = next // ← ここが唯一の更新ポイント
}
</script>
<template>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button
v-for="opt in templateOptions" :key="opt.id"
@click="toggle(opt)"
:style="{padding:'6px 10px', border:'1px solid #ccc', borderRadius:'8px',
background: modelSelectedOptions.some(o => o.id===opt.id) ? '#16a34a' : '#fff',
color: modelSelectedOptions.some(o => o.id===opt.id) ? '#fff' : '#333'}"
>
{{ opt.label }}
</button>
</div>
<pre>孫の受取値: {{ modelSelectedOptions }}</pre>
</template>
ポイントまとめ
-
単一の真実源:状態は親だけが持つ。子/孫は
computed({get,set})でブリッジするだけ。 -
props の直接変更禁止:
props.xxx.push(...)のように書かない。常に setter を通す。 -
v-model の規約:
v-model:fooはfooprop を受け取り、update:fooを emit する。 -
双方向の役割分担:
-
下り(親→孫):
getが呼ばれて props として届く → UI 更新 -
上り(孫→親):ユーザー操作で
setを呼ぶ → emit で伝播 → 親状態が更新
-
下り(親→孫):
付録:Vue 3.3+ の defineModel でさらに簡潔に
- 子や孫では次のように書けます(内部で
computedを生成してくれるシュガーシンタックス)。
<script setup>
// 子/孫いずれでも
const modelSelectedOptions = defineModel('selectedOptions', { type: Array, default: () => [] })
</script>
<template>
<!-- 以降は同じ -->
</template>
ただし「親でタブに応じて違う配列を返す/保存する」といったビジネスロジックは親の computed(get/set) が最適です。
以上。これを雛形にすれば、親のみ状態保持・子孫は編集通知だけという安全な設計をシンプルに実現できます。