【Vue.js】ref と reactive どっちを使う?
Vue.js の Composition API が登場してから 1 年少しが経過しており、すでに使いこなしている方もいらっしゃるのではないでしょう?
私自身お仕事で Composition API を使用しており、従来の Options API と比較して UI とロジックの分解がよりやりやすくなったように思えます。リアクティブなデータをコンポーネントの外で定義できるようになったことで、1 つのコンポーネントにまとめて書かざるをえなかった論理的な関心事に分けてそれぞれ別のファイルで定義できます。
書き心地としては React のカスタムフックに近い感じとなっていますね。
個人的には React がクラスコンポーネントから関数コンポーネント + Hook へ移行したように、Vue.js においても Composition API へ移行する流れが来るのではないかと思います。
さて、そんな Compositon API ですがリアクティブなデータを定義する際に reactive
と ref
の 2 つの方法が用意されています。 reactive
と ref
どちらを使用するのがよいのか公式からも推奨する方法がありませんので、どちらを使用するべきか迷ってしまうところです。
reactive
と ref
のそれぞれのメリット・デメリットを確認してみましょう。
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>
メリット
Data
の定義と似ている
Options API の 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
がアンラップされるという違いはありますが)
つまるところ、変数の型情報を見ただけではそれが通常のオブジェクトなのか、リアクティブな値なのか判別できません。
リアクティブなデータを 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>
分割代入を利用したい場合には 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
などの日付ライブラリは日付のフォーマットのような再利用可能な関数を提供しますが、これはステートレスなロジックです。対してステートフルなロジックとは時間の経過によって変化する状態の管理を含みます。
例として Composition 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
useFetch
は URL
を引数に受け取りレスポンスデータ、ローディング可否、エラー可否の 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:
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')
個人的な見解
さて、reactive
と ref
どちらを使用すればよいかの話に戻りましょう。私の個人的な意見としてはコンポーネント内では常に ref
を使用するのが良いと思います。
理由としてはやはり reactive
において分割代入をするとリアクティブ性が失われるという挙動によることが大きいです。結局 toRefs
で Ref
に変換する必要があるのならば元からリアクティブなデータは ref
で定義すると決めておいたほうがデータのアクセス方法も統一されるのでわかりやすいでしょう。(computed
も同様に .value
でデータにアクセスします)
ref
において常に .value
をつけなければいけないというわかりづらさはありますがこれは TypeScript
を使用していれば大きな問題にはならないと考えました。(また VSCode の拡張である Volar を使用すれば .value
を補完してくれます。
例外としては、Composables 関数内で状態を定義する際にはカプセル化されているため、必ず toRefs()
を使用して返すことを条件に reactive
を使用してもよいかもしれません。
Discussion