【Vue.js】ストアとProp Drillingはどっち良い?

GMOメイクショップ コアグループ エンジニアの森です。
21年4月に新卒で入社しおよそ3年になります。
2年ほど前から、現在のチームでフロントエンドをメインに開発してきました。

今回はVue.jsにおいて便利だけど少し危険なストア、そして面倒だけど比較的安全なProp Drillingについて、
開発での失敗談と共に書いていきます。

ストアについて

ストア(store)とはVue.jsにおける状態管理を行うコンテナです。 基本的にVuex、Piniaといった状態管理ライブラリで扱われます。(我々のチームでは開発初期はVuexを、現在はPiniaに移行して使用しています。)

ストアを使うと状態(state)を一元管理できるため、どのコンポーネントでもstateを共有して簡単に扱うことができます。

https://ja.vuejs.org/guide/scaling-up/state-management

Prop Drillingについて

Prop/Emitを使いを上位コンポーネントから下位コンポーネントへバケツリレーでのデータの受け渡し
データの流れが直感的に分かりやすい一方で、コンポーネント内で全く使用しないデータを下位コンポーネントへ中継するために受け渡す必要が出る等、コードが冗長で複雑になってしまうことがある。

https://ja.vuejs.org/guide/components/provide-inject#prop-drilling

ストアを使いすぎた結果

システム構築初期、ストアを使えばどのコンポーネントからもデータ更新できるし、簡潔なコードがかけメンテナンス性も高い?じゃあ全部ストアでいいじゃん、とストアを使いまくっていました。

そんな中チームに新メンバー加わりコードを読んでみると、処理の流れが追えず時間が膨大に取られるという自体が発生しました。
どのコンポーネントからでも直接データ更新しているので、この更新はどこの処理が動いたのか?親コンポーネントを見れば良いのか、子コンポーネントを見れば良いのか?確認するため全てのコンポーネントを見なければならないという状態になりと処理を追うことが非常に困難になっていました。

ストアをProp/Emitに置き換える

ストアに依存した結果、処理が追えないコードを量産してしまいました…
そこでストアの必要性がない部分をProp/Emitに置き換えていく方向へ、加えてコンポーザブルを導入しました。

Vue アプリケーションの文脈で「コンポーザブル(composable)」とは、Vue の Composition API を活用して状態を持つロジックをカプセル化して再利用するための関数です。 https://ja.vuejs.org/guide/reusability/composables

Prop Drillingは階層が深くなっていくと、バケツリレーが長くなりどんどん冗長になっていきますが、上位から辿れば、どのコンポーネントに処理が書かれているか必ず見つけられるので結果的に可読性は良くなります。

ストアとProp Drillingの使い分け

ストアの使いすぎで少し痛い目を見ましたが、ストア自体は使い方を間違えなければ非常に便利で強力です。

ストアは特にサイト全体に関わるようなデータの保持する場合に力を発揮します。一度保持したデータを、複数のページで共有できるため、ページ遷移の度にDBと通信してデータ取得するようなことが不要になります。
例えば我々の開発しているmakeshopでは、ショップの契約情報などのデータは 各ページで参照するため、ログイン時に1度だけDBから取得し、その後はどのページでもストアで保持したstateを参照するようにしています。

逆にページ単位で完結しているデータをあまり多くのコンポーネントから触られたくないため、Prop Drillingが適していそうです。
makeshopの場合、各販売商品のデータは商品設定ページでのみ参照できれば良いためProp/Emitでデータの受け渡しをしています。

実装例

ストアを使用した場合

mainModule.ts

import { defineStore } from 'pinia'
import { reactive } from 'vue'

export const useMainStore = defineStore('mainStore', () => {
  const state = reactive({
    name: '',
  })

  const setName = (v: string) => {
    state.name = v
  }

  return {
    state,
    setName,
  }
})

Main.vue

<script setup lang="ts">
import Child from './components/Child.vue'
import { useMainStore } from './stores/mainModule'

const store = useMainStore()
</script>

<template>
  <span v-text="store.state.name" />
  <Child :name="store.state.name" @update:name="store.setName" />
</template>

Child.vue

<script setup lang="ts">
import { useMainStore } from './stores/mainModule'

const store = useMainStore()
</script>

<template>
  <v-text-field :model-value="store.state.name" @change="(v) => store.setName(v)" />
</template>

Prop Drillingを使用した場合

useMain.ts

import { ref } from 'vue'

export const useMain = () => {
 const name = ref<string>('TEST')


 const setName = (v: string) => {
    name.value = v
 }

 return { name, setName }
}

Main.vue

<script setup lang="ts">
import Child from './components/Child.vue'
import { useMain } from './composable/useMain'

const { name, setName } = useMain()
</script>

<template>
  <span v-text="name" />
  <Child :name="name" @update:name="setName" />
</template>

Child.vue

<script setup lang="ts">
interface Props {
 name: string
}
const props = defineProps<Props>()


interface Emits {
 (event: 'update:name', value: string): void
}
const emits = defineEmits<Emits>()
</script>


<template>
 <v-text-field :model-value="props.name" @change="(v) => emits('update:name', v)" />
</template>

まとめ

ストアによる状態管理は非常に便利で簡潔コードを書きやすいですが、何も考えずに使ってしまうと逆に冗長で複雑なコードに転じてしまいます。もちろん逆にProp/Emitだけ使っていても複雑化してしまします。

ストアを使うべきなのか、Prop Drillingでバケツリレーをした方が良いのか、結局はケースバイケースですが、 重要なのはチーム内のでルール作り。

「サイト全体で使うものはストア、ページ単位でしか使わないものはProp/Emit使う」
「フロントエンドから更新しないものだけストアを使う」

など、それぞれ開発のスタイルやシステムの要件などによって、チーム内でどのように使い分けるのかをしっかりルール化し認識を揃えていく必要があります。