React経験者のためのVue 3入門
3行でいうと
- Vueのリアクティビティは「値の変化を追跡する」方式。Reactの「再レンダーで関数ごと再実行する」方式とは根本的に違う。
- この違いが、
useCallback/useMemo/ 依存配列 / stale closure といったReact特有の悩みをまるごと消す。 - 代わりに
.valueを書き忘れる、reactiveの分割代入でリアクティビティが切れる、という別種の落とし穴が生まれる。
慣れの問題ではなく設計思想の違いなので、「Reactのあれは何に対応するのか」を1対1で覚えるより、なぜ対応物が存在しないのかを理解したほうが早い。
1. リアクティビティ ― ここが一番の違い
React のコンポーネントは、state が変わるたびに関数全体が再実行される。
function Counter() {
const [count, setCount] = useState(0);
console.log('re-render'); // 更新のたびに毎回出る
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Vue の setup は最初に1回しか実行されない。変わったのは count だけだと分かっているので、その値を参照しているDOMノードだけを書き換える。
<script setup>
import { ref } from 'vue'
const count = ref(0)
console.log('setup') // 一度しか出ない
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
setup が1回しか走らないということは、その中で定義した関数やオブジェクトも1回しか作られない。だから useCallback も useMemo も要らないし、「古いstateを掴んだクロージャ」も原理的に発生しない。
ref と reactive
値を持つ箱は2種類ある。
import { ref, reactive } from 'vue'
// ref: 何でも入る。JS側では .value が必要
const count = ref(0)
const user = ref({ name: 'masa' })
count.value++
user.value.name = 'toukanno'
// reactive: オブジェクト専用。.value は不要
const state = reactive({ count: 0, name: 'masa' })
state.count++
実務では ref に統一するのがおすすめ。 reactive はプリミティブを扱えない上に、分割代入するとリアクティビティが失われる。
const state = reactive({ count: 0 })
let { count } = state // ここで縁が切れる。count は ただの 0
count++ // 画面は変わらない
ref なら渡し回しても壊れないので、判断コストが減る。ちなみに template の中では .value が自動で外れるので、書くのはJS側だけ。
<template>
<p>{{ count }}</p> <!-- count.value ではない -->
</template>
2. テンプレート構文
JSXと違い、Vueは HTML に属性を足していく形式を採る。
<template>
<!-- 条件分岐: v-if は DOM ごと消す / v-show は display:none -->
<p v-if="isLoading">読み込み中</p>
<p v-else-if="error">{{ error.message }}</p>
<ul v-else>
<!-- 繰り返し -->
<li v-for="item in items" :key="item.id">{{ item.title }}</li>
</ul>
<!-- 属性バインド (: は v-bind: の省略) -->
<img :src="user.avatar" :alt="user.name">
<!-- イベント (@ は v-on: の省略) -->
<button @click="save" :disabled="isSaving">保存</button>
<!-- 双方向バインド -->
<input v-model="query" placeholder="検索">
</template>
v-model は React にない発想で、実体は :value と @input のセットの糖衣構文。フォームの多い管理画面だと記述量がはっきり減る。
一長一短
JSXは「ただのJavaScript」なので、複雑な分岐やリストの組み立てを普通のコードとして書ける。Vueのテンプレートは表現力で劣る代わりに、静的解析しやすいという利点がある。コンパイル時にどこが動的かを判別できるため、実行時の差分計算量が少なくて済む。
なお Vue でも JSX は使える(vue/babel-plugin-jsx)。レンダリングロジックが複雑なコンポーネントだけ JSX にする、という混在も可能。
3. computed ― useMemo の要らない世界
派生値は computed で作る。依存配列は書かない。中で参照した ref を Vue が自動で追跡してくれる。
const items = ref([])
const keyword = ref('')
const filtered = computed(() =>
items.value.filter(i => i.title.includes(keyword.value))
)
// items か keyword が変わったときだけ再計算。それ以外はキャッシュを返す
React の useMemo は「書き忘れると無駄な再計算が起きる最適化」だが、Vue の computed は「派生値を宣言する標準的な書き方」。パフォーマンスを気にして付けるものではなく、常にこう書く。
書き込み可能にもできる。
const fullName = computed({
get: () => `${first.value} ${last.value}`,
set: (v) => { [first.value, last.value] = v.split(' ') }
})
4. watch と watchEffect ― useEffect の分割
Reactの useEffect が担っていた仕事は、Vueでは目的別に分かれている。
| やりたいこと | React | Vue |
|---|---|---|
| 派生値を計算 | useMemo |
computed |
| 特定の値の変化に反応 | useEffect(fn, [dep]) |
watch(dep, fn) |
| 使った値すべてに反応 | (手書きの依存配列) | watchEffect(fn) |
| マウント時に1回 | useEffect(fn, []) |
onMounted(fn) |
// 監視対象を明示する。旧値も受け取れる
watch(userId, async (newId, oldId) => {
user.value = await fetchUser(newId)
})
// 複数監視
watch([page, keyword], ([p, k]) => reload(p, k))
// reactive オブジェクトの中身を深く見る
watch(form, () => { isDirty.value = true }, { deep: true })
// 初回も走らせたい
watch(userId, load, { immediate: true })
watchEffect は依存を書かない。実行してみて触れた値を自動で依存として登録する。
watchEffect(() => {
console.log(`${page.value}ページ目 / 検索語: ${keyword.value}`)
// page と keyword が依存として自動登録される
})
便利だが、条件分岐の中でしか触れない値が依存から漏れることがある。 監視対象がはっきりしているなら watch を明示的に書くほうが読み手に優しい。
クリーンアップ
useEffect は return がクリーンアップだったが、Vueは引数で受け取る。
watch(query, async (q, _old, onCleanup) => {
const ac = new AbortController()
onCleanup(() => ac.abort()) // 次の実行前 / 破棄時に走る
const res = await fetch(`/api/search?q=${q}`, { signal: ac.signal })
results.value = await res.json()
})
なお、コンポーネント内で作った watch / watchEffect / computed はアンマウント時に自動で破棄される。手で止める必要があるのは Vue の管理外のもの(addEventListener、setInterval、WebSocket など)だけ。
5. ライフサイクル
import { onMounted, onBeforeUnmount, onUnmounted } from 'vue'
onMounted(() => {
// DOM 描画済み。template ref に実体が入るのはここから
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
// まだ DOM が生きている。サイズ計測やスクロール位置の保存はここ
})
onUnmounted(() => {
// 後片付け全般
window.removeEventListener('resize', onResize)
})
React の StrictMode のような開発時の二重実行はないので、onMounted は開発でも本番でも1回だけ。
<KeepAlive> でキャッシュされるコンポーネントは onUnmounted が呼ばれず、代わりに onActivated / onDeactivated が走る。タブUIで後片付けが動かないときはこれを疑う。
6. コンポーネント間の受け渡し
props と emit
<!-- 子: NoteItem.vue -->
<script setup>
const props = defineProps({
note: { type: Object, required: true },
compact: { type: Boolean, default: false }
})
const emit = defineEmits(['delete', 'update'])
</script>
<template>
<article :class="{ compact }">
<h3>{{ note.title }}</h3>
<button @click="emit('delete', note.id)">削除</button>
</article>
</template>
<!-- 親 -->
<NoteItem :note="n" compact @delete="removeNote" />
Reactが onDelete という関数をpropsで渡すのに対し、Vueはイベントとして上に投げる。DOMのイベントモデルに近い。
TypeScript なら型引数で書ける。
const props = defineProps<{ note: Note; compact?: boolean }>()
const emit = defineEmits<{
delete: [id: number]
update: [note: Note]
}>()
provide / inject
React Context 相当。深い階層に配りたいときに使う。
// 親
provide('theme', theme) // theme は ref のまま渡す
// 子孫
const theme = inject('theme', 'light') // 第2引数はデフォルト値
Reactと違い、Context の値が変わっても間のコンポーネントは再レンダーされない。 使っているノードだけが更新される。
7. Composables ― カスタムフックとほぼ同じ
ロジックの再利用単位。use 始まりの関数にまとめる、という慣習も同じ。
// composables/useAutoSave.js
import { ref, watch, onUnmounted } from 'vue'
export function useAutoSave(source, save, delay = 3000) {
const status = ref('idle')
let timer
watch(source, () => {
status.value = 'pending'
clearTimeout(timer)
timer = setTimeout(async () => {
status.value = 'saving'
await save(source.value)
status.value = 'saved'
}, delay)
}, { deep: true })
onUnmounted(() => clearTimeout(timer))
return { status }
}
<script setup>
const doc = ref({ body: '' })
const { status } = useAutoSave(doc, saveToDisk)
</script>
Reactのフックと違い、呼び出し順序のルールがない。 setup が1回しか走らないので、条件分岐の中で呼んでも早期returnの後で呼んでも壊れない。ESLintの rules-of-hooks に相当するものが不要。
8. エコシステム
| 用途 | 定番 | 補足 |
|---|---|---|
| 状態管理 | Pinia | 公式。Vuexの後継。DevTools連携が強い |
| ルーティング | Vue Router | 公式 |
| メタフレームワーク | Nuxt | Next.js 相当。SSR/SSG/ファイルベースルーティング |
| ユーティリティ | VueUse | 200以上のcomposable集。実質準標準 |
| UIライブラリ | PrimeVue / Vuetify / Naive UI | shadcn/ui 系なら shadcn-vue |
Pinia はストアもcomposableとして書ける。
// stores/notes.js
export const useNotesStore = defineStore('notes', () => {
const items = ref([])
const pinned = computed(() => items.value.filter(i => i.pinned))
async function load() {
items.value = await api.get('/notes')
}
return { items, pinned, load }
})
Redux Toolkit のようなボイラープレートがなく、コンポーネント内のコードをそのまま外に出した感覚で書ける。
VueUse はクリーンアップ漏れを構造的に防いでくれるので、早い段階で入れておくと楽。
import { useEventListener, useIntervalFn, useLocalStorage } from '@vueuse/core'
useEventListener(window, 'resize', onResize) // 自動で解除される
useIntervalFn(autoSave, 30_000) // 自動で停止する
const theme = useLocalStorage('theme', 'dark') // ref として同期される
9. Laravel と組み合わせる場合
Vue は Laravel 界隈で採用例が多い。選択肢は3つ。
Inertia.js
APIを書かずにSPAを作れる。コントローラから直接 Vue コンポーネントに props を渡す形になるので、Blade の感覚に近いまま SPA 化できる。
return Inertia::render('Notes/Index', [
'notes' => Note::latest()->get(),
]);
Blade + 部分的な Vue マウント
既存アプリの一部だけリッチにしたいとき。移行コストが最小。
完全分離(Laravel は API のみ / Nuxt が front)
SEOやSSRを本気でやる、フロント専任がいる、といった場合。構成は綺麗だが認証まわりの手間が増える。
段階的に進めるなら Inertia が扱いやすい。
10. で、どちらを選ぶか
Vueが向く場面
- フォームや一覧が主体の業務系画面。
v-modelとテンプレートで記述量が減る - チームにフロント専任がいない。学習コストが低く、依存配列やメモ化の事故が起きにくい
- Laravel が既にある(Inertia というショートカットが使える)
- 細かい再レンダー制御を考えたくない
Reactが向く場面
- React Native でモバイルも作る
- 求人・情報量・サードパーティライブラリの選択肢を最優先する
- レンダリングロジックが複雑で、JSXの表現力が効く
- 既存のReact資産がある
技術的な優劣というより、チームの構成と既存資産で決まる話。どちらも十分に成熟していて、これを間違えたからプロジェクトが失敗するという類のものではない。
つまずきポイントまとめ
-
.valueの付け忘れ — JS側では必須、template では不要。慣れるまでは一番よく踏む。Volar(公式のVSCode拡張)を入れると警告してくれる -
reactiveの分割代入 — リアクティビティが切れる。refに統一すれば回避できる - props を直接書き換える — 一方向。変更は emit で親に投げる
-
watchEffectの依存漏れ — 条件分岐の中でしか触れない値は追跡されない -
<KeepAlive>配下のライフサイクル —onUnmountedではなくonDeactivated
参考
- 公式ドキュメント日本語版: https://ja.vuejs.org/
- VueUse: https://vueuse.org/
- Pinia: https://pinia.vuejs.org/