📝

vue composition apiにおけるpropsのリアクティブ性について知っておくべきこと

2022/03/27に公開

この記事で伝えたいこと

  • vue composition apiにおけるコンポーネントのsetupで受け取れるpropsreactiveで定義した変数のようなリアクティブ性をもちます
  • propsのプロパティにリアクティブ性を求めるパターンと求めないパターンごとの使い分け

リアクティブをもたらすAPI ref, reactive, computed

composition apiではリアクティブをもたらすAPIとしてref, reactive, computedなどがあり、特にrefreactiveに関してはどちらをつかうべきかという記事が多い印象をもちます。
それぞれの比較としては他開発者さまの記事ですがこちらが大変参考になると思います。
https://zenn.dev/azukiazusa/articles/ref-vs-article

私自身振り返って考えてみるとrefcomputedを多用しています。
refはTypeScriptを使っていればリアクティブな変数かどうかわかりやすいし、
computedは他のリアクティブな変数に依存するリアクティブな変数を簡潔に表現でき、かつrefのように別のロジックから変更される恐れがないので使いやすいです。

const isOpen = ref(true);
const message = computed(() => isOpen.value ? '開いてるよ!' : '閉まっているよ!');
// messageに入る値は上記の2パターンのみであり更新はされない。下記を実行すると `Write operation failed: computed value is readonly` というワーニングが表示される
message.value = '全然関係ないメッセージ';

UIロジックなどを表現する際にはrefcomputedでほぼ十分でありますが、storeを利用する場合などではreactiveを用います。
しかしreactiveにはリアクティブの消失という注意ポイントがあります。

reactiveのリアクティブの消失について簡単に言うと

リアクティブの消失についてはリアクティブな状態の分割代入にある通りで分割代入によってリアクティブを消失します。
分割代入の例としては下記のようなパターンもあります。

const state = reactive({ name: 'karino' });

// 1. リンクにある通りの分割代入を行うパターン
const { name } = state;

// 2. プロパティを直接参照して代入するパターン
const name = state.name;

// 3. プロパティを直接参照して他の処理に渡すパターン
const { length } = useLength(state.name);
// useLengthの戻り値のlengthはリアクティブな変数のはずですが、state.nameが更新されてもその値が変わることはないです

// 一種のcomposable
const useLength = (name: string) => {
  const length = computed(() => name.length);
  return { length };
};

これらは分割代入によってリアクティブが消失しているといえますが、別の言い方をすると reactiveで定義された変数自体はリアクティブだが、そのプロパティはリアクティブではない」 という表現もできるかなと思います。

実験1: どんなタイプのプロパティでもリアクティブは消失する
https://stackblitz.com/edit/vue-eysy8a?embed=1&file=src/App.vue&hideDevTools=1&hideExplorer=1&hideNavigation=1

実験2: プロパティがrefによるものでも分割代入でリアクティブは消失する
https://stackblitz.com/edit/vue-kjypvk?embed=1&file=src/App.vue&hideDevTools=1&hideExplorer=1&hideNavigation=1

本題1: propsは何にあたるのか

コンポーネントのsetupの第一引数に渡されるprops変数もリアクティブな変数です。
そしてreactiveで作られた変数のプロパティがリアクティブを消失するパターンを見ましたが、
同様にpropsも同じ操作でそのプロパティのリアクティブを消失します。

例: propsは分割代入でリアクティブを失う
https://stackblitz.com/edit/vue-cnb8my?embed=1&file=src/App.vue&hideDevTools=1&hideExplorer=1&hideNavigation=1

上記実験1でのリンク先ではreactiveで作成した変数の分割代入された変数がリアクティブを失っているのと同じように、propsのプロパティも分割代入によってリアクティブを失っています。
このことからpropsreactiveで定義された変数と同じような リアクティブ性を持つと考えられます。

本題2: propsのプロパティにリアクティブを求めるパターンと求めないパターンの使い分け

propsのプロパティがリアクティブを失うパターンについて見ました。
ではpropsのリアクティブを生かした状態でロジックを書きたい時と、そうでない時ではそれぞれどのように使い分ける必要があるのかを書き方ごとにまとめてみます。

propsのプロパティにリアクティブを求めるパターン

propsをそのまま使える場合はリアクティブのまま

上記まででpropsのプロパティがリアクティブを失うパターンを見ましたが、いずれも分割代入の実行タイミングがsetupの直下でした。
分割代入の実行タイミングが変われば、その時のプロパティの値が取得できます。

下記例ではボタンを押すとその時のidの値を用いてsomeAPIを呼びます。

<template>
  <button @click="callAPI">call api</button>
</template>

<script>
export default {
  props: { id: { type: Number } },
  setup(props) {
    const callAPI = () => {
      someAPI(props.id);
    };

    return { callAPI };
  },
};
</script>

props + toRefs, toRef

https://v3.ja.vuejs.org/api/refs-api.html#toref

toRefs, toRefはいずれもreactiveな変数に使えますしpropsにも使えます。

例: propstoRefを使い、親・子からそれぞれ更新してみる実験
https://stackblitz.com/edit/vue-rcqnny?embed=1&file=src/App.vue&hideNavigation=1

親から子の変更は伝わりますが、子で取得したリアクティブな変数を更新しようとするとSet operation on key "〇〇" failed: target is readonly.というワーニングが出て更新されません。
これはpropsの子側からの変更を許さないVueの方針によるものです。

この方法のメリットはpropsのプロパティを独立したリアクティブな変数として宣言できるところにあります。
これを行うことでプロパティのみをcomposableなメソッドに与えロジックの分割ができるようになります。

しかしTypeScriptを用いている場合、toRefs, toRefによって取得した変数はRef型になり、〇〇.value = △△型エラーになりません
もしそのようなコードがある場合、実行時不具合になりエラーログではなくアラートログが出るだけなので、発見や修正が遅れるかも…しれません。ちょっと怖いですね。
今後のVue自体のアップデートで対応されたり、もしかしたらLintの設定などで対処できるのか分かりませんが、一先ず注意ポイントですね。

props + computed

computedを使うことでもpropsのプロパティをリアクティブな変数として独立させることができます。
個人的にはtoRef, toRefsを使うよりもいいのではないかと思っています。

export default {
  props: { name: { type: String } },
  setup(props) {
    // プロパティに連動した別のリアクティブな変数を作るのに便利
    const nameLength = computed(() => props.name.length);
    // プロパティとまったく同じ意味合いのリアクティブな変数を作ることも可能
    const name = computed(() => props.name);
  },
};

computedをおすすめする理由は2つあります。
1つ目はTypeScript上ではComputedRef型になる点です。
ComputedRef型はReadOnlyな型なので〇〇.value = △△型エラーになります。実行前に不備を気づけるようになります。

2つ目は覚えるAPIの数を減らせるという点です。
computedは便利な特性をもつので積極的に使い覚えたいAPIですが、toRef,toRefsは比較的使用頻度は高くないです。
toRefsは一気に複数のプロパティを取り出せる使い方もできますが、少なくともtoRefに限って言えばcomputedでいいのではないでしょうか?

propsのプロパティにリアクティブを求めないパターン

const 〇〇 = props.〇〇

setupの直下でこの書き方をするということは、コンポーネントがマウントされて以降更新されない値だということになります。

props + ref

const input = ref(props.initialValue);
const onUpdate = (inputValue: string) => input.value = inputValue;

上記例のようにrefの中でpropsのプロパティを使う場合は、そのrefの変数の初期値としてpropsのプロパティを使うという意味になります。
propsのプロパティと連動してrefの変数が変わることはありません。
propsのプロパティに対応する変数が親のコンポーネント側で更新されても、子のコンポーネントのrefは更新されませんし、逆に子のrefが更新されても親の値は変わりません。

https://stackblitz.com/edit/vue-qyqwmo?embed=1&file=src/Child.vue&hideDevTools=1&hideExplorer=1&hideNavigation=1

最後に

なぜこのような当たり前のような記事を今更書いたかというと、実は私はpropsがリアクティブじゃなくなる条件をよく分からないまま使っていたからです。
しかし考えてみればreactiveと同じような動きをすることに気づき、自分の鈍感さを痛感しました。
あまり良くないロジックを量産していたような気がします。恥ずかしい〜

以上

Discussion