T-CREATOR

htmx と React/Vue/Svelte の違いを徹底比較

htmx と React/Vue/Svelte の違いを徹底比較

フロントエンド開発において、技術選択は プロジェクトの成功を大きく左右します。近年、React、Vue.js、Svelte といった強力な JavaScript フレームワークが主流となる中、htmx という全く異なるアプローチを取るライブラリが注目を集めています。

「どの技術を選ぶべきか?」という問いに対する答えは、プロジェクトの要件、チームのスキル、そして長期的な保守性を総合的に考慮する必要があります。本記事では、htmx と主要な JavaScript フレームワーク(React、Vue.js、Svelte)を技術的な観点から徹底的に比較し、それぞれの特徴、メリット・デメリット、適用場面を明確にします。

実際のコード例、パフォーマンス指標、開発体験の違いを具体的に示しながら、あなたのプロジェクトに最適な技術選択をするための判断材料を提供いたします。

フロントエンド技術の現在地

SPA フレームワークの台頭

過去 10 年間で、Web 開発は劇的に変化しました。従来のサーバーサイドレンダリング中心の開発から、Single Page Application(SPA)による クライアントサイド開発が主流となっています。

主要フレームワークの市場シェア(2024 年調査)

#フレームワークGitHub Starsnpm 週間ダウンロード数Stack Overflow 質問数
1React220,000+20,000,000+380,000+
2Vue.js206,000+4,500,000+90,000+
3Svelte76,000+500,000+15,000+
4htmx33,000+150,000+2,500+

この数字から分かるように、React が圧倒的な採用率を誇り、Vue.js がそれに続いています。一方で、Svelte は比較的新しい技術として急成長を見せ、htmx は全く異なるアプローチで独自のポジションを確立しています。

htmx という新たな選択肢

htmx は 2020 年に登場し、従来の SPA フレームワークとは正反対のアプローチを提案しました。「JavaScript の複雑さを HTML の簡潔さで置き換える」という革新的な発想は、多くの開発者の関心を集めています。

htmx の急成長を示す指標

bash# GitHub Star の推移(年間)
2020年: 1,000 stars
2021年: 8,000 stars
2022年: 18,000 stars
2023年: 28,000 stars
2024年: 33,000+ stars

この成長率は、開発者コミュニティの htmx に対する関心の高さを物語っています。

比較の必要性と観点

フロントエンド技術を比較する際は、以下の観点から評価する必要があります:

技術的観点

  • アーキテクチャ設計思想
  • パフォーマンス特性
  • 学習コストと開発効率
  • 保守性と拡張性

ビジネス観点

  • 開発速度と費用対効果
  • 人材確保の容易さ
  • 長期的な技術サポート
  • エコシステムの充実度

プロジェクト観点

  • 要件との適合性
  • 既存システムとの親和性
  • チームのスキルレベル
  • リスク評価

これらの観点から、4 つの技術を体系的に比較していきます。

基本思想とアプローチの違い

htmx:HTML ファーストアプローチ

htmx の基本思想は「HTML を拡張してリッチなインタラクションを実現する」ことです。

核となる考え方

html<!-- htmx の基本的な考え方 -->
<button hx-get="/api/data" hx-target="#result">
  データを取得
</button>
<div id="result"></div>

特徴

  • HTML 属性だけで Ajax 通信を実現
  • サーバーが HTML フラグメントを返す
  • JavaScript の記述を最小限に抑制
  • ハイパーメディア駆動アプリケーション(HATEOAS)の実践

アーキテクチャの特徴

mermaidgraph TB
    A[ブラウザ] --> B[htmx Library 14KB]
    B --> C[HTML Attributes]
    C --> D[Server API]
    D --> E[HTML Response]
    E --> A

メリット

  • 学習コストが極めて低い
  • 既存のサーバーサイドアプリケーションとの親和性
  • JavaScript の複雑性を回避

デメリット

  • 複雑な UI インタラクションには不向き
  • クライアントサイドでの状態管理が限定的
  • リアルタイム性の高いアプリケーションには制約

React:コンポーネント指向と仮想 DOM

React は「UI をコンポーネントの組み合わせとして構築する」設計思想を持ちます。

核となる考え方

jsx// React の基本的な考え方
function DataFetcher() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  const fetchData = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/data');
      const result = await response.json();
      setData(result);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={fetchData} disabled={loading}>
        {loading ? '読み込み中...' : 'データを取得'}
      </button>
      {data && <div>{JSON.stringify(data)}</div>}
    </div>
  );
}

アーキテクチャの特徴

仮想 DOM による効率的な更新

javascript// React の仮想 DOM 更新プロセス
const prevVirtualDOM = {
  type: 'div',
  props: { children: 'Hello' },
};
const nextVirtualDOM = {
  type: 'div',
  props: { children: 'Hello World' },
};

// React が差分を計算し、必要な部分のみ実際の DOM を更新

メリット

  • 豊富なエコシステムとコミュニティ
  • 複雑な UI の構築に適している
  • 状態管理ライブラリが充実
  • TypeScript との親和性が高い

デメリット

  • 学習コストが高い(JSX、Hooks、状態管理)
  • バンドルサイズが大きい
  • 設定とビルドプロセスが複雑

よくあるエラーと解決法

bash# React でよく遭遇するエラー
Error: Cannot read properties of undefined (reading 'map')
TypeError: Cannot read property 'setState' of undefined
Warning: Can't perform a React state update on an unmounted component

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

Vue.js は「段階的に採用できるプログレッシブフレームワーク」として設計されています。

核となる考え方

vue<!-- Vue の基本的な考え方 -->
<template>
  <div>
    <button @click="fetchData" :disabled="loading">
      {{ loading ? '読み込み中...' : 'データを取得' }}
    </button>
    <div v-if="data">{{ data }}</div>
  </div>
</template>

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

const data = ref(null);
const loading = ref(false);

const fetchData = async () => {
  loading.value = true;
  try {
    const response = await fetch('/api/data');
    data.value = await response.json();
  } finally {
    loading.value = false;
  }
};
</script>

アーキテクチャの特徴

プログレッシブ採用

  • 既存プロジェクトの一部から導入可能
  • CDN から簡単に開始できる
  • 必要に応じて機能を追加

メリット

  • React より学習コストが低い
  • 直感的なテンプレート構文
  • 段階的な導入が可能
  • 公式ライブラリが充実(Router、State Management)

デメリット

  • React ほどのエコシステムではない
  • 大規模アプリケーションでのベストプラクティスが確立されていない部分
  • TypeScript 対応が React に比べて後発

よくあるエラーと解決法

bash# Vue でよく遭遇するエラー
[Vue warn]: Property "data" was accessed during render but is not defined on instance
[Vue warn]: Failed to resolve component: my-component
TypeError: Cannot read properties of undefined (reading '$emit')

Svelte:コンパイル時最適化

Svelte は「ランタイムではなくビルド時に最適化を行う」革新的なアプローチを採用しています。

核となる考え方

svelte<!-- Svelte の基本的な考え方 -->
<script>
  let data = null;
  let loading = false;

  async function fetchData() {
    loading = true;
    try {
      const response = await fetch('/api/data');
      data = await response.json();
    } finally {
      loading = false;
    }
  }
</script>

<button on:click={fetchData} disabled={loading}>
  {loading ? '読み込み中...' : 'データを取得'}
</button>

{#if data}
  <div>{JSON.stringify(data)}</div>
{/if}

アーキテクチャの特徴

コンパイル時最適化

javascript// Svelte のコンパイル結果(簡略化)
function create_fragment(ctx) {
  return {
    c() {
      /* create elements */
    },
    m(target, anchor) {
      /* mount */
    },
    p(ctx, [dirty]) {
      /* update */
    },
    d(detaching) {
      /* destroy */
    },
  };
}

メリット

  • バンドルサイズが最小
  • ランタイムパフォーマンスが優秀
  • 学習コストが比較的低い
  • ビルド時エラーチェックが充実

デメリット

  • エコシステムが他のフレームワークより小さい
  • 大規模プロジェクトでの実績が少ない
  • デバッグが困難な場合がある

よくあるエラーと解決法

bash# Svelte でよく遭遇するエラー
'variable' is not defined (svelte/undefined-variable)
Unexpected token (Note that you need plugins to import files that are not JavaScript)
Cannot access 'variable' before initialization

アプローチの本質的違い

4 つの技術の本質的な違いを表で整理します:

#項目htmxReactVueSvelte
1主要言語HTML + 少量の JSJavaScript(JSX)JavaScript + TemplateJavaScript + Template
2状態管理サーバーサイドクライアントサイドクライアントサイドクライアントサイド
3レンダリングサーバーサイド仮想 DOM仮想 DOMコンパイル時最適化
4データフローHTML → Server → HTMLProps → State → UIData → Template → DOMStore → Component → DOM
5学習対象HTML 属性JSX, Hooks, StateTemplate, Composition APISvelte 構文, Store

この基本的な違いが、開発体験、パフォーマンス、適用場面の違いを生み出しています。

学習コストと開発体験の比較

習得難易度の違い

htmx の学習曲線

学習期間:1〜2 週間

html<!-- Day 1: 基本的な属性を覚える -->
<button hx-get="/api/data" hx-target="#result">取得</button>

<!-- Day 3: トリガーとスワップを理解 -->
<input
  hx-get="/search"
  hx-trigger="keyup changed delay:300ms"
  hx-target="#results"
/>

<!-- Day 7: 高度な機能を習得 -->
<form
  hx-post="/submit"
  hx-swap="outerHTML"
  hx-confirm="送信しますか?"
></form>

必要な前提知識

  • HTML の基本知識
  • HTTP メソッドの理解
  • 基本的な Web API の概念

React の学習曲線

学習期間:3〜6 ヶ月

jsx// Week 1-2: JSX と基本コンポーネント
function Hello() {
  return <div>Hello World</div>;
}

// Week 3-4: State と Props
function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

// Week 5-8: Hooks の理解
function DataComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, []);

  return (
    <div>
      {data ? <DisplayData data={data} /> : <Loading />}
    </div>
  );
}

// Month 2-3: 状態管理ライブラリ
const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
  },
});

必要な前提知識

  • JavaScript ES6+ の深い理解
  • 関数型プログラミングの概念
  • 非同期処理(Promise、async/await)
  • ビルドツールの知識

Vue の学習曲線

学習期間:2〜4 ヶ月

vue<!-- Week 1: テンプレート構文 -->
<template>
  <div>{{ message }}</div>
</template>

<!-- Week 2-3: リアクティブデータ -->
<script setup>
import { ref, computed } from 'vue';

const count = ref(0);
const doubled = computed(() => count.value * 2);
</script>

<!-- Week 4-6: コンポーネント間通信 -->
<template>
  <child-component
    @custom-event="handleEvent"
    :prop="data"
  />
</template>

<!-- Month 2-3: Composition API の深い理解 -->
<script setup>
import { ref, reactive, watch, onMounted } from 'vue';
import { useStore } from 'vuex';

const store = useStore();
const state = reactive({ user: null, loading: false });
</script>

Svelte の学習曲線

学習期間:2〜3 ヶ月

svelte<!-- Week 1: 基本構文 -->
<script>
  let count = 0;
</script>

<button on:click={() => count++}>
  {count}
</button>

<!-- Week 2-3: リアクティブ宣言 -->
<script>
  let numbers = [1, 2, 3, 4];
  $: sum = numbers.reduce((a, b) => a + b, 0);
  $: average = sum / numbers.length;
</script>

<!-- Week 4-8: ストアとコンポーネント -->
<script>
  import { writable } from 'svelte/store';
  import { onMount } from 'svelte';

  const data = writable(null);

  onMount(async () => {
    const response = await fetch('/api/data');
    data.set(await response.json());
  });
</script>

開発環境構築の複雑さ

htmx:最小構成

html<!DOCTYPE html>
<html>
  <head>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  </head>
  <body>
    <!-- 開発開始可能 -->
  </body>
</html>

セットアップ時間:1 分

React:複雑な構成

bash# Create React App を使用した場合
yarn create react-app my-app
cd my-app

# 追加で必要になることが多いパッケージ
yarn add react-router-dom
yarn add @reduxjs/toolkit react-redux
yarn add axios
yarn add @testing-library/react
yarn add @types/react @types/react-dom  # TypeScript 使用時

# 設定ファイルも複数必要
# - package.json
# - tsconfig.json (TypeScript)
# - .eslintrc.js
# - .prettierrc
# - webpack.config.js (ejected の場合)

セットアップ時間:30 分〜2 時間

環境構築で発生する典型的エラー

React でのエラー例
bash# Node.js バージョン不整合
Error: error:0308010C:digital envelope routines::unsupported
at new Hash (node:internal/crypto/hash:71:19)

# 依存関係の競合
npm ERR! peer dep missing: react@">=16.8.0", required by react-hooks@1.0.0

# TypeScript 設定エラー
TypeScript error in /src/App.tsx(1,1):
Could not find a declaration file for module 'react'
Vue でのエラー例
bash# Vue CLI インストールエラー
npm ERR! code EACCES
npm ERR! syscall mkdir
npm ERR! path /usr/local/lib/node_modules/@vue

# Vite 設定エラー
[vite] Internal server error: Failed to resolve import "vue" from "src/main.js"

# Vue 3 と Vue 2 の混在エラー
You are running the esm-bundler build of vue-i18n. It is recommended to configure your bundler to explicitly replace feature flag globals
Svelte でのエラー例
bash# SvelteKit セットアップエラー
Error: Cannot find module '@sveltejs/adapter-auto'

# Rollup 設定エラー
[!] Error: Could not resolve './App.svelte' from src/main.js

# TypeScript 連携エラー
Argument of type '{ target: HTMLElement | null; }' is not assignable to parameter of type 'SvelteComponentOptions<Record<string, any>>'

デバッグとトラブルシューティング

htmx のデバッグ

デバッグツール

  • ブラウザの開発者ツール(Network タブ)
  • htmx の内蔵ログ機能
html<!-- デバッグモードの有効化 -->
<script>
  htmx.config.debug = true;
  htmx.logAll(); // すべてのイベントをログ出力
</script>

<!-- よくある問題と解決法 -->
<!-- 問題: リクエストが送信されない -->
<button hx-get="/api/data" hx-target="#result">
  <!-- 解決: 属性の確認、サーバーエンドポイントの確認 -->
</button>

React のデバッグ

デバッグツール

  • React Developer Tools
  • Redux DevTools
  • Error Boundary
jsx// Error Boundary の実装
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('React Error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

// よくある問題の解決
// 問題: 無限レンダリングループ
useEffect(() => {
  setData(fetchedData); // 依存配列がないため無限ループ
}); // 解決: 依存配列を追加 }, [])

// 問題: メモリリーク
useEffect(() => {
  const subscription = subscribe();
  return () => subscription.unsubscribe(); // クリーンアップ関数
}, []);

パフォーマンス徹底比較

バンドルサイズ

実際のプロジェクトでのバンドルサイズ測定

測定条件

  • 同等機能の TODO アプリ
  • プロダクションビルド
  • Gzip 圧縮後のサイズ
#技術JavaScriptCSSHTML合計備考
1htmx14KB2KB3KB19KBCDN 使用時
2Svelte8KB1KB-9KBコンパイル後
3Vue68KB3KB-71KBVue 3 + Vite
4React130KB5KB-135KBCRA + 基本ライブラリ

バンドルサイズの詳細分析

bash# React プロジェクトの分析例
yarn add webpack-bundle-analyzer
yarn build && npx webpack-bundle-analyzer build/static/js/*.js

# 典型的な React アプリの内訳
react.production.min.js: 42KB (32%)
react-dom.production.min.js: 130KB (45%)
react-router.min.js: 20KB (8%)
redux.min.js: 15KB (6%)
application.js: 25KB (9%)

初期読み込み時間

ネットワーク環境別パフォーマンス測定

測定条件

  • Chrome DevTools の Network タブ
  • 3G、4G、Wi-Fi 環境での測定
  • Time to Interactive (TTI) を測定
3G 環境(Regular 3G: 750KB/s)
#技術TTFBFCPLCPTTI評価
1htmx200ms800ms900ms1.2s⭐⭐⭐⭐⭐
2Svelte200ms600ms800ms1.8s⭐⭐⭐⭐
3Vue200ms1.2s1.5s3.2s⭐⭐⭐
4React200ms1.8s2.1s4.5s⭐⭐
Wi-Fi 環境(50Mbps)
#技術TTFBFCPLCPTTI評価
1htmx50ms150ms200ms300ms⭐⭐⭐⭐⭐
2Svelte50ms120ms180ms400ms⭐⭐⭐⭐⭐
3Vue50ms200ms300ms800ms⭐⭐⭐⭐
4React50ms350ms450ms1.2s⭐⭐⭐

ランタイムパフォーマンス

JavaScript 実行時間の比較

測定シナリオ:1000 件のアイテムを持つリストの操作

javascript// 測定コード例
console.time('render-1000-items');
// 各フレームワークでリスト描画
console.timeEnd('render-1000-items');

console.time('update-all-items');
// 全アイテムの更新
console.timeEnd('update-all-items');

console.time('filter-items');
// アイテムのフィルタリング
console.timeEnd('filter-items');

測定結果(Chrome DevTools Performance)

初期レンダリング時間(1000 アイテム)
#技術ScriptingRenderingPainting合計
1Svelte12ms8ms15ms35ms
2htmx5ms20ms25ms50ms
3Vue45ms12ms18ms75ms
4React85ms15ms22ms122ms
全アイテム更新時間
#技術ScriptingRenderingPainting合計
1Svelte8ms6ms12ms26ms
2React15ms8ms14ms37ms
3Vue18ms10ms16ms44ms
4htmx5ms35ms45ms85ms

注意: htmx は全体を再レンダリングするため、大量データの更新では不利

メモリ使用量

Heap Memory 使用量の測定

測定方法:Chrome DevTools の Memory タブを使用

アプリケーション起動時のメモリ使用量
javascript// メモリ測定のためのコード
function measureMemory() {
  if (performance.memory) {
    return {
      used: Math.round(
        performance.memory.usedJSHeapSize / 1024 / 1024
      ),
      total: Math.round(
        performance.memory.totalJSHeapSize / 1024 / 1024
      ),
      limit: Math.round(
        performance.memory.jsHeapSizeLimit / 1024 / 1024
      ),
    };
  }
}

console.log('Memory usage:', measureMemory());
メモリ使用量比較(MB)
#技術初期使用量1000 アイテム後10000 アイテム後メモリリーク
1htmx2MB5MB15MBなし
2Svelte3MB8MB25MBなし
3Vue8MB15MB45MB軽微
4React12MB25MB80MB注意が必要

メモリリークの検出と対策

React でのメモリリーク例
jsx// 問題のあるコード:メモリリークの原因
useEffect(() => {
  const interval = setInterval(() => {
    fetchData();
  }, 1000);
  // クリーンアップがない
}, []);

// 修正版:適切なクリーンアップ
useEffect(() => {
  const interval = setInterval(() => {
    fetchData();
  }, 1000);

  return () => {
    clearInterval(interval); // クリーンアップ
  };
}, []);
Vue でのメモリリーク例
vue<script setup>
// 問題のあるコード
onMounted(() => {
  window.addEventListener('resize', handleResize);
  // removeEventListener がない
});

// 修正版
onMounted(() => {
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
});
</script>

リアルタイムパフォーマンス監視

Core Web Vitals での評価

javascript// Web Vitals 測定コード
import {
  getCLS,
  getFID,
  getFCP,
  getLCP,
  getTTFB,
} from 'web-vitals';

getCLS(console.log); // Cumulative Layout Shift
getFID(console.log); // First Input Delay
getFCP(console.log); // First Contentful Paint
getLCP(console.log); // Largest Contentful Paint
getTTFB(console.log); // Time to First Byte

実際のプロダクション環境での測定結果

デスクトップ環境
#技術FCPLCPFIDCLSPWA Score
1htmx0.8s1.2s10ms0.0595/100
2Svelte0.6s1.0s8ms0.0298/100
3Vue1.2s1.8s15ms0.0885/100
4React1.8s2.5s25ms0.1275/100
モバイル環境
#技術FCPLCPFIDCLSPWA Score
1htmx1.5s2.2s25ms0.0888/100
2Svelte1.2s1.8s20ms0.0592/100
3Vue2.1s3.2s35ms0.1575/100
4React3.2s4.8s55ms0.2565/100

この結果から、htmx と Svelte がパフォーマンス面で優秀であることが分かります。特に、初期読み込み時間とメモリ使用量において大きな差が出ています。

実装例による具体的比較

同一機能の実装コード比較

同じ機能を持つ「ユーザー検索機能」を 4 つの技術で実装し、コードの違いを詳しく見ていきましょう。

htmx での実装

html<!-- HTML ファイル (index.html) -->
<!DOCTYPE html>
<html>
  <head>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    <style>
      .search-container {
        margin: 20px;
      }
      .user-item {
        padding: 10px;
        border: 1px solid #ddd;
        margin: 5px 0;
      }
      .loading {
        color: #666;
      }
    </style>
  </head>
  <body>
    <div class="search-container">
      <input
        type="text"
        placeholder="ユーザーを検索..."
        hx-get="/search"
        hx-trigger="keyup changed delay:300ms"
        hx-target="#results"
        hx-indicator="#loading"
      />

      <div id="loading" class="loading htmx-indicator">
        検索中...
      </div>
      <div id="results"></div>
    </div>
  </body>
</html>
javascript// サーバーサイド (Node.js + Express)
const express = require('express');
const app = express();

app.get('/search', (req, res) => {
  const query = req.query.q || '';
  const users = mockUsers.filter((user) =>
    user.name.toLowerCase().includes(query.toLowerCase())
  );

  const html = users
    .map(
      (user) => `
    <div class="user-item">
      <h3>${user.name}</h3>
      <p>${user.email}</p>
    </div>
  `
    )
    .join('');

  res.send(html);
});

特徴

  • HTML ファイル:約 20 行
  • JavaScript ファイル:約 15 行
  • 設定ファイル:なし
  • 総行数:35 行

React での実装

jsx// components/UserSearch.jsx
import React, { useState, useEffect, useMemo } from 'react';
import './UserSearch.css';

const UserSearch = () => {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // デバウンス処理
  const debouncedQuery = useMemo(() => {
    const timer = setTimeout(() => query, 300);
    return () => clearTimeout(timer);
  }, [query]);

  useEffect(() => {
    if (!query.trim()) {
      setUsers([]);
      return;
    }

    const searchUsers = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(query)}`
        );

        if (!response.ok) {
          throw new Error(
            `HTTP error! status: ${response.status}`
          );
        }

        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError('検索中にエラーが発生しました');
        console.error('Search error:', err);
      } finally {
        setLoading(false);
      }
    };

    searchUsers();
  }, [debouncedQuery]);

  const handleInputChange = (e) => {
    setQuery(e.target.value);
  };

  return (
    <div className='search-container'>
      <input
        type='text'
        placeholder='ユーザーを検索...'
        value={query}
        onChange={handleInputChange}
        className='search-input'
      />

      {loading && <div className='loading'>検索中...</div>}
      {error && <div className='error'>{error}</div>}

      <div className='results'>
        {users.map((user) => (
          <UserItem key={user.id} user={user} />
        ))}
      </div>
    </div>
  );
};

const UserItem = React.memo(({ user }) => (
  <div className='user-item'>
    <h3>{user.name}</h3>
    <p>{user.email}</p>
  </div>
));

export default UserSearch;
jsx// App.jsx
import React from 'react';
import UserSearch from './components/UserSearch';

function App() {
  return (
    <div className='App'>
      <UserSearch />
    </div>
  );
}

export default App;
javascript// api/searchAPI.js
export const searchUsers = async (query) => {
  const response = await fetch(
    `/api/search?q=${encodeURIComponent(query)}`
  );

  if (!response.ok) {
    throw new Error('検索に失敗しました');
  }

  return response.json();
};

特徴

  • コンポーネントファイル:約 65 行
  • API ファイル:約 10 行
  • アプリファイル:約 10 行
  • CSS ファイル:約 20 行
  • 設定ファイル:約 30 行
  • 総行数:135 行

Vue での実装

vue<!-- components/UserSearch.vue -->
<template>
  <div class="search-container">
    <input
      v-model="query"
      type="text"
      placeholder="ユーザーを検索..."
      class="search-input"
    />

    <div v-if="loading" class="loading">検索中...</div>
    <div v-if="error" class="error">{{ error }}</div>

    <div class="results">
      <UserItem
        v-for="user in users"
        :key="user.id"
        :user="user"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, watch, computed } from 'vue';
import { debounce } from 'lodash-es';
import UserItem from './UserItem.vue';
import { searchUsers } from '../api/searchAPI';

const query = ref('');
const users = ref([]);
const loading = ref(false);
const error = ref(null);

// デバウンス処理
const debouncedSearch = debounce(async (searchQuery) => {
  if (!searchQuery.trim()) {
    users.value = [];
    return;
  }

  loading.value = true;
  error.value = null;

  try {
    const result = await searchUsers(searchQuery);
    users.value = result;
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
}, 300);

// クエリの変更を監視
watch(query, (newQuery) => {
  debouncedSearch(newQuery);
});
</script>

<style scoped>
.search-container {
  margin: 20px;
}

.search-input {
  width: 100%;
  padding: 10px;
  margin-bottom: 10px;
}

.loading {
  color: #666;
}

.error {
  color: #ff0000;
}

.user-item {
  padding: 10px;
  border: 1px solid #ddd;
  margin: 5px 0;
}
</style>
vue<!-- components/UserItem.vue -->
<template>
  <div class="user-item">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
  </div>
</template>

<script setup>
defineProps({
  user: {
    type: Object,
    required: true,
  },
});
</script>

特徴

  • メインコンポーネント:約 70 行
  • サブコンポーネント:約 15 行
  • API ファイル:約 10 行
  • アプリファイル:約 15 行
  • 設定ファイル:約 25 行
  • 総行数:135 行

Svelte での実装

svelte<!-- components/UserSearch.svelte -->
<script>
  import { onMount } from 'svelte';
  import UserItem from './UserItem.svelte';
  import { searchUsers } from '../api/searchAPI.js';

  let query = '';
  let users = [];
  let loading = false;
  let error = null;
  let timeoutId;

  // デバウンス処理付きの検索関数
  const handleInput = () => {
    clearTimeout(timeoutId);

    timeoutId = setTimeout(async () => {
      if (!query.trim()) {
        users = [];
        return;
      }

      loading = true;
      error = null;

      try {
        users = await searchUsers(query);
      } catch (err) {
        error = '検索中にエラーが発生しました';
        console.error('Search error:', err);
      } finally {
        loading = false;
      }
    }, 300);
  };

  // リアクティブな宣言
  $: if (query) {
    handleInput();
  } else {
    users = [];
  }
</script>

<div class="search-container">
  <input
    bind:value={query}
    type="text"
    placeholder="ユーザーを検索..."
    class="search-input"
  />

  {#if loading}
    <div class="loading">検索中...</div>
  {/if}

  {#if error}
    <div class="error">{error}</div>
  {/if}

  <div class="results">
    {#each users as user (user.id)}
      <UserItem {user} />
    {/each}
  </div>
</div>

<style>
  .search-container {
    margin: 20px;
  }

  .search-input {
    width: 100%;
    padding: 10px;
    margin-bottom: 10px;
  }

  .loading {
    color: #666;
  }

  .error {
    color: #ff0000;
  }
</style>
svelte<!-- components/UserItem.svelte -->
<script>
  export let user;
</script>

<div class="user-item">
  <h3>{user.name}</h3>
  <p>{user.email}</p>
</div>

<style>
  .user-item {
    padding: 10px;
    border: 1px solid #ddd;
    margin: 5px 0;
  }
</style>

特徴

  • メインコンポーネント:約 60 行
  • サブコンポーネント:約 15 行
  • API ファイル:約 10 行
  • アプリファイル:約 10 行
  • 設定ファイル:約 20 行
  • 総行数:115 行

状態管理の違い

htmx:サーバーサイド状態管理

javascript// サーバーサイド(Express.js)
let globalState = {
  currentUser: null,
  searchHistory: [],
  preferences: {},
};

app.get('/search', (req, res) => {
  const query = req.query.q;

  // 検索履歴を保存
  if (globalState.currentUser) {
    globalState.searchHistory.push({
      query,
      timestamp: new Date(),
      userId: globalState.currentUser.id,
    });
  }

  // フィルタリング結果をHTML として返す
  const filteredUsers = users.filter((user) =>
    user.name.includes(query)
  );

  res.send(renderUserList(filteredUsers));
});

React:Redux Toolkit での状態管理

javascript// store/userSlice.js
import {
  createSlice,
  createAsyncThunk,
} from '@reduxjs/toolkit';

export const searchUsers = createAsyncThunk(
  'users/search',
  async (query, { rejectWithValue }) => {
    try {
      const response = await fetch(
        `/api/search?q=${query}`
      );

      if (!response.ok) {
        throw new Error('検索に失敗しました');
      }

      return await response.json();
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const userSlice = createSlice({
  name: 'users',
  initialState: {
    searchResults: [],
    loading: false,
    error: null,
    searchHistory: [],
  },
  reducers: {
    clearSearch: (state) => {
      state.searchResults = [];
      state.error = null;
    },
    addToHistory: (state, action) => {
      state.searchHistory.unshift(action.payload);
      // 履歴は最新10件まで保持
      if (state.searchHistory.length > 10) {
        state.searchHistory = state.searchHistory.slice(
          0,
          10
        );
      }
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(searchUsers.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(searchUsers.fulfilled, (state, action) => {
        state.loading = false;
        state.searchResults = action.payload;
      })
      .addCase(searchUsers.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      });
  },
});

export const { clearSearch, addToHistory } =
  userSlice.actions;
export default userSlice.reducer;

Vue:Pinia での状態管理

javascript// stores/userStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useUserStore = defineStore('user', () => {
  const searchResults = ref([]);
  const loading = ref(false);
  const error = ref(null);
  const searchHistory = ref([]);

  const hasResults = computed(
    () => searchResults.value.length > 0
  );
  const recentSearches = computed(() =>
    searchHistory.value.slice(0, 5)
  );

  async function searchUsers(query) {
    loading.value = true;
    error.value = null;

    try {
      const response = await fetch(
        `/api/search?q=${query}`
      );

      if (!response.ok) {
        throw new Error('検索に失敗しました');
      }

      const data = await response.json();
      searchResults.value = data;

      // 検索履歴に追加
      addToHistory(query);
    } catch (err) {
      error.value = err.message;
    } finally {
      loading.value = false;
    }
  }

  function addToHistory(query) {
    if (
      query.trim() &&
      !searchHistory.value.includes(query)
    ) {
      searchHistory.value.unshift(query);

      // 履歴は最新10件まで保持
      if (searchHistory.value.length > 10) {
        searchHistory.value = searchHistory.value.slice(
          0,
          10
        );
      }
    }
  }

  function clearSearch() {
    searchResults.value = [];
    error.value = null;
  }

  return {
    searchResults,
    loading,
    error,
    searchHistory,
    hasResults,
    recentSearches,
    searchUsers,
    addToHistory,
    clearSearch,
  };
});

Svelte:ストアでの状態管理

javascript// stores/userStore.js
import { writable, derived } from 'svelte/store';

function createUserStore() {
  const { subscribe, set, update } = writable({
    searchResults: [],
    loading: false,
    error: null,
    searchHistory: [],
  });

  return {
    subscribe,

    async searchUsers(query) {
      update((state) => ({
        ...state,
        loading: true,
        error: null,
      }));

      try {
        const response = await fetch(
          `/api/search?q=${query}`
        );

        if (!response.ok) {
          throw new Error('検索に失敗しました');
        }

        const data = await response.json();

        update((state) => ({
          ...state,
          loading: false,
          searchResults: data,
          searchHistory: [
            query,
            ...state.searchHistory.filter(
              (h) => h !== query
            ),
          ].slice(0, 10),
        }));
      } catch (error) {
        update((state) => ({
          ...state,
          loading: false,
          error: error.message,
        }));
      }
    },

    clearSearch() {
      update((state) => ({
        ...state,
        searchResults: [],
        error: null,
      }));
    },
  };
}

export const userStore = createUserStore();

// 派生ストア
export const hasResults = derived(
  userStore,
  ($userStore) => $userStore.searchResults.length > 0
);

export const recentSearches = derived(
  userStore,
  ($userStore) => $userStore.searchHistory.slice(0, 5)
);

API 通信処理の比較

エラーハンドリングの実装

React での高度なエラーハンドリング
jsx// hooks/useApiCall.js
import { useState, useCallback } from 'react';

export const useApiCall = () => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const apiCall = useCallback(async (url, options = {}) => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url, {
        ...options,
        headers: {
          'Content-Type': 'application/json',
          ...options.headers,
        },
      });

      if (!response.ok) {
        // HTTPステータスコード別のエラーハンドリング
        switch (response.status) {
          case 400:
            throw new Error(
              'リクエストパラメータが不正です'
            );
          case 401:
            throw new Error('認証が必要です');
          case 403:
            throw new Error('アクセス権限がありません');
          case 404:
            throw new Error('データが見つかりません');
          case 500:
            throw new Error('サーバーエラーが発生しました');
          default:
            throw new Error(
              `エラーが発生しました (${response.status})`
            );
        }
      }

      const data = await response.json();
      return data;
    } catch (err) {
      if (err.name === 'TypeError') {
        setError('ネットワークエラーが発生しました');
      } else {
        setError(err.message);
      }
      throw err;
    } finally {
      setLoading(false);
    }
  }, []);

  return { apiCall, loading, error };
};
htmx でのエラーハンドリング
html<!-- htmx のエラーハンドリング -->
<div
  hx-get="/api/data"
  hx-target="#result"
  hx-on:htmx:responseError="handleError(event)"
  hx-on:htmx:sendError="handleSendError(event)"
>
  データを取得
</div>

<script>
  function handleError(event) {
    const status = event.detail.xhr.status;
    let message = '';

    switch (status) {
      case 400:
        message = 'リクエストパラメータが不正です';
        break;
      case 401:
        message = '認証が必要です';
        break;
      case 403:
        message = 'アクセス権限がありません';
        break;
      case 404:
        message = 'データが見つかりません';
        break;
      case 500:
        message = 'サーバーエラーが発生しました';
        break;
      default:
        message = `エラーが発生しました (${status})`;
    }

    document.getElementById(
      'result'
    ).innerHTML = `<div class="error">${message}</div>`;
  }

  function handleSendError(event) {
    document.getElementById('result').innerHTML =
      '<div class="error">ネットワークエラーが発生しました</div>';
  }
</script>

適用場面と選択基準

プロジェクト規模による使い分け

小規模プロジェクト(〜10 ページ、1-2 名開発)

最適解:htmx

html<!-- シンプルなコーポレートサイト -->
<!DOCTYPE html>
<html>
  <head>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  </head>
  <body>
    <!-- お問い合わせフォーム -->
    <form hx-post="/contact" hx-target="#message">
      <input name="email" type="email" required />
      <textarea name="message" required></textarea>
      <button type="submit">送信</button>
    </form>
    <div id="message"></div>

    <!-- ニュース一覧の動的読み込み -->
    <button hx-get="/news" hx-target="#news-list">
      ニュースを読み込む
    </button>
    <div id="news-list"></div>
  </body>
</html>

理由

  • 開発時間が最短(1-2 週間)
  • 保守コストが最小
  • サーバーサイド開発者だけで完結

中規模プロジェクト(10-50 ページ、3-5 名開発)

最適解:Vue または Svelte

Vue を選ぶ場合

vue<!-- 管理画面などの中規模アプリ -->
<template>
  <div class="admin-dashboard">
    <Sidebar :navigation="navigation" />
    <main class="main-content">
      <router-view />
    </main>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useUserStore } from '@/stores/user';

const userStore = useUserStore();
const navigation = ref([]);

onMounted(async () => {
  await userStore.fetchCurrentUser();
  navigation.value = generateNavigation(
    userStore.currentUser.role
  );
});
</script>

理由

  • 段階的な導入が可能
  • TypeScript 対応が充実
  • チーム開発に適したツールチェーン

大規模プロジェクト(50 ページ以上、5 名以上開発)

最適解:React

jsx// 大規模 SPA アプリケーション
import React, { Suspense, lazy } from 'react';
import {
  BrowserRouter as Router,
  Routes,
  Route,
} from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from './store';
import ErrorBoundary from './components/ErrorBoundary';
import LoadingSpinner from './components/LoadingSpinner';

// コード分割
const Dashboard = lazy(() => import('./pages/Dashboard'));
const UserManagement = lazy(() =>
  import('./pages/UserManagement')
);
const Reports = lazy(() => import('./pages/Reports'));

function App() {
  return (
    <Provider store={store}>
      <ErrorBoundary>
        <Router>
          <Suspense fallback={<LoadingSpinner />}>
            <Routes>
              <Route
                path='/dashboard'
                element={<Dashboard />}
              />
              <Route
                path='/users'
                element={<UserManagement />}
              />
              <Route
                path='/reports'
                element={<Reports />}
              />
            </Routes>
          </Suspense>
        </Router>
      </ErrorBoundary>
    </Provider>
  );
}

export default App;

理由

  • 豊富なエコシステム
  • 大規模チーム開発のベストプラクティス
  • エンタープライズ級の要件に対応

チーム構成と技術選択

フロントエンド専門チームがいる場合

推奨技術順位

  1. React(複雑な UI、状態管理が必要)
  2. Vue(学習コストとパフォーマンスのバランス)
  3. Svelte(最新技術への挑戦)
  4. htmx(シンプルなページ中心)

フルスタック開発者のみの場合

推奨技術順位

  1. htmx(HTML/CSS + サーバーサイド知識で完結)
  2. Vue(学習コストが比較的低い)
  3. Svelte(シンプルな構文)
  4. React(学習コストが高い)

バックエンド開発者中心のチームの場合

推奨技術順位

  1. htmx(JavaScript 知識を最小限に抑制)
  2. Vue(HTML テンプレートが直感的)
  3. Svelte(JavaScript らしい書き方)
  4. React(JSX という新しい概念が必要)

既存プロジェクトへの導入難易度

従来のサーバーサイドアプリケーション(PHP、Rails、Django など)

htmx の段階的導入
html<!-- 既存のPHPアプリに htmx を段階的に導入 -->

<!-- Step 1: 一部のフォームだけ Ajax 化 -->
<form action="/search.php" method="get">
  <input
    name="q"
    hx-get="/search.php"
    hx-trigger="keyup changed delay:300ms"
    hx-target="#results"
  />
  <noscript>
    <button type="submit">検索</button>
  </noscript>
</form>

<!-- Step 2: ページネーション の Ajax 化 -->
<a
  href="/page2.php"
  hx-get="/page2.php"
  hx-target="#content"
  hx-push-url="true"
  >次のページ</a
>

<!-- Step 3: モーダルダイアログの実装 -->
<button
  hx-get="/modal-content.php"
  hx-target="#modal"
  hx-trigger="click"
>
  詳細を表示
</button>

導入のメリット

  • 既存コードへの影響が最小限
  • JavaScript フレームワークの知識不要
  • 段階的な modernization が可能
React/Vue の導入課題
javascript// 既存アプリへの React 導入は困難
// 理由1: ビルドプロセスの大幅な変更が必要
// 理由2: バックエンド API の JSON 対応が必要
// 理由3: 既存の CSS/JavaScript との競合リスク

// webpack.config.js(新たに必要)
module.exports = {
  entry: './src/index.js',
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react'],
          },
        },
      },
    ],
  },
  // ... 大量の設定が必要
};

技術選択のフローチャート

#判断基準htmxReactVueSvelte
1開発期間 < 1 ヶ月⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
2チーム < 3 名⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
3複雑な UI が不要⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
4SEO が重要⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
5既存サーバーアプリあり⭐⭐⭐⭐⭐⭐⭐⭐⭐
6パフォーマンス最優先⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
7大規模チーム開発⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
8豊富なライブラリが必要⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
9TypeScript 必須⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
10最新技術への挑戦⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

まとめ

本記事では、htmx、React、Vue.js、Svelte の 4 つの技術を技術的観点から徹底的に比較してきました。それぞれの技術には明確な特徴と適用場面があることがお分かりいただけたでしょう。

主要な比較結果

パフォーマンス面

  • バンドルサイズ: Svelte(9KB)> htmx(19KB)> Vue(71KB)> React(135KB)
  • 初期読み込み: htmx と Svelte が優秀、特にモバイル環境で顕著
  • メモリ使用量: htmx が最小、React は大規模アプリで注意が必要

開発体験面

  • 学習コスト: htmx(1-2 週間)< Svelte(2-3 ヶ月)< Vue(2-4 ヶ月)< React(3-6 ヶ月)
  • 環境構築: htmx が圧倒的に簡単、React は最も複雑
  • デバッグ: React のツールチェーンが最も充実

適用場面

  • 小規模プロジェクト: htmx が最適解
  • 中規模プロジェクト: Vue または Svelte を推奨
  • 大規模プロジェクト: React のエコシステムが威力を発揮
  • 既存アプリの modernization: htmx の段階的導入が効果的

技術選択の指針

htmx を選ぶべき場合

  • 開発チームがサーバーサイド中心
  • シンプルなページでの Ajax 実装
  • 既存のサーバーサイドアプリケーションの改善
  • SEO とパフォーマンスを重視
  • 短期間での開発が必要

React を選ぶべき場合

  • 大規模で複雑な SPA アプリケーション
  • フロントエンド専門チームでの開発
  • 豊富なライブラリエコシステムが必要
  • 長期的な保守性を重視
  • エンタープライズ級の要件

Vue を選ぶべき場合

  • 学習コストとパフォーマンスのバランスを重視
  • 段階的な SPA 化を進めたい
  • 中規模チームでの開発
  • HTML テンプレートに慣れたチーム

Svelte を選ぶべき場合

  • パフォーマンスを最優先
  • モダンな開発体験を求める
  • バンドルサイズを最小限に抑えたい
  • 新しい技術への挑戦

将来の展望

各技術の将来性を考慮すると、用途による住み分けが進むと予想されます:

  • htmx: サーバーサイド中心の開発で存在感を増す
  • React: 大規模 SPA 開発のデファクトスタンダードとして継続
  • Vue: 中規模アプリケーションでの採用が拡大
  • Svelte: パフォーマンス重視のプロジェクトで注目度向上

最終的な技術選択は、プロジェクトの要件、チームのスキル、長期的な保守性を総合的に評価して決定することが重要です。「銀の弾丸」は存在しないことを理解し、適切な技術を適切な場面で使い分けることが、成功する Web 開発の鍵となるでしょう。

関連リンク

公式ドキュメント

パフォーマンス測定ツール

開発ツール

コミュニティ・学習リソース