現在業務でVue3/Nuxt3を使用していて、実装に勤しんでいる。
Vueの3.4から使用できるようになったdefineModelを使ってみて、便利だと思う反面、使い時に迷ったこともあるのでこんな場合は使うのを避けるというのを書いてみる。
defineModelを簡単にまとめると
propsとemitをまとめてバインディングできる機能。
defineModelが実装されるまでのバインディング
defineModelが実装されるまでは、親コンポーネントから子コンポーネントに値を渡してその値を制御するには、propsでその値を渡し、emitでその値を変更する処理を定義するのが主流だった。
// 親
<MyInput v-model="text" />
// 子
<script setup>
const props = defineProps({
modelValue: String
})
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
defineModelの実装後
// 親
<script setup>
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
あっさりと値の受け渡しと制御可能なコードができあがる。
defineModelを使わない方がいい場合
実際に使ってみて、こんな場合は使わないほうが良さそうだなというのをまとめてみた。
双方向バインディングが不要なコンポーネント
データフローが一方向で済むなら、defineProps() + defineEmits() の方が明示的である。
特に状態の責任が親にある場合、子で勝手に更新できると混乱を招くことがある。
状態の責務が親にある場合は、defineModelではなく、propsとemitでやったほうがいい。
例えば、
export const useHoge = () => {
const name = useState<string>("");
return {
name
};
}
<script setup>
const { name } = useHoge();
</script>
<template>
<Child v-model="name" />
</template>
<script setup>
const { name } = useHoge();
</script>
<template>
<div>
<input id="input-name" v-model="name" />
<label for="input-name">名前</label>
</div>
</template>
このように、親コンポーネントで受け取ったComposables関数を子コンポーネントに渡している場合。
こんな時は、defineModelを使うのを控えたほうが良さそう。
状態管理が複雑になるケース
大規模フォームやビジネスロジックを持つUIでは、「双方向バインディング」がトラブルの元になることも。
その場合は emit('change', value) のように一方向に設計した方がベター。
例
type FormItems = {
name: {
first: string;
last: string;
}
address: string;
age: number;
sex?: string;
tel: number;
email: string;
}
export const useHoge = () => {
const form = useState<FormItems | undefined>(undefined);
return {
name
};
}
<script setup>
const { form } = useHoge();
</script>
<template>
<Child v-model="form" />
</template>
<script setup>
const { form } = useHoge();
</script>
<template>
<div>
.....
</div>
</template>
理由
1.責務の一貫性
状態の責務はどちらかというと、親にある。
親コンポーネントで受け取ったcomposables関数の値を渡しているわけで、この関数内で値の変更をしないとそのComposables関数の値はどこで変えるのか?と混乱を招いてしまう可能性がある。
親がcomposable関数からuseState()の値を取得している時点で、その値のライフサイクル管理は親(または composable)が担当している。
つまり、
・値をいつ初期化するか
・どこで保存するか
・どこで永続化するか
・どこで副作用を走らせるか
これらを決める権限は親側にある。
子が defineModel() で直接その値をいじると、「親のstateを暗黙的に書き換える構造」になってしまう。
composable の役割は、「状態とロジックの共通化」。
そこに「子コンポーネントが勝手に値を変える」経路ができると、ロジックが外から壊されるリスクが出る。
特に useState() は Nuxt でグローバルに共有されるので、別の子コンポーネントが書き換えると全体が反応してしまう。
結果的に、defineModelで制御するのが適切ではない値をdefineModelで制御したとしたら、
「あれ、どこでこのstateは変わったんだ?」
「なんでcomposableの値が書き変わってるの?」
というバグも起こりやすくなる。
2.データの変更の基本は親で行う
Vue/Nuxt は基本的に「上→下の一方向データフロー」を推奨している。
(Reactも同じだが。)
↑のページにも、
All props form a one-way-down binding between the child property and the parent one: when the parent property updates, it will flow down to the child, but not the other way around. This prevents child components from accidentally mutating the parent’s state, which can make your app’s data flow harder to understand. (すべてのプロパティは、子プロパティと親プロパティの間で一方向バインディングを形成します。つまり、親プロパティが更新されると子プロパティにも反映されますが、その逆は起こりません。これにより、子コンポーネントが誤って親プロパティの状態を変更してしまうことを防ぎ、アプリのデータフローが分かりにくくなるのを防ぎます。)
との記載がある。
defineModelは便利だけど、「双方向に流れる」という特性を導入する以上、データの流れが読みにくくなるデメリットも有している。
共通コンポーネント
この場合も、defineModelでの実装は控えたいと考えている。
理由は、変更したい値をv-modelでバインディングしないといけない制約が発生するからである。
useHoge.tsを例に説明すると、Child.vueでは、defineModelでnameをバインディングしているが、defineModelで実装すると、親コンポーネントで呼び出すときに、変更したい値をv-modelでしか渡せなくなる。
<script setup>
type Props = {
name: string;
}
defineProps<Props>()
</script>
<template>
<Child v-model="name" />
</template>
これだと、エラーが出てしまう。
propsの値をv-modelでバインディングはできないためである。
こうなると、使える部分が制限されてしまうので、共通コンポーネントの意味がなくなってしまう。
共通コンポーネントは「状態を持たない」のが原則であり、「見た目とイベントだけを提供する」もの。
そのため、状態の責務を内部に持たせると再利用性が落ちてしまう。
defineModelは内部的に「親と状態を共有して書き換え可能」にするので、そのコンポーネントが状態の一部を管理しているように見えてしまう。
使用するコンポーネントと共通コンポーネントの責務の境界が曖昧になってしまう。
共通コンポーネントは、「どこで呼び出しても使える」が前提として存在しているべきで、Atomic Designで捉えると、少なくとも、molecules、atomsに該当する共通コンポーネントではdefineModelを使うのは控えたほうがいいだろう。
まとめ
defineModelはあくまでpropsとemitを省略している、いわゆるシンタックスシュガーであることは肝に銘じておきたい。
使わない理由を書いてみたが、書き終えてみて、あまり恩恵を得られていない気がしなくもない。
新しい技術ができたからといって、なんでもかんでもその技術に頼ればいいということではないことを改めて痛感した。
技術は使い時を極めて選定できるようにしていきたい。