T-CREATOR

Vue.js と React の本当の違いを徹底比較

Vue.js と React の本当の違いを徹底比較

フロントエンド開発において、Vue.js と React は最も人気の高いフレームワーク・ライブラリです。しかし、表面的な機能だけでなく、技術アーキテクチャの根本的な違いを理解することが、適切な技術選択の鍵となります。

本記事では、Vue.js と React の設計思想から内部実装まで、技術者が真に知るべき本質的な違いを詳細に解説します。単なる機能比較ではなく、なぜそのような設計になったのか、どのような技術的トレードオフがあるのかを深く掘り下げていきます。

設計思想の根本的な違い

Vue.js: プログレッシブフレームワーク

Vue.js はプログレッシブフレームワークとして設計されており、既存のプロジェクトに段階的に導入できることを重視しています。

javascript// Vue.js - 段階的導入の例
// 1. CDN で簡単に始められる
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
  const { createApp } = Vue
  createApp({
    data() {
      return { message: 'Hello Vue!' }
    }
  }).mount('#app')
</script>

// 2. Single File Component での本格開発
<template>
  <div>{{ message }}</div>
</template>

<script setup>
import { ref } from 'vue'
const message = ref('Hello Vue!')
</script>

React: ライブラリエコシステム

React はライブラリとして設計され、UI 構築に特化しています。他の機能は外部ライブラリとの組み合わせで実現します。

javascript// React - エコシステム前提の設計
import React, { useState } from 'react';
import { BrowserRouter } from 'react-router-dom'; // ルーティング
import { Provider } from 'react-redux'; // 状態管理
import {
  QueryClient,
  QueryClientProvider,
} from 'react-query'; // データフェッチ

function App() {
  const [message, setMessage] = useState('Hello React!');

  return (
    <Provider store={store}>
      <QueryClientProvider client={queryClient}>
        <BrowserRouter>
          <div>{message}</div>
        </BrowserRouter>
      </QueryClientProvider>
    </Provider>
  );
}

内部アーキテクチャの比較

リアクティビティシステムの違い

Vue.js のリアクティビティ

Vue.js は Proxy ベースのリアクティビティシステムを採用しています。

javascript// Vue.js - 自動的なリアクティビティ
<script setup>
import { ref, reactive, computed } from 'vue'

const count = ref(0)
const user = reactive({ name: 'Alice', age: 25 })

// 依存関係は自動的に追跡される
const doubled = computed(() => count.value * 2)
const userInfo = computed(() => `${user.name} (${user.age}歳)`)

// 値の変更で自動的にUIが更新される
const increment = () => {
  count.value++
  user.age++
}
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Doubled: {{ doubled }}</p>
    <p>User: {{ userInfo }}</p>
    <button @click="increment">更新</button>
  </div>
</template>

React の状態管理

React は 明示的な状態更新を要求します。

javascript// React - 明示的な状態管理
import React, {
  useState,
  useMemo,
  useCallback,
} from 'react';

function MyComponent() {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState({
    name: 'Alice',
    age: 25,
  });

  // 依存関係を明示的に指定
  const doubled = useMemo(() => count * 2, [count]);
  const userInfo = useMemo(
    () => `${user.name} (${user.age}歳)`,
    [user.name, user.age]
  );

  // 状態更新を明示的に行う
  const increment = useCallback(() => {
    setCount((prev) => prev + 1);
    setUser((prev) => ({ ...prev, age: prev.age + 1 }));
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <p>User: {userInfo}</p>
      <button onClick={increment}>更新</button>
    </div>
  );
}

仮想 DOM の実装方式

Vue.js の仮想 DOM

Vue.js はテンプレートコンパイル時に静的解析を行い、効率的な更新を実現します。

javascript// Vue.js - コンパイル時最適化
<template>
  <div>
    <h1>{{ title }}</h1> <!-- 動的 -->
    <p>固定テキスト</p> <!-- 静的 - 更新対象外 -->
    <span>{{ dynamicText }}</span> <!-- 動的 -->
  </div>
</template>

// コンパイル後の内部表現(簡略化)
function render() {
  return h('div', [
    h('h1', this.title), // 更新対象
    h('p', '固定テキスト'), // スキップ対象としてマーク
    h('span', this.dynamicText) // 更新対象
  ])
}

React の仮想 DOM

React はランタイムでの差分計算に依存します。

javascript// React - ランタイム差分計算
function MyComponent({ title, dynamicText }) {
  return (
    <div>
      <h1>{title}</h1>
      <p>固定テキスト</p>
      <span>{dynamicText}</span>
    </div>
  );
}

// React.createElement での内部表現
React.createElement(
  'div',
  null,
  React.createElement('h1', null, title),
  React.createElement('p', null, '固定テキスト'),
  React.createElement('span', null, dynamicText)
);

状態管理のアプローチ

#項目Vue.jsReact
1内蔵状態管理Pinia(公式)なし
2状態の変更直接変更可能イミュータブル
3依存関係追跡自動手動指定
4学習コスト低い高い

コンポーネントシステムの違い

テンプレート vs JSX

Vue.js のテンプレート

vue<!-- Vue.js - HTMLライクなテンプレート -->
<template>
  <div class="user-card">
    <!-- 条件分岐 -->
    <div v-if="user.isOnline" class="status online">
      オンライン
    </div>
    <div v-else class="status offline">オフライン</div>

    <!-- ループ処理 -->
    <ul>
      <li v-for="hobby in user.hobbies" :key="hobby.id">
        {{ hobby.name }}
      </li>
    </ul>

    <!-- イベントハンドリング -->
    <button @click="handleClick">クリック</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const user = ref({
  isOnline: true,
  hobbies: [
    { id: 1, name: '読書' },
    { id: 2, name: '映画鑑賞' },
  ],
});

const handleClick = () => {
  console.log('クリックされました');
};
</script>

React の JSX

jsx// React - JSX(JavaScriptの拡張)
import React, { useState } from 'react';

function UserCard() {
  const [user] = useState({
    isOnline: true,
    hobbies: [
      { id: 1, name: '読書' },
      { id: 2, name: '映画鑑賞' },
    ],
  });

  const handleClick = () => {
    console.log('クリックされました');
  };

  return (
    <div className='user-card'>
      {/* 条件分岐 */}
      {user.isOnline ? (
        <div className='status online'>オンライン</div>
      ) : (
        <div className='status offline'>オフライン</div>
      )}

      {/* ループ処理 */}
      <ul>
        {user.hobbies.map((hobby) => (
          <li key={hobby.id}>{hobby.name}</li>
        ))}
      </ul>

      {/* イベントハンドリング */}
      <button onClick={handleClick}>クリック</button>
    </div>
  );
}

データフローの設計

Vue.js - Props Down, Events Up + v-model

vue<!-- 子コンポーネント: InputField.vue -->
<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
    :placeholder="placeholder"
  />
</template>

<script setup>
defineProps({
  modelValue: String,
  placeholder: String,
});

defineEmits(['update:modelValue']);
</script>

<!-- 親コンポーネント -->
<template>
  <div>
    <!-- 双方向バインディング -->
    <InputField
      v-model="message"
      placeholder="メッセージを入力"
    />
    <p>入力値: {{ message }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import InputField from './InputField.vue';

const message = ref('');
</script>

React - 単方向データフロー

jsx// 子コンポーネント: InputField.jsx
function InputField({ value, onChange, placeholder }) {
  return (
    <input
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder={placeholder}
    />
  );
}

// 親コンポーネント
import React, { useState } from 'react';

function ParentComponent() {
  const [message, setMessage] = useState('');

  return (
    <div>
      <InputField
        value={message}
        onChange={setMessage}
        placeholder='メッセージを入力'
      />
      <p>入力値: {message}</p>
    </div>
  );
}

ライフサイクル管理

Vue.js のライフサイクル

vue<script setup>
import {
  ref,
  onMounted,
  onUpdated,
  onUnmounted,
  watch,
} from 'vue';

const data = ref(null);
const error = ref(null);

// マウント時
onMounted(async () => {
  try {
    const response = await fetch('/api/data');
    data.value = await response.json();
  } catch (err) {
    error.value = err.message;
  }
});

// 更新時
onUpdated(() => {
  console.log('コンポーネントが更新されました');
});

// アンマウント時
onUnmounted(() => {
  // クリーンアップ処理
  console.log('コンポーネントがアンマウントされました');
});

// データ変更の監視
watch(data, (newData, oldData) => {
  console.log('データが変更されました:', newData);
});
</script>

React のライフサイクル

jsximport React, { useState, useEffect } from 'react';

function MyComponent() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  // マウント時とデータ変更時
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('/api/data');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      }
    };

    fetchData();
  }, []); // 空の依存配列でマウント時のみ実行

  // データ変更の監視
  useEffect(() => {
    if (data) {
      console.log('データが変更されました:', data);
    }
  }, [data]); // dataの変更時に実行

  // アンマウント時のクリーンアップ
  useEffect(() => {
    return () => {
      console.log('コンポーネントがアンマウントされました');
    };
  }, []);

  if (error) return <div>エラー: {error}</div>;
  if (!data) return <div>読み込み中...</div>;

  return <div>{/* データ表示 */}</div>;
}

パフォーマンス特性の詳細比較

バンドルサイズ比較

#項目Vue.js 3React 18
1最小構成34KB42KB
2本格構成45KB130KB+
3Tree-shaking優秀良好

レンダリングパフォーマンス

javascript// Vue.js - 静的ホイスティング例
<template>
  <div>
    <h1>{{ title }}</h1>
    <div class='static-content'>
      <p>このコンテンツは静的</p>
      <span>更新されない</span>
    </div>
    <p>{{ dynamicContent }}</p>
  </div>
</template>;

// コンパイル後(簡略化)
const _hoisted_1 = h('div', { class: 'static-content' }, [
  h('p', 'このコンテンツは静的'),
  h('span', '更新されない'),
]);

function render() {
  return h('div', [
    h('h1', this.title),
    _hoisted_1, // 再作成されない
    h('p', this.dynamicContent),
  ]);
}
jsx// React - React.memo での最適化
import React, { memo } from 'react';

// 静的コンテンツをメモ化
const StaticContent = memo(() => (
  <div className='static-content'>
    <p>このコンテンツは静的</p>
    <span>更新されない</span>
  </div>
));

function MyComponent({ title, dynamicContent }) {
  return (
    <div>
      <h1>{title}</h1>
      <StaticContent />
      <p>{dynamicContent}</p>
    </div>
  );
}

TypeScript 統合の違い

Vue.js の TypeScript サポート

typescript// Vue.js - ネイティブサポート
<script setup lang="ts">
import { ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

const users = ref<User[]>([])
const loading = ref<boolean>(false)

// 型推論が効く
const userCount = computed<number>(() => users.value.length)

const fetchUsers = async (): Promise<void> => {
  loading.value = true
  try {
    const response = await fetch('/api/users')
    users.value = await response.json() // User[] として型推論
  } catch (error) {
    console.error('Failed to fetch users:', error)
  } finally {
    loading.value = false
  }
}

// プロパティの型定義
interface Props {
  initialUsers?: User[]
  showCount?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  initialUsers: () => [],
  showCount: true
})
</script>

React の TypeScript サポート

typescript// React - 明示的な型指定が必要
import React, { useState, useEffect, FC } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

interface Props {
  initialUsers?: User[];
  showCount?: boolean;
}

const UserComponent: FC<Props> = ({
  initialUsers = [],
  showCount = true,
}) => {
  const [users, setUsers] = useState<User[]>(initialUsers);
  const [loading, setLoading] = useState<boolean>(false);

  // 型を明示的に指定
  const userCount: number = users.length;

  const fetchUsers = async (): Promise<void> => {
    setLoading(true);
    try {
      const response = await fetch('/api/users');
      const data: User[] = await response.json();
      setUsers(data);
    } catch (error) {
      console.error('Failed to fetch users:', error);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchUsers();
  }, []);

  return (
    <div>
      {showCount && <p>ユーザー数: {userCount}</p>}
      {/* ユーザー一覧表示 */}
    </div>
  );
};

export default UserComponent;

実際のコード比較:同機能の実装方式

複雑なフォーム処理

Vue.js 版

vue<template>
  <form @submit.prevent="submitForm" class="contact-form">
    <div class="field">
      <label>名前</label>
      <input
        v-model="form.name"
        :class="{ error: errors.name }"
        @blur="validateField('name')"
      />
      <span v-if="errors.name" class="error-message">
        {{ errors.name }}
      </span>
    </div>

    <div class="field">
      <label>メール</label>
      <input
        v-model="form.email"
        type="email"
        :class="{ error: errors.email }"
        @blur="validateField('email')"
      />
      <span v-if="errors.email" class="error-message">
        {{ errors.email }}
      </span>
    </div>

    <button
      type="submit"
      :disabled="!isFormValid || loading"
    >
      {{ loading ? '送信中...' : '送信' }}
    </button>
  </form>
</template>

<script setup>
import { ref, computed, watch } from 'vue';

const form = ref({
  name: '',
  email: '',
});

const errors = ref({});
const loading = ref(false);

const isFormValid = computed(() => {
  return (
    Object.keys(errors.value).length === 0 &&
    form.value.name &&
    form.value.email
  );
});

const validateField = (field) => {
  switch (field) {
    case 'name':
      if (!form.value.name) {
        errors.value.name = '名前は必須です';
      } else {
        delete errors.value.name;
      }
      break;
    case 'email':
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!form.value.email) {
        errors.value.email = 'メールアドレスは必須です';
      } else if (!emailRegex.test(form.value.email)) {
        errors.value.email =
          '正しいメールアドレスを入力してください';
      } else {
        delete errors.value.email;
      }
      break;
  }
};

const submitForm = async () => {
  // バリデーション実行
  validateField('name');
  validateField('email');

  if (!isFormValid.value) return;

  loading.value = true;
  try {
    await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form.value),
    });
    alert('送信完了');
  } catch (error) {
    alert('送信エラー');
  } finally {
    loading.value = false;
  }
};
</script>

React 版

jsximport React, {
  useState,
  useCallback,
  useMemo,
} from 'react';

function ContactForm() {
  const [form, setForm] = useState({
    name: '',
    email: '',
  });

  const [errors, setErrors] = useState({});
  const [loading, setLoading] = useState(false);

  const isFormValid = useMemo(() => {
    return (
      Object.keys(errors).length === 0 &&
      form.name &&
      form.email
    );
  }, [errors, form.name, form.email]);

  const validateField = useCallback(
    (field, value) => {
      let newErrors = { ...errors };

      switch (field) {
        case 'name':
          if (!value) {
            newErrors.name = '名前は必須です';
          } else {
            delete newErrors.name;
          }
          break;
        case 'email':
          const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
          if (!value) {
            newErrors.email = 'メールアドレスは必須です';
          } else if (!emailRegex.test(value)) {
            newErrors.email =
              '正しいメールアドレスを入力してください';
          } else {
            delete newErrors.email;
          }
          break;
      }

      setErrors(newErrors);
    },
    [errors]
  );

  const handleInputChange = useCallback((field, value) => {
    setForm((prev) => ({ ...prev, [field]: value }));
  }, []);

  const handleBlur = useCallback(
    (field) => {
      validateField(field, form[field]);
    },
    [validateField, form]
  );

  const submitForm = useCallback(
    async (e) => {
      e.preventDefault();

      // バリデーション実行
      validateField('name', form.name);
      validateField('email', form.email);

      if (!isFormValid) return;

      setLoading(true);
      try {
        await fetch('/api/contact', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(form),
        });
        alert('送信完了');
      } catch (error) {
        alert('送信エラー');
      } finally {
        setLoading(false);
      }
    },
    [form, isFormValid, validateField]
  );

  return (
    <form onSubmit={submitForm} className='contact-form'>
      <div className='field'>
        <label>名前</label>
        <input
          value={form.name}
          onChange={(e) =>
            handleInputChange('name', e.target.value)
          }
          onBlur={() => handleBlur('name')}
          className={errors.name ? 'error' : ''}
        />
        {errors.name && (
          <span className='error-message'>
            {errors.name}
          </span>
        )}
      </div>

      <div className='field'>
        <label>メール</label>
        <input
          type='email'
          value={form.email}
          onChange={(e) =>
            handleInputChange('email', e.target.value)
          }
          onBlur={() => handleBlur('email')}
          className={errors.email ? 'error' : ''}
        />
        {errors.email && (
          <span className='error-message'>
            {errors.email}
          </span>
        )}
      </div>

      <button
        type='submit'
        disabled={!isFormValid || loading}
      >
        {loading ? '送信中...' : '送信'}
      </button>
    </form>
  );
}

よくある誤解と実際の違い

誤解 1: 「Vue.js は React より簡単」

実際の違い

  • Vue.js は始めやすいが、高度な機能では同等の複雑さ
  • React は一貫性があり、慣れれば予測しやすい

誤解 2: 「React は大規模開発向け、Vue.js は小規模向け」

実際の違い

  • 両方とも大規模開発に対応
  • アーキテクチャ設計の考え方が異なる
javascript// Vue.js の大規模開発例
// stores/modules/user.js
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    users: [],
    currentUser: null,
    permissions: [],
  }),
  getters: {
    isAuthenticated: (state) => !!state.currentUser,
    hasPermission: (state) => (permission) =>
      state.permissions.includes(permission),
  },
  actions: {
    async fetchUsers() {
      const response = await userAPI.getUsers();
      this.users = response.data;
    },
  },
});

// React の大規模開発例
// contexts/UserContext.js
import React, {
  createContext,
  useContext,
  useReducer,
} from 'react';

const UserContext = createContext();

function userReducer(state, action) {
  switch (action.type) {
    case 'SET_USERS':
      return { ...state, users: action.payload };
    case 'SET_CURRENT_USER':
      return { ...state, currentUser: action.payload };
    default:
      return state;
  }
}

export function UserProvider({ children }) {
  const [state, dispatch] = useReducer(userReducer, {
    users: [],
    currentUser: null,
    permissions: [],
  });

  return (
    <UserContext.Provider value={{ state, dispatch }}>
      {children}
    </UserContext.Provider>
  );
}

よくあるエラーと解決方法

Vue.js のよくあるエラー

bash# エラー1: Reactivity Transform エラー
Error: Cannot access before initialization

原因と解決

javascript// ❌ エラーが発生するコード
<script setup>
console.log(count) // count が定義される前にアクセス
const count = ref(0)
</script>

// ✅ 正しいコード
<script setup>
const count = ref(0)
console.log(count.value) // 定義後にアクセス
</script>

React のよくあるエラー

bash# エラー2: Hooks の誤用
Error: React Hook "useState" is called conditionally

原因と解決

javascript// ❌ エラーが発生するコード
function MyComponent({ condition }) {
  if (condition) {
    const [state, setState] = useState(0); // 条件内でHooks使用
  }
  return <div>Content</div>;
}

// ✅ 正しいコード
function MyComponent({ condition }) {
  const [state, setState] = useState(0); // 常にトップレベルで使用

  if (!condition) return null;

  return <div>Content</div>;
}

まとめ

Vue.js と React の技術的な違いは、表面的な構文の差異を超えて、設計哲学の根本的な違いに由来しています。

Vue.js の強み

  • プログレッシブな導入: 既存プロジェクトへの段階的適用
  • 直感的なテンプレート: HTML ライクな記述で学習コストが低い
  • 自動リアクティビティ: 依存関係の自動追跡による開発効率

React の強み

  • 一貫性のあるアーキテクチャ: 予測しやすい動作
  • 豊富なエコシステム: 多様なライブラリとツール
  • 関数型プログラミング: 明示的な状態管理によるバグの少ないコード

技術選択において重要なのは、プロジェクトの要件、チームのスキル、長期的な保守性を総合的に判断することです。どちらも優秀なツールであり、適切な場面で使用すれば高い生産性を実現できます。

両技術の本質的な違いを理解し、プロジェクトに最適な選択を行うことで、より効率的で保守性の高いアプリケーション開発を実現してください。

関連リンク