🆚

【Vue.js】ref と reactive どっちを使う?

2022/01/07に公開約8,400字

Vue.js の Composition API が登場してから 1 年少しが経過しており、すでに使いこなしている方もいらっしゃるのではないでしょう?

私自身お仕事で Composition API を使用しており、従来の Options API と比較して UI とロジックの分解がよりやりやすくなったように思えます。リアクティブなデータをコンポーネントの外で定義できるようになったことで、1 つのコンポーネントにまとめて書かざるをえなかった論理的な関心事に分けてそれぞれ別のファイルで定義できます。

書き心地としては React のカスタムフックに近い感じとなっていますね。

個人的には React がクラスコンポーネントから関数コンポーネント + Hook へ移行したように、Vue.js においても Composition API へ移行する流れが来るのではないかと思います。

さて、そんな Compositon API ですがリアクティブなデータを定義する際に reactiveref の 2 つの方法が用意されています。 reactiveref どちらを使用するのがよいのか公式からも推奨する方法がありませんので、どちらを使用するべきか迷ってしまうところです。

reactiveref のそれぞれのメリット・デメリットを確認してみましょう。

reactive

reactive メソッドはオブジェクトを引数に受け取り、リアクティブにしたコピーを返します。

<script setup lang="ts">
import { reactive, computed } from 'vue'

const state = reactive({
  count: 0
})

const increment = () => {
  state.count++
}
</script>

<template>
  <p>{{ state.count }}</p>
  <button @click="increment">+</button>
</template>

メリット

Options API の Data の定義と似ている

reactive はオブジェクトによって定義しますが、この方法は Options API における Data の定義の仕方とよく似ているので従来の方法に慣れている人にとっては ref と比べてとっつきにくいかと思われます。

<script>
export default defineComponent({
  data() {
    return {
      count: 0
    }    
  },
  methods: {
    increment() {
      this.count++
    }
  }
})
</script>

まとまったデータを定義するのに向いている

例えば、ユーザーのデータにおける firstName,lastName のように関連性のあるデータ群を定義する際には ref を使って個別に定義するよりも個別の関連性がわかりやすくなります。

<script setup lang="ts">
import { reactive, ref } from 'vue';

const user = reactive({
  firstName: 'Jhon',
  lastName: 'Smith',
  age: 21,
});

const fullName = computed(() => `${user.firstName} ${user.lastName}`);

const firstName = ref('Jhon');
const lastName = ref('Smith');
const age = ref(21);

const fullName = computed(() => `${firstName.value} ${lastName.value}`);
</script>

デメリット

通常のオブジェクトと区別がつきづらい

reactive メソッドの返り値の型は元のオブジェクトのままです。(ref がアンラップされるという違いはありますが)

つまるところ、変数の型情報を見ただけではそれが通常のオブジェクトなのか、リアクティブな値なのか判別できません。

スクリーンショット 2021-12-30 19.38.33

リアクティブなデータを ref で定義した場合になら Ref<number> のように推論されるので、型情報を見るだけでリアクティブなデータだと認識できます。

const count = ref(0) // Ref<number>

分割代入

reactive の大きなデメリットの 1 つとして分割代入するとリアクティブ性が失われる点が挙げられます。例として、以下のように分割代入して count を表示しようとすると予想に反して描画は変更されません。

<script setup lang="ts">
import { reactive } from "vue";

const state = reactive({
  count: 0,
});

let { count } = state;

const increment = () => {
  count++;
};
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+</button>
</template>

count

分割代入を利用したい場合には toRefs を使って ref に変換した値として使う必要があります。

<script setup lang="ts">
import { reactive, toRefs } from "vue";

const state = reactive({
  count: 0,
});

let { count } = toRefs(state);

const increment = () => {
  count.value++;
};
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+</button>
</template>

個人的には、この分割代入できないという仕様は結構痛手のように思えます。前述のとおりに reactive で宣言したデータは通常のオブジェクトと区別がつかないので分割代入をしてよいのかどうかを宣言元まで見にいかなくてはいけないのは手間がかかります。コード中で reactive を使っている場合には、通常のオブジェクトを使用する際にも注意を払わなくてはいけません。

また誤って reactive で宣言したデータを分割代入してしまった場合においても特に警告などが表示されるわけでもないので値がリアクティブとならない不具合の原因を探る際に少し不都合なように思えます。

toRefs を使う方法も toRefs 自体が主要な API ではないのと、結局 ref に変換されるのであれば元から ref で定義しておけばよいのでは?という気持ちもあります。

Ref

ref はプリミティブな値(string,number など)を引数にとりリアクティブなデータを定義します。ref メソッドの返り値の型は Ref<T>(T は引数に渡した値の型)というオブジェクトになります。

ref で定義した値にアクセスするためには value というプロパティにアクセスする必要があるという特徴があります。<template> 内で使うときには .value を省略できます。

これは Vue.js においてリアクティブ性は Proxy オブジェクトにより実現されているのでプリミティブなままリアクティブにできないという事情があります。(Vue 2 においては Object.defineProperty

<script setup lang="ts">
import { ref } from "vue";

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+</button>
</template>

メリット

リアクティブなデータか判別しやすい

reactive のデメリットにおいて説明したとおり ref() の返り値は Ref<T> なのでただのプリミティブなデータではなくプリミティブなデータであることが判別しやすいです。

const count = ref(0) // Ref<number>

デメリット

常に .value でアクセスする必要がある

ref で定義した値は常に .value でアクセスする必要があるという一風変わった方法が必要です。(<template> 内部では .value を省略できるという点も混乱を招く要因の 1 つになりそうです)

これは特に TypeScript ではなく JavaScript を使用している場合に顕著になります。

例えば boolean の値を扱っているときにうっかり .value をつけ忘れてしまった場合に少し困ったことになりうるでしょうか。

import { ref } from "vue";

const isShow = ref(false);

if (isShow) {
  // 常に実行されてしまう!!
}

Composables 関数として公開するときは常に Ref 型とする

閑話休題、ここまではコンポーネント内での使用について考えてきましたが一旦 Composables 関数について見ていきましょう。

Composables とは Composition API を利用してステートフルなロジックを再利用可能な関数として定義するものです。例えば dayjs などの日付ライブラリは日付のフォーマットのような再利用可能な関数を提供しますが、これはステートレスなロジックです。対してステートフルなロジックとは時間の経過によって変化する状態の管理を含みます。

例として Compositiomn API を利用して API コールを再利用可能にした useFetch 関数を見てみましょう。

import { Ref, ref, unref, watchEffect } from 'vue'

const useFetch = <T>(url: string | Ref<string>) => {
  const unrefUrl = unref(url)
  const data = ref<T | null>(null)
  const loading = ref(true)
  const error = ref<unknown | null>(null)

  const fetchData = async () => {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(unrefUrl)
      data.value = await res.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  watchEffect(() => {
    fetchData()
  })

  return {
    data,
    loading
    error,
  }
}

export default useFetch

useFetchURL を引数に受け取りレスポンスデータ、ローディング可否、エラー可否の 3 つのデータを返します。返されるデータはそれぞれの ref で定義されているためリアクティブとなっており、時間が経過して API コールが完了するにつれて返却されるデータの状態は変化します。

また、引数の URL をリアクティブなデータとして渡した場合、引数の URL が変更されるたびに(例えば、クエリパラメータが変更されるたびに watchEffect によって API が再コールされるようになります。

またComposables 関数は composables ディレクトリに配置して use + キャメルケースの関数名とすることが慣例です。

これは、コンポーネント内では次のように使用します。

<script setup lang="ts">
import useFetch from "./useFetch";

interface User {
  firstName: string;
  lastName: string;
  age: number;
}
const { data, loading, error } = useFetch<User>("/api/users");
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error!</div>
  <div v-else>
    <p>{{ data?.firstName }} {{ data?.lastName }}</p>
  </div>
</template>

さて、そんな Composables 関数ですが公式の見解として関数内部の状態保持として reactive,ref どちらを使用したとしても、返り値は必ず reactive ではなく Ref 型で返すことが推奨されています。

Returning a reactive object from a composable will cause such destructures to lose the reactivity connection to the state inside the composable.

If you prefer to use returned state from composables as object properties, you can wrap the returned object with reactive() so that the refs are unwrapped. For example:

https://staging.vuejs.org/guide/reusability/composables.html#return-values
const useFetch = <T>(url: string | Ref<string>) => {

  const state = reactive({
    data: null,
    loading: true,
    error: null,
  })

  // bad practice...
  return state
  // better
  return toRefs(state)

  // or...
  const data = ref<T | null>(null)
  const loading = ref(true)
  const error = ref<unknown | null>(null)

  return {
    data,
    loading,
    error,
  }
}

これは、コンポーネント内で Composables 関数を利用するときにリアクティブ性を失わないようにするためです。前述のとおり reactive は分割代入をするとリアクティブ性が失われてしまいます。

const { data, loading, error } = useFetch('/api/users')

個人的にな見解

さて、reactiveref どちらを使用すればよいかの話に戻りましょう。私の個人的な意見としてはコンポーネント内では常に ref を使するのが良いと思います。

理由としてはやはり reactive において分割代入をするとリアクティブ性が失われるという挙動によるとことが大きいです。結局 toRefsRef に変換する必要があるのならば元からリアクティブなデータは ref で定義すると決めておいたほうがデータのアクセス方法も統一されるのでわかりやすいでしょう。(computed も同様に .value でデータにアクセスします)

ref において常に .value をつけなければいけないというわかりづらさはありますがこれは TypeScript を使用していれば大きな問題にはならないと考えました。(また VSCode の拡張である Volar を使用すれば .value を補完してくれます。

ref-value1

例外としては、Composables 関数内で状態を定義する際にはカプセル化されているため、必ず toRefs() を使用して返すことを条件に reactive を使用してもよいかもしれません。

GitHubで編集を提案

Discussion

ログインするとコメントできます