【Vue.js】リアクティブなオブジェクトを分割代入で取り出すのはNG

Piniaで定義したリアクティブなオブジェクトをVue.jsのコンポーネントで取り出そうとしたのですが、なぜかリアクティブにならずハマったため、備忘録としてまとめます。

バージョン
  • vue 3.3.11
  • pinia 2.1.7
記事の信頼性
  • ぼくは独学で未経験から従業員300名以上の自社開発企業へ転職しました。
  • 実務ではVue.jsとRailsを毎日書いています。
  • 初心者や駆け出しエンジニアがつまづくポイントも身をもってよく理解しています。
目次

問題

Vue.jsとFirebaseを使った個人開発でクイズアプリを作っており、以下のようにクイズカテゴリの取得を試みていました。(console.logはデバッグ用)

import { defineStore } from "pinia"
import { collection, onSnapshot, query, orderBy } from "firebase/firestore"
import { db } from "@/firebase"
import { ref } from 'vue'
import type { CategoryType } from '@/types'

const categoriesCollectionRef = collection(db, "categories")

export const useQuizStore = defineStore('quiz', () => {
  const quizCategories = ref<CategoryType[]>([])
  const isLoaded = ref(false);

  const getQuizCategories = () => {
    // Firestore からカテゴリデータを 'display_order' フィールドの昇順で取得する
    const q = query(categoriesCollectionRef, orderBy("display_order", "asc"));
    onSnapshot(q, (snapshot) => {
      quizCategories.value = snapshot.docs.map((doc) => {
        return {
          id: doc.id,
          title: doc.data().title,
          description: doc.data().description,
          displayOrder: doc.data().display_order
        }
      })
      console.log('quizCategories in quizStore', quizCategories)
      isLoaded.value = true;
    })
  }

  return {
    quizCategories,
    getQuizCategories,
    isLoaded
  }
})
<script setup lang="ts">
import { onMounted } from 'vue';
import HomePresentation from './HomePresentation.vue';
import { useQuizStore } from '@/stores/quizStore';

const { quizCategories, getQuizCategories, isLoaded } = useQuizStore();

onMounted(() => {
  getQuizCategories();
  console.log('quizCategories in HomeContainer', quizCategories)
});
</script>

<template>
  <v-container>
    <HomePresentation v-if="isLoaded" :quizCategories="quizCategories" />
  </v-container>
</template>

しかし画面はいつまで経っても真っ白のままです。

2つのファイルにそれぞれ仕込んだconsole.logの結果を見てみると、quizStore.tsquizCategoriesにはカテゴリの配列が格納されているのに、HomeContainer.vuequizCategoriesにはなぜか値が格納されていません、、。

次のようにHomeContainer.vuewatchを仕込んでみたところ、そもそも'quizCategories updated' 自体がコンソールに出力されなかったことから、「HomeContainer.vuequizCategoriesがリアクティブになっていない」ということまでは突き止めました。

<script setup lang="ts">
import { onMounted } from 'vue';
import HomePresentation from './HomePresentation.vue';
import { useQuizStore } from '@/stores/quizStore';
import { watch } from 'vue';

const { quizCategories, getQuizCategories, isLoaded } = useQuizStore();

onMounted(() => {
  getQuizCategories();
  console.log('quizCategories in HomeContainer', quizCategories)
});

// quizCategories を監視し、データ取得が反映されているか確認
watch(quizCategories, (newValue, oldValue) => {
  // 'quizCategories updated'が出力されないため、quizCategories がリアクティブになっていない
  console.log('quizCategories updated', newValue);
}, { deep: true });
</script>

<template>
  <v-container>
    <div v-if="isLoaded">
      <HomePresentation :quizCategories="quizCategories" />
    </div>
  </v-container>
</template>

しかし、なぜquizCategoriesがリアクティブになっていないのかがわからず、ハマりました、、。

解決策

結論として、以下のように書くことでquizCategoriesのリアクティビティが復活しました。

<script setup lang="ts">
import { onMounted } from 'vue';
import HomePresentation from './HomePresentation.vue';
import { useQuizStore } from '@/stores/quizStore';

// 分割代入で取り出すとリアクティビティが失われてしまう
// const { quizCategories, getQuizCategories, isLoaded } = useQuizStore();

// 以下のように取り出す
const quizStore = useQuizStore();

onMounted(() => {
  quizStore.getQuizCategories();
});
</script>

<template>
  <v-container>
    <HomePresentation v-if="quizStore.isLoaded" :quizCategories="quizStore.quizCategories" />
  </v-container>
</template>

リアクティブなオブジェクトを、コンポーネント側で分割代入で取り出してしまうと、リアクティビティが失われてしまいます。

そのため、分割代入をつかわず、↑のように取り出す必要があります。

なおこの取り出し方だと、すべてのオブジェクト・メソッドの先頭にquizStore.をつけなくてはいけないため、冗長に感じる人も多いはずです。

そういう場合は次のようにstoreToRefsを使うことで、分割代入を実現しつつquizStore.を付けなくて済むので検討してみてください。

<script setup lang="ts">
import { onMounted } from 'vue';
import HomePresentation from './HomePresentation.vue';
import { useQuizStore } from '@/stores/quizStore';
import { storeToRefs } from 'pinia';

const quizStore = useQuizStore();
// storeToRefs で各プロパティを ref に変換して分割代入を実現する
const { quizCategories, isLoaded } = storeToRefs(quizStore);

onMounted(() => {
  quizStore.getQuizCategories();
});
</script>

<template>
  <v-container>
    <HomePresentation v-if="isLoaded" :quizCategories="quizCategories" />
  </v-container>
</template>

ぼくの場合は、取り出すオブジェクトの数が少ないのでstoreToRefsの採用は見送りました。

補足:Vue.jsの公式ドキュメント

今回の問題は、Piniaにかぎった話ではなくVue.js全体に当てはまる内容です。

Vue.jsの公式ドキュメントには以下の記載がありました。

リアクティブオブジェクトのプロパティをローカル変数に割り当てたり分割代入した場合、その変数へのアクセスや代入は、ソースオブジェクトの get / set プロキシートラップをトリガーしなくなるため、非リアクティブになります。

Vue におけるリアクティビティーの仕組み

分割代入できない: また、リアクティブなオブジェクトのプリミティブ型のプロパティをローカル変数に分割代入したり、そのプロパティを関数に渡したりすると、下記に示すようにリアクティブなつながりが失われることとなります:

reactive() の制限

おわりに

Vue.jsの根本原理への深い理解が自分には不足しているように思いました。

このあたりの学習はどう進めていけばいいか、迷い中です。

参考文献
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

未経験でSESから従業員300名以上の自社開発企業に転職しました。業務や個人開発で直面した問題や、転職・学習の経験を発信していきます。

コメント

コメントする

目次