はじめに
はじめまして!
自分は2年ほどReactを使った開発をしてきた後、Vueを使った開発がメインの会社に転職をしました。
React と Vue は、どちらもモダンなフロントエンド開発で広く使われているフレームワークです。思想や設計は似ている部分も多い一方で、書き方が異なるためいきなり移行先のフレームワークのコードを見てもよくわからないと思います。
今回自分がVueをインプットしていく際に
「この書き方はReact でいうと〇〇か!」
と対応づけながら見るとすんなり頭に入っていくように感じたので、自分のために備忘録として残しておくとともに今回書く記事が似たような境遇の方の参考になればいいなと思います。
目次を作ったので知りたいところだけ見るみたいな使い方をおすすめします。
1. ローカルステート(状態管理の考え方)
ローカルステートは、Vue は ref() を使い React は useState を使います
Vue(ref)
Vue でリアクティブな変数を持ちたいときは、基本的に ref() を使います。
const count = ref(0)
count.value++
.value が急に出てきて戸惑いますが、
内部的なイメージとしては次のような「箱」を扱っていると考えると分かりやすいです。
// イメージ(実際は Vue が監視用の仕組みを持っている)
const count = {
value: 0
}
そのため、スクリプト内で値を変更するときは .value を指定する必要があります。
オブジェクトを扱う場合
ref はオブジェクトもそのまま扱えます。
const user = ref({ name: 'Alice', age: 20 })
user.value.age++
オブジェクトの場合でも、中のプロパティ変更には正しくリアクティブに反応します。
ちなみに<template> 内では自動的に .value が展開されるため、省略できます。
<template>
<button>{{ count }}</button>
<p>{{ user.age }}</p>
</template>
React(useState)
Reacter にとってはおなじみの useState です。
リアクティブな変数を持ちたいときはuseStateを使って、変数とそれに変更を加える関数を一緒に生成します。
const [count, setCount] = useState(0)
setCount(prev => prev + 1)
const [user, setUser] = useState({ name: 'Alice', age: 20 })
setUser(prev => ({ ...prev, age: prev.age + 1 }))
state は直接書き換えることはできないので、変更したいときはsetter関数を使います
2. グローバルステート
グローバルステートとは、コンポーネント階層をまたいで共有される状態や関数のことです。
どのコンポーネントからでも参照・更新できるため便利な反面、使いすぎると依存関係が見えづらくなり、可読性や保守性が下がる原因にもなります。
基本的には props で受け渡しできる範囲に留めつつ、
コンポーネントのネストが深くなり、props のバケツリレーが辛くなってきた場合に、
グローバルステートを使うのが一般的です。
下記で紹介するuseContext ⇄ provide / inject はほぼ同じ役割です。
Vue:provide / inject
親コンポーネント
<script setup lang="ts">
import { provide, ref } from 'vue'
const user = ref(null)
const setUser = (value) => {
user.value = value
}
provide('userContext', {
user,
setUser
})
</script>
子・孫コンポーネント
<script setup lang="ts">
import { inject } from 'vue'
const { user, setUser } = inject('userContext')
</script>
-
provideした値は、そのコンポーネント配下で利用可能 -
refを渡すことでリアクティブな状態として扱える
React:useContext
import { createContext, useContext, useState } from 'react'
const UserContext = createContext(null)
export const UserProvider = ({ children }) => {
const [user, setUser] = useState(null)
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
)
}
const { user, setUser } = useContext(UserContext)
-
Provider配下であれば、どのコンポーネントからでも利用可能 -
useStateと組み合わせることで簡易的なグローバルステートになる
補足:Redux / Pinia について
provide / inject や useContext は、
値をコンポーネントツリー内で共有するための仕組みです。
一方で、アプリが大きくなると次のような問題が出てきます。
- 状態がどこで変更されたのか分かりにくい
- 更新ルールがバラバラになる
- 状態の数が増えて管理が難しくなる
こうした課題に対して使われるのが、
専用の状態管理ライブラリである Pinia や Redux です。
Pinia(Vue)
Pinia は Vue の公式状態管理ライブラリで、Vuex の後継です。
- Vue のリアクティブシステムと自然に統合される
- 複数の store を定義できる
// stores/user.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref(null)
const setUser = (value) => {
user.value = value
}
return { user, setUser }
})
const userStore = useUserStore()
userStore.setUser(data)
Redux(React)
Redux は、
状態の変更を必ず「dispatch → reducer」という1本の流れに制限する
という思想を持ったライブラリです。
// reducer.ts
const initialState = { user: null }
export const userReducer = (state = initialState, action) => {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload }
default:
return state
}
}
dispatch({ type: 'SET_USER', payload: user })
Redux には下記のような特徴があります
- 状態は単一のストアで管理される
- 直接状態を変更できない
- どんな操作で状態が変わったかを追いやすい
3. 双方向バインディングの有無
双方向データバインディングとはUI の値と state が自動で同期される仕組みです。
state を変える → UI が更新される
UI を操作する → state が更新される
この両方向が自動でつながっている状態を指します。
Vue(v-model)
Vue では v-model という仕組みが用意されており、
双方向バインディングを簡潔に記述できます。
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
</script>
<template>
<input v-model="name" />
<p>{{ name }}</p>
</template>
- 状態(name)と UI が自動で同期される
- 記述量が少なく、フォーム実装が直感的
- 小〜中規模ではとても扱いやすい
React
React は 単方向データフローを前提としており、
「UI → state の更新」もイベントとして明確に扱います。
import { useState } from 'react'
export const Sample = () => {
const [name, setName] = useState('')
return (
<>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p>{name}</p>
</>
)
}
- 双方向バインディングは存在しない
- 値の表示と更新処理を明示的に書く
- state の変更箇所が常に分かる
対応関係
-
v-model⇔value + onChange
補足:どっちがいいの?
これをぱっと見たときにReactの書き方は冗長に感じたのでVueのほうがいいのではと思いました。
しかしReactは単方向データフローなので、状態の流れが追いやすいです。またロジックが明示的なのでテストやデバッグもしやすいです。
Vue はすっきり書ける反面、規模が大きくなると依存関係が増えやすいため、設計には注意が必要です。
4. Props とイベントの受け渡し
React から Vue へ移行する際一番戸惑ったのがこれでした。
React では、
- 親コンポーネントが状態や更新用の関数を定義し、props として子に渡す
- 子コンポーネントは、渡された関数を呼び出すことで親の状態を更新する
一方 Vue では、
- 子コンポーネントは emit を使ってイベントを発火する
- 親コンポーネントは子からイベントを受け取って処理を定義する
- 子は親の状態や関数を直接操作しない
Vue(Props + emit)
子コンポーネント(Child.vue)
<script setup lang="ts">
// 親に通知するイベントを定義
// calculated イベントで number を渡す
const emit = defineEmits<{
calculated: [number]
}>()
const handleClick = () => {
const result = Math.floor(Math.random() * 10) + 1
emit('calculated', result)
}
</script>
<template>
<!-- ボタンを押すと子で計算し、結果を親に渡す -->
<button @click="handleClick"></button>
</template>
親コンポーネント(Parent.vue)
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
// 子から受け取った値を保持する state
const valueFromChild = ref(0)
</script>
<template>
<div>
<p>子から受け取った値: {{ valueFromChild }}</p>
<!--
calculated イベントを受け取り
emit された値 ($event) を state に反映
-->
<Child @calculated="valueFromChild = $event" />
</div>
</template>
- 親 → 子:
props - 子 → 親:
emit
React(Props + コールバック)
子コンポーネント(Child.tsx)
// 親から渡される props の型定義
type Props = {
onCalculated: (value: number) => void
}
export const Child = ({ onCalculated }: Props) => {
const handleClick = () => {
const result = Math.floor(Math.random() * 10) + 1
onCalculated(result)
}
return (
// ボタンを押すと子で計算 → 親の関数を実行
<button onClick={handleClick}>
子で計算して親に渡す
</button>
)
}
親コンポーネント(Parent.tsx)
import { useState } from 'react'
import { Child } from './Child'
export const Parent = () => {
// 子から受け取った値を保持する state
const [valueFromChild, setValueFromChild] = useState(0)
return (
<div>
<p>子から受け取った値: {valueFromChild}</p>
{/*
子に「値を受け取ったときの処理」を渡す
子は計算結果をこの関数に渡すだけ
*/}
<Child onCalculated={(value) => setValueFromChild(value)} />
</div>
)
}
- React には
emitは存在しない - 関数を props として渡すのが基本
🔁 対応関係
-
emit('event')⇔props.onEvent() - Vue のイベント設計 ⇔ React のコールバック設計
5. 副作用・監視
副作用とは、値の変化やレンダリングをきっかけに実行される処理です。
(API 呼び出し、ログ出力、保存処理など)
Vue(watchEffect)
Vueでは、副作用用途として watchEffect を使うのが直感的です。依存関係を明示的に指定しなくてよいのが楽で良いなと思いました。
しかし watchEffect 内に登場する 全ての reactiveな値が監視対象となるので意図しない挙動が起きる可能性があるので、注意して使用する必要があります。
watchEffect(() => {
console.log(count.value)
})
- 中で使っているリアクティブな値を自動で監視
- 初回に必ず1回実行される
React(useEffect)
Reactでは レンダリング後に実行される副作用を useEffect で定義します。
useEffect(() => {
console.log(count)
}, [count])
- 第2引数(依存配列)で再実行条件を指定、この場合countが変更されたら再実行されます
- 初回実行 + 依存が変わったときに実行
対応関係
watchEffect(fn) ⇔ useEffect(fn, [依存])
補足:Vue の watch
watch は 特定の値だけをピンポイントで監視したいときに使います。
watch(count, (newValue, oldValue) => {
console.log(newValue)
})
- 監視対象を明示的に指定
- 初回は実行されない(immediate: true を指定してあげると実行されます)
-
oldValueが取得できる
watch(() => user.age, (age) => {
console.log(age)
})
6. ロジックの再利用
ロジック部分を再利用するために別ファイルにまとめるといった考え方は同じです。
ほぼ一緒だと思ってもらって大丈夫かと思いますが、細かいところに違いはあります
- use〇〇という命名がReactは必須、Vueは必須じゃない
- Reactのhooksはトップレベルでのみ使用可能。if文の中では使用不可。Vue はどこでもOK
Vue(Composable)
export const useCounter = () => {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
React(Custom Hook)
export const useCounter = () => {
const [count, setCount] = useState(0)
const increment = () => setCount(c => c + 1)
return { count, increment }
}
7. キャッシュを伴う状態変化
Vue(computed)
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
- 依存する値が変わると自動で再計算
- キャッシュされる
React(関数 or useMemo)
基本は下記のように書くだけで、
state が変わるたびに再レンダリングされるため
fullName も自動で再計算されます。
const fullName = `${firstName} ${lastName}`
この計算は毎回行われますが、
軽い処理であれば特に問題はありません。
計算が重い場合のみ、再計算を避けるために
useMemo を使ってキャッシュします。
const fullName = useMemo(() => {
return `${firstName} ${lastName}`
}, [firstName, lastName])
8. ルーティング
設計思想の違い
まず大きな違いとして、ルーティングをどこで定義するかが異なります。
-
Vue(vue-router)
- 専用の
router設定ファイルで定義 - 画面コンポーネントとは分離して管理
- 専用の
-
React(react-router)
- 配置場所は自由
- プロジェクトの規模によって書き方を変える
Vue(vue-router)
ディレクトリ構成例
src/
├ router/
│ └ index.ts # ルーティング定義(集約)
│
├ views/ # 画面(ルートに対応するコンポーネント)
│ ├ game/
│ │ ├ Game.vue # /game(親)
│ │ ├ GameDetail.vue # /game/detail
│ │ └ GameScore.vue # /game/score
│ │
│ └ user/
│ ├ User.vue # /user(親)
│ ├ UserProfile.vue # /user/profile
│ └ UserSetting.vue # /user/setting
│
├ App.vue
└ main.ts
routerの設定ファイルにルーティングの設定を記載します
router/index.ts
import Game from '@/views/game/Game.vue'
import GameDetail from '@/views/game/GameDetail.vue'
import GameScore from '@/views/game/GameScore.vue'
import User from '@/views/user/User.vue'
import UserProfile from '@/views/user/UserProfile.vue'
const routes = [
{
path: '/game',
component: Game,
children: [
{ path: 'detail', component: GameDetail },
{ path: 'score', component: GameScore }
]
},
{
path: '/user',
component: User,
children: [
{ path: 'profile', component: UserProfile }
]
}
]
Game.vue
<RouterView />がパスに該当するコンポーネントを表示してくれます
User.vueは構成が同じなので割愛
<template>
<div>
<h1>Game Page</h1>
<!-- /game 配下の子コンポーネントがここに表示される -->
<RouterView />
</div>
</template>
表示結果
| URL | 表示されるコンポーネント |
|---|---|
/game |
Game |
/game/detail |
Game + GameDetail
|
/game/score |
Game + GameScore
|
/user |
User |
/user/profile |
User + UserProfile
|
React(react-router)
Reactは
- App.tsxで全てルーティングする集約型
- 機能ごとにルーティングする分散型
の2つがあります。
プロジェクトの規模で使い分けますが
小規模であれば集約型、大規模であれば分散型という使い分けをしていきます。
両方のコードパターンを載せておきます。
ディレクトリ構成例
// 集約型
src/
├ App.tsx # 全ルートを定義する親コンポーネント
│
├ game/
│ ├ Game.tsx # /game の親画面(レイアウト・Outletあり)
│ ├ GameDetail.tsx # /game/detail の画面
│ └ GameScore.tsx # /game/score の画面
│
└ user/
├ User.tsx # /user の親画面(レイアウト・Outletあり)
└ UserProfile.tsx # /user/profile の画面
// 分散型
src/
├ App.tsx # ルートの大枠だけ定義(/game, /user など)
│
├ game/
│ ├ GameLayout.tsx # /game の親レイアウト(Outletを持つ)
│ ├ GameDetail.tsx # /game/detail の画面
│ ├ GameScore.tsx # /game/score の画面
│ └ routes.tsx # game配下のルーティング定義
│
└ user/
├ UserLayout.tsx # /user の親レイアウト(Outletを持つ)
├ UserProfile.tsx # /user/profile の画面
└ routes.tsx # user配下のルーティング定義
App.tsx(集約型)
// import文省略
export const App = () => {
return (
<Routes>
<Route path="/game" element={<Game />}>
<Route path="detail" element={<GameDetail />} />
<Route path="score" element={<GameScore />} />
</Route>
<Route path="/user" element={<User />}>
<Route path="profile" element={<UserProfile />} />
</Route>
</Routes>
)
}
App.tsx(分散型)
// import文省略
export const App = () => {
return (
<Routes>
{/* /game 配下を game に委譲 */}
<Route path="/game" element={<GameLayout />}>
<Route path="*" element={<GameRoutes />} />
</Route>
{/* /user 配下を user に委譲 */}
<Route path="/user" element={<UserLayout />}>
<Route path="*" element={<UserRoutes />} />
</Route>
</Routes>
)
}
Game/routes.tsx(分散型のみ)
分散型は各機能ディレクトリにroutes.tsxを置き
そこでルーティングをしていきます
user/routes.tsxは同じ構成なので割愛します。
// import文省略
export const GameRoutes = () => {
return (
<Routes>
<Route path="detail" element={<GameDetail />} />
<Route path="score" element={<GameScore />} />
</Routes>
)
}
Game.tsx(親コンポーネント)
ここは集約型も分散型も共通ですね
<Outlet />がパスに該当するコンポーネントを表示してくれます
user.tsxも同じ構成なので割愛します
import { Outlet } from 'react-router-dom'
export const Game = () => {
return (
<div>
<h1>Game Page</h1>
{/* /game 配下の子コンポーネントがここに表示される */}
<Outlet />
</div>
)
}
表示結果
| URL | 表示されるコンポーネント |
|---|---|
/game |
Game |
/game/detail |
Game + GameDetail
|
/game/score |
Game + GameScore
|
/user |
User |
/user/profile |
User + UserProfile
|
補足:フレームワーク使えばいいんじゃないか説
ReactのフレームワークNext.jsと
VueのフレームワークNuxt.jsですが
両方ともディレクトリ構造で自動的にルーティングをしてくれます
それなら最初からフレームワーク使ったほうがいいじゃんと思うかもしれませんが
- ルートを動的に生成したり、複雑なネストや条件付きルートを簡単に実装できる
- バンドルサイズを小さくできるため軽量に作れる
といったメリットがあります。
ルーティングだけを使うために、フレームワークを使うのはちょっとオーバースペックな気もしますのでバランスを考えて仕様を検討すると良いと思います。
感想
記事を通して見てきたように、React と Vue は思想や設計アプローチに違いはあるものの、実現できること自体に大きな差はありません。どちらも優れたフレームワークであり、単純な機能面で優劣がつくものではないと思います。
一方で、実務の観点では周辺エコシステムや現在の主流を意識する必要があると思います。
React では Next.js を前提とした開発が事実上の標準になりつつあり、フルスタック対応や各種 SaaS との連携、事例の多さといった点で強みがあります。
検索ワードでもReactのほうが優勢です。
Vue の方でも Nuxt を中心としたエコシステムが整備されており、特に「何をどこに書けばよいか」が明確な点や、フレームワーク側が用意する規約の多さを評価するチームも多いでしょう。
ディレクトリ構成によるルーティングや、状態管理・非同期処理・SEO 対応といった要素が最初から一定の形で揃っているため、設計の自由度よりも開発体験や属人性の低さを重視するプロジェクトでは採用しやすい傾向があります
このように、「できることはほぼ同じ」でも、アプリを構成するうえでどこまでを自分で設計しどこからをフレームワークの規約に任せるのかは、選ぶフレームワークやメタフレームワークによって変わってくるのかなと思いました。
終わりに
今回は、React ⇄ Vue への移行を考えている方や、すでに移行したばかりの方に向けて、両フレームワークの対応関係をまとめました。
この記事が2つのフレームワークの理解する助けになれば幸いです。
最後まで読んでいただき、ありがとうございました。
