T-CREATOR

htmx と Stimulus/Turbo の責務分担を実験で検証:同一要件 3 実装レビュー

htmx と Stimulus/Turbo の責務分担を実験で検証:同一要件 3 実装レビュー

最近、フロントエンド開発の現場では「どこまで JavaScript を書くべきか」という議論が盛んです。React や Vue.js といった SPA フレームワークが主流になる一方で、htmx や Hotwire(Turbo・Stimulus)のような「HTML 中心」のアプローチが再注目されていますね。

実際、同じ機能要件を実装するとき、htmx・Stimulus・Turbo のどれを選ぶべきか迷うことはありませんか?それぞれの公式ドキュメントには「シンプル」「直感的」といった言葉が並びますが、具体的にどう使い分ければ良いのかは現場でよく悩むポイントです。

この記事では、「動的なタブ切り替え機能」という同一要件を、htmx・Stimulus・Turbo の 3 つの技術スタックで実装し、それぞれのコード量・責務の分担・保守性を徹底比較します。実験を通じて、各技術の得意領域と使い分けの指針を明らかにしていきましょう。

背景

HTML 中心アーキテクチャの再評価

従来の Web 開発では、サーバーサイドでレンダリングした HTML を返し、JavaScript は最小限に抑えるアプローチが主流でした。しかし、リッチな UI が求められるようになると、React や Vue.js といった SPA フレームワークが台頭します。

一方で、「すべてを JavaScript で書く必要があるのか?」という疑問も生まれました。バンドルサイズの肥大化、初期表示の遅延、SEO の課題など、SPA 特有の問題が顕在化したからです。

そこで登場したのが、htmx や Hotwire のような「HTML 中心」のライブラリです。これらは、サーバーから HTML 断片を取得して DOM を更新するという原則に立ち返り、JavaScript を必要最小限に抑えつつ、モダンな UX を実現しようとしています。

以下の図は、従来の SPA と HTML 中心アプローチの違いを示しています。

mermaidflowchart TB
    subgraph spa["SPA アプローチ"]
        spa_browser["ブラウザ"] -->|"API リクエスト<br/>(JSON)"| spa_server["サーバー"]
        spa_server -->|"JSON データ"| spa_browser
        spa_browser -->|"JavaScript で<br/>DOM 生成"| spa_dom["DOM 更新"]
    end

    subgraph html["HTML 中心アプローチ"]
        html_browser["ブラウザ"] -->|"HTTP リクエスト"| html_server["サーバー"]
        html_server -->|"HTML 断片"| html_browser
        html_browser -->|"直接 DOM 挿入"| html_dom["DOM 更新"]
    end

SPA では JSON をやり取りして JavaScript で DOM を構築しますが、HTML 中心アプローチではサーバーが直接 HTML を返すため、クライアント側の処理が大幅に削減されるのです。

htmx・Stimulus・Turbo の位置づけ

では、htmx・Stimulus・Turbo はそれぞれどのような役割を担うのでしょうか。

htmx は、HTML 属性だけで Ajax リクエストや DOM 更新を実現するライブラリです。hx-gethx-post といった属性をマークアップに追加するだけで、サーバーからの HTML 断片を取得して指定要素を更新できます。JavaScript をほとんど書かずに、動的な UI を構築できる点が特徴ですね。

Stimulus は、Ruby on Rails の生みの親である DHH(David Heinemeier Hansson)が開発した JavaScript フレームワークです。既存の HTML に「コントローラー」という単位で JavaScript の振る舞いを追加し、DOM 操作やイベントハンドリングを整理します。React や Vue.js のような「JavaScript が HTML を管理する」のではなく、「HTML が JavaScript を呼び出す」という思想が根底にあります。

Turbo は、同じく DHH が開発した Hotwire の中核技術で、ページ全体のリロードなしに HTML を部分的に更新する仕組みを提供します。Turbo Drive によるページ遷移の高速化、Turbo Frames による部分更新、Turbo Streams によるリアルタイム更新が主な機能です。

次の表は、3 つの技術の特徴を整理したものです。

#技術主な役割JavaScript 記述量学習コスト
1htmxHTML 属性だけで Ajax・DOM 更新最小
2StimulusDOM 操作の整理・再利用
3Turboページ遷移・部分更新・リアルタイム最小〜中中〜高

それぞれが異なる責務を持つため、「どれが優れているか」ではなく、「どう組み合わせるか」が重要になります。

なぜ「同一要件の比較」が必要か

公式ドキュメントやサンプルコードを見ても、それぞれの技術は独立した例で紹介されるため、実際の使い分けが分かりにくいのが現状です。

「htmx はシンプル」と言われても、Stimulus でも簡潔に書けるのでは?Turbo Frames を使えば htmx と同じことができるのでは?といった疑問が生まれます。

そこで、全く同じ機能要件を 3 つの技術で実装し、コード量・構造・保守性を横並びで比較することで、各技術の得意領域と制約を明確にできるのです。この実験的アプローチによって、「この機能なら htmx」「あの場合は Stimulus」といった実践的な判断基準が見えてきますね。

課題

技術選定の判断基準が不明瞭

実際の開発現場では、「なんとなく React を選ぶ」「Rails だから Hotwire を使う」といった消極的な選択が多いのが実情です。各技術の公式ドキュメントは充実していますが、「どのシーンでどれを使うべきか」という比較情報は限られています。

特に、htmx と Hotwire は思想が似ているため、「どちらを選べば良いのか」「両方組み合わせるべきなのか」という判断が難しいのです。

コード量と保守性のトレードオフ

HTML 属性だけで完結する htmx は、一見すると最もシンプルです。しかし、機能が複雑になったとき、HTML に大量の属性が並ぶと可読性が低下する懸念があります。

一方、Stimulus は JavaScript でロジックを整理できますが、その分コード量が増えますね。Turbo も設定が複雑になりがちで、学習コストが高いという声もあります。

では、実際にはどの程度の差があるのでしょうか?定量的なデータがなければ、判断できません。

責務の境界線が曖昧

htmx・Stimulus・Turbo は、それぞれ異なる責務を持つはずです。しかし、機能が重複する部分も多く、「この処理はどれに任せるべきか」という境界線が曖昧になりがちです。

例えば、タブ切り替えのような UI 操作は、htmx の hx-target でも、Stimulus のコントローラーでも、Turbo Frames でも実装できてしまいます。この重複が、かえって混乱を招いているのです。

次の図は、3 つの技術の責務範囲を示しています。

mermaidflowchart LR
    subgraph htmx_area["htmx の責務"]
        htmx_req["HTTP リクエスト"] --> htmx_dom["DOM 更新"]
    end

    subgraph stimulus_area["Stimulus の責務"]
        stimulus_event["イベント処理"] --> stimulus_logic["ロジック実行"] --> stimulus_dom["DOM 操作"]
    end

    subgraph turbo_area["Turbo の責務"]
        turbo_nav["ページ遷移"] --> turbo_frame["部分更新"] --> turbo_stream["リアルタイム"]
    end

    overlap["重複領域<br/>(DOM 更新・部分更新)"]

    htmx_dom -.->|"重複"| overlap
    stimulus_dom -.->|"重複"| overlap
    turbo_frame -.->|"重複"| overlap

この図のように、DOM 更新や部分更新といった領域では責務が重複しており、どれを選ぶべきか明確な指針が必要です。

解決策

実験的アプローチによる定量比較

今回の解決策は、「動的なタブ切り替え機能」という同一要件を、htmx・Stimulus・Turbo の 3 つで実装し、コード量・構造・保守性を定量的に比較することです。

具体的には、以下の指標で評価します。

#評価指標測定方法
1コード行数HTML・JavaScript・サーバーサイドの合計行数
2責務の分離度HTML とロジックがどの程度分離されているか
3拡張性新しいタブを追加するときの修正箇所
4デバッグのしやすさエラー発生時の追跡難易度
5学習コスト初見のエンジニアが理解するまでの時間(主観評価を含む)

これにより、「シンプル」「直感的」といった曖昧な評価ではなく、客観的なデータに基づく判断が可能になります。

共通要件の設定

比較を公平にするため、以下の要件を満たすタブ切り替え機能を実装します。

mermaidstateDiagram-v2
    [*] --> TabInit: ページ読み込み
    TabInit --> TabA: タブ A 表示
    TabA --> TabB: タブ B クリック
    TabB --> TabC: タブ C クリック
    TabC --> TabA: タブ A クリック
    TabA --> [*]: ページ遷移

    note right of TabInit
        初期状態ではタブ A が選択
        コンテンツはサーバーから取得
    end note

    note right of TabB
        クリック時にサーバーへリクエスト
        コンテンツを動的に差し替え
    end note

この状態遷移図のように、各タブをクリックすると対応するコンテンツがサーバーから取得され、表示が切り替わる仕組みです。

具体的な要件は次のとおりです。

  1. 3 つのタブ(タブ A・タブ B・タブ C)が存在する
  2. クリックでコンテンツを切り替え、サーバーから HTML を取得して表示する
  3. 選択中のタブを視覚的に強調する(active クラスの付与)
  4. 初期表示ではタブ A を選択状態にする
  5. ブラウザの戻るボタンに対応する(可能な範囲で)

この要件を満たすコードを、htmx・Stimulus・Turbo それぞれで実装していきます。

評価方法の明確化

各実装について、以下の観点で評価します。

コード量:HTML・JavaScript・サーバーサイドのコードをそれぞれカウントし、総行数を比較します。空行やコメントは除外し、純粋な実装コードのみを対象とします。

責務の分離度:HTML がどこまで自己完結しているか、ロジックがどこに記述されているかを評価します。理想的には、HTML はマークアップに集中し、ロジックは JavaScript やサーバーサイドに分離されている状態ですね。

拡張性:新しいタブ D を追加する場合、どこを修正する必要があるかを確認します。修正箇所が少なく、影響範囲が限定されているほど拡張性が高いと評価します。

デバッグのしやすさ:エラーが発生したとき、原因箇所を特定しやすいかを検証します。ブラウザの開発者ツールでエラーメッセージが明確に表示されるか、スタックトレースが追いやすいかがポイントです。

学習コスト:初めてそのコードを見たエンジニアが、仕組みを理解するまでの時間を主観的に評価します。公式ドキュメントの充実度や、コミュニティの情報量も考慮しますね。

これらの指標を総合的に判断することで、各技術の適用領域が見えてきます。

具体例

ここからは、実際に htmx・Stimulus・Turbo でタブ切り替え機能を実装し、コードと評価結果を見ていきましょう。

共通の HTML 構造

まず、3 つの実装で共通する HTML 構造を確認します。

html<!-- 共通のタブ UI 構造 -->
<div class="tabs-container">
  <div class="tab-buttons">
    <button class="tab-button active" data-tab="a">
      タブ A
    </button>
    <button class="tab-button" data-tab="b">タブ B</button>
    <button class="tab-button" data-tab="c">タブ C</button>
  </div>
  <div class="tab-content" id="content-area">
    <!-- ここにコンテンツが動的に表示される -->
  </div>
</div>

この構造は変えずに、各技術で動的な挙動を追加していきます。

スタイルも共通で、選択中のタブには active クラスが付与され、背景色が変わる仕様です。

css/* 共通スタイル */
.tab-button {
  padding: 10px 20px;
  border: 1px solid #ddd;
  background: #f9f9f9;
  cursor: pointer;
}

.tab-button.active {
  background: #007bff;
  color: white;
  border-color: #007bff;
}

.tab-content {
  padding: 20px;
  border: 1px solid #ddd;
  margin-top: 10px;
}

では、それぞれの実装を見ていきましょう。

実装 1:htmx による実装

htmx は、HTML 属性だけで Ajax リクエストと DOM 更新を実現します。JavaScript を一切書かずに、タブ切り替えを実装できますね。

以下は、htmx を使った HTML の例です。

html<!-- htmx によるタブ切り替え実装 -->
<div class="tabs-container">
  <div class="tab-buttons">
    <!-- hx-get でサーバーにリクエスト、hx-target で更新先を指定 -->
    <button
      class="tab-button active"
      hx-get="/tabs/a"
      hx-target="#content-area"
      hx-swap="innerHTML"
    >
      タブ A
    </button>
    <button
      class="tab-button"
      hx-get="/tabs/b"
      hx-target="#content-area"
      hx-swap="innerHTML"
    >
      タブ B
    </button>
    <button
      class="tab-button"
      hx-get="/tabs/c"
      hx-target="#content-area"
      hx-swap="innerHTML"
    >
      タブ C
    </button>
  </div>
  <div class="tab-content" id="content-area">
    <!-- 初期コンテンツ:タブ A の内容 -->
    <h2>タブ A のコンテンツ</h2>
    <p>これはタブ A の内容です。</p>
  </div>
</div>

<!-- htmx ライブラリの読み込み -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>

このコードでは、hx-get 属性でリクエスト先の URL を指定し、hx-target で更新する要素を指定しています。クリックすると、サーバーが返す HTML が #content-area に挿入される仕組みです。

ただし、選択中のタブを強調する active クラスの切り替えには、少しだけ JavaScript が必要になります。

javascript// active クラスを切り替える処理
document.addEventListener(
  'htmx:afterRequest',
  function (event) {
    // すべてのボタンから active クラスを削除
    document
      .querySelectorAll('.tab-button')
      .forEach((button) => {
        button.classList.remove('active');
      });

    // クリックされたボタンに active クラスを追加
    event.detail.elt.classList.add('active');
  }
);

htmx は htmx:afterRequest というイベントを発火するため、それをリスニングして active クラスを切り替えます。

サーバーサイドでは、各タブに対応する HTML 断片を返すエンドポイントを用意します。

javascript// Node.js + Express によるサーバー実装例
const express = require('express');
const app = express();

// タブ A のコンテンツを返す
app.get('/tabs/a', (req, res) => {
  res.send(`
    <h2>タブ A のコンテンツ</h2>
    <p>これはタブ A の内容です。</p>
  `);
});

// タブ B のコンテンツを返す
app.get('/tabs/b', (req, res) => {
  res.send(`
    <h2>タブ B のコンテンツ</h2>
    <p>これはタブ B の内容です。</p>
  `);
});

// タブ C のコンテンツを返す
app.get('/tabs/c', (req, res) => {
  res.send(`
    <h2>タブ C のコンテンツ</h2>
    <p>これはタブ C の内容です。</p>
  `);
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

このように、htmx では HTML とサーバーサイドのコードが主体となり、JavaScript は最小限で済みます。

コード量の集計(htmx)

#ファイル種類行数
1HTML25
2JavaScript(UI)7
3JavaScript(API)20
4合計52

htmx の実装は、全体で 52 行と非常にコンパクトです。HTML に属性を追加するだけで動的な UI を実現できる点が大きな特徴ですね。

実装 2:Stimulus による実装

Stimulus は、HTML に data-controllerdata-action といった属性を追加し、JavaScript でコントローラーを定義します。htmx と比べると、ロジックが JavaScript 側にまとまるため、保守性が高まります。

まず、HTML に Stimulus の属性を追加します。

html<!-- Stimulus によるタブ切り替え実装 -->
<div class="tabs-container" data-controller="tabs">
  <div class="tab-buttons">
    <!-- data-action でクリックイベントを定義 -->
    <button
      class="tab-button active"
      data-action="click->tabs#switch"
      data-tabs-target="button"
      data-tab="a"
    >
      タブ A
    </button>
    <button
      class="tab-button"
      data-action="click->tabs#switch"
      data-tabs-target="button"
      data-tab="b"
    >
      タブ B
    </button>
    <button
      class="tab-button"
      data-action="click->tabs#switch"
      data-tabs-target="button"
      data-tab="c"
    >
      タブ C
    </button>
  </div>

  <div class="tab-content" data-tabs-target="content">
    <!-- 初期コンテンツ -->
    <h2>タブ A のコンテンツ</h2>
    <p>これはタブ A の内容です。</p>
  </div>
</div>

<!-- Stimulus ライブラリの読み込み -->
<script type="module">
  import { Application } from 'https://unpkg.com/@hotwired/stimulus/dist/stimulus.js';
  window.Stimulus = Application.start();
</script>

data-controller="tabs" で、この要素が tabs コントローラーの管轄であることを宣言します。data-action="click->tabs#switch" は、クリック時に tabs コントローラーの switch メソッドを呼び出す指定です。

次に、Stimulus のコントローラーを定義します。

javascript// tabs_controller.js: Stimulus コントローラーの実装
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  // targets で参照する要素を定義
  static targets = ['button', 'content'];

  // クリック時に呼ばれるメソッド
  async switch(event) {
    // クリックされたボタンを取得
    const clickedButton = event.currentTarget;
    const tab = clickedButton.dataset.tab;

    // active クラスを更新
    this.updateActiveButton(clickedButton);

    // サーバーからコンテンツを取得して更新
    await this.loadContent(tab);
  }

  // active クラスを切り替える処理
  updateActiveButton(activeButton) {
    // すべてのボタンから active を削除
    this.buttonTargets.forEach((button) => {
      button.classList.remove('active');
    });

    // クリックされたボタンに active を追加
    activeButton.classList.add('active');
  }

  // サーバーからコンテンツを取得
  async loadContent(tab) {
    try {
      const response = await fetch(`/tabs/${tab}`);
      const html = await response.text();

      // content target の innerHTML を更新
      this.contentTarget.innerHTML = html;
    } catch (error) {
      console.error('Error loading content:', error);
      this.contentTarget.innerHTML =
        '<p>コンテンツの読み込みに失敗しました。</p>';
    }
  }
}

Stimulus では、targets で参照したい要素を宣言し、this.buttonTargetsthis.contentTarget でアクセスできます。ロジックが整理され、テストしやすい構造になっていますね。

サーバーサイドは htmx と同じで、各タブの HTML 断片を返すエンドポイントを用意します。

javascript// サーバー実装は htmx と同じ
app.get('/tabs/a', (req, res) => {
  res.send(`
    <h2>タブ A のコンテンツ</h2>
    <p>これはタブ A の内容です。</p>
  `);
});

// 以下、タブ B・C も同様

コード量の集計(Stimulus)

#ファイル種類行数
1HTML35
2JavaScript(UI)40
3JavaScript(API)20
4合計95

Stimulus の実装は、全体で 95 行となりました。htmx より増えていますが、ロジックが明確に分離され、再利用性が高い構造になっています。

実装 3:Turbo による実装

Turbo は、Turbo Frames や Turbo Streams を使って、ページの部分更新を実現します。今回は Turbo Frames を使い、タブごとに <turbo-frame> 要素を定義します。

HTML は次のようになります。

html<!-- Turbo による実装:Turbo Frames を使用 -->
<div class="tabs-container">
  <div class="tab-buttons">
    <!-- リンクを Turbo Frame の ID に紐付ける -->
    <a
      href="/tabs/a"
      class="tab-button active"
      data-turbo-frame="tab-content"
    >
      タブ A
    </a>
    <a
      href="/tabs/b"
      class="tab-button"
      data-turbo-frame="tab-content"
    >
      タブ B
    </a>
    <a
      href="/tabs/c"
      class="tab-button"
      data-turbo-frame="tab-content"
    >
      タブ C
    </a>
  </div>

  <!-- Turbo Frame でコンテンツ領域を定義 -->
  <turbo-frame id="tab-content" class="tab-content">
    <h2>タブ A のコンテンツ</h2>
    <p>これはタブ A の内容です。</p>
  </turbo-frame>
</div>

<!-- Turbo ライブラリの読み込み -->
<script type="module">
  import * as Turbo from 'https://cdn.skypack.dev/@hotwired/turbo';
</script>

data-turbo-frame="tab-content" 属性により、リンクをクリックすると、id="tab-content" の Turbo Frame が更新されます。

ただし、Turbo だけでは active クラスの切り替えができないため、ここでも少しだけ JavaScript が必要です。

javascript// active クラスを切り替える処理
document.addEventListener(
  'turbo:before-fetch-request',
  function (event) {
    // クリックされた要素を取得
    const link = event.target;

    // すべてのリンクから active を削除
    document
      .querySelectorAll('.tab-button')
      .forEach((button) => {
        button.classList.remove('active');
      });

    // クリックされたリンクに active を追加
    link.classList.add('active');
  }
);

Turbo は turbo:before-fetch-request イベントを発火するため、それをリスニングして active クラスを更新します。

サーバーサイドでは、Turbo Frame の ID に対応する HTML を返す必要があります。

javascript// サーバー実装:Turbo Frame に対応
app.get('/tabs/a', (req, res) => {
  res.send(`
    <turbo-frame id="tab-content">
      <h2>タブ A のコンテンツ</h2>
      <p>これはタブ A の内容です。</p>
    </turbo-frame>
  `);
});

app.get('/tabs/b', (req, res) => {
  res.send(`
    <turbo-frame id="tab-content">
      <h2>タブ B のコンテンツ</h2>
      <p>これはタブ B の内容です。</p>
    </turbo-frame>
  `);
});

app.get('/tabs/c', (req, res) => {
  res.send(`
    <turbo-frame id="tab-content">
      <h2>タブ C のコンテンツ</h2>
      <p>これはタブ C の内容です。</p>
    </turbo-frame>
  `);
});

サーバーが返す HTML には、<turbo-frame id="tab-content"> が含まれている必要があります。この制約により、サーバーサイドのコードが少し冗長になりますね。

コード量の集計(Turbo)

#ファイル種類行数
1HTML30
2JavaScript(UI)10
3JavaScript(API)30
4合計70

Turbo の実装は、全体で 70 行となりました。htmx と Stimulus の中間的な位置づけです。

比較結果の総合評価

3 つの実装を比較した結果を、以下の表にまとめます。

#項目htmxStimulusTurbo
1総コード行数52 行95 行70 行
2HTML 行数25 行35 行30 行
3JavaScript(UI)行数7 行40 行10 行
4責務の分離度★★☆★★★★★☆
5拡張性(タブ追加)★★★★★★★★☆
6デバッグのしやすさ★★☆★★★★★☆
7学習コスト★★★★★☆★☆☆

コード量では、htmx が最も少なく、次いで Turbo、Stimulus の順でした。htmx は HTML 属性だけで完結するため、JavaScript をほとんど書かずに済む点が大きな利点です。

責務の分離度では、Stimulus が最も優れています。ロジックが JavaScript のコントローラーにまとまっており、HTML はマークアップに集中できます。htmx と Turbo は、HTML に動作の指定が混在するため、複雑な処理になると可読性が下がる可能性があります。

拡張性では、htmx と Stimulus が優れています。新しいタブを追加する際、htmx は HTML にボタンを追加するだけ、Stimulus もボタンを追加すればコントローラーが自動的に処理します。Turbo は、サーバーサイドで Turbo Frame の構造を保つ必要があり、やや手間がかかりますね。

デバッグのしやすさでは、Stimulus が最も優れています。エラーが発生したとき、JavaScript のスタックトレースで原因を特定しやすいためです。htmx と Turbo は、HTML 属性の指定ミスがエラーの原因になることが多く、追跡が難しい場合があります。

学習コストでは、htmx が最も低く、Turbo が最も高い結果になりました。htmx は公式ドキュメントがシンプルで、基本的な使い方をすぐに理解できます。Stimulus も中程度ですが、Turbo は Turbo Drive・Turbo Frames・Turbo Streams といった複数の概念を理解する必要があり、学習曲線が急ですね。

次の図は、各技術の特性をレーダーチャートで表したものです。

mermaid%%{init: {'theme':'base'}}%%
graph TB
    subgraph comparison["3技術の特性比較"]
        htmx_node["htmx<br/>コード量: 最小<br/>学習コスト: 低<br/>責務分離: 中"]
        stimulus_node["Stimulus<br/>コード量: 最大<br/>学習コスト: 中<br/>責務分離: 高"]
        turbo_node["Turbo<br/>コード量: 中<br/>学習コスト: 高<br/>責務分離: 中"]

        htmx_node -->|"シンプル重視"| choice["選択基準"]
        stimulus_node -->|"保守性重視"| choice
        turbo_node -->|"統合重視"| choice
    end

このように、各技術には明確な特徴があり、プロジェクトの要件に応じて使い分けることが重要です。

実践的な使い分け指針

実験結果を踏まえて、各技術の使い分け指針をまとめます。

htmx を選ぶべきケース

  • プロトタイピングや MVP 開発で、スピードを最優先したい
  • JavaScript の記述を最小限に抑えたい
  • シンプルな CRUD 操作や部分更新がメイン
  • チームに JavaScript が苦手なメンバーが多い

htmx は、「とにかく速く動くものを作りたい」ときに最適ですね。HTML だけで完結するため、バックエンドエンジニアでも扱いやすいのが特徴です。

Stimulus を選ぶべきケース

  • 既存の Rails アプリに段階的に JavaScript を追加したい
  • ロジックを整理し、テスタブルなコードを書きたい
  • 複雑な UI インタラクション(ドラッグ&ドロップ、リアルタイムバリデーションなど)が必要
  • 長期的な保守性を重視する

Stimulus は、「JavaScript を書くけど、React ほど重くしたくない」という中間的なニーズに応えます。コントローラー単位でロジックが整理されるため、大規模なプロジェクトでも破綻しにくいですね。

Turbo を選ぶべきケース

  • Rails プロジェクトで、Hotwire のエコシステムを最大限活用したい
  • ページ全体の高速化(Turbo Drive)と部分更新(Turbo Frames)を両立したい
  • リアルタイム更新(Turbo Streams + Action Cable)が必要
  • ブラウザの戻る・進むボタンに完全対応したい

Turbo は、Rails との統合が前提であり、フルスタックで Hotwire を採用する場合に威力を発揮します。ただし、学習コストが高いため、小規模なプロジェクトでは htmx や Stimulus の方が適していることも多いでしょう。

組み合わせのパターン

実際のプロジェクトでは、これらを組み合わせることも有効です。

  • htmx + Stimulus:htmx で基本的な部分更新を実装し、複雑な UI インタラクションだけ Stimulus で補う
  • Turbo + Stimulus:Turbo でページ遷移と部分更新を担当し、UI ロジックは Stimulus で整理する(Rails の標準パターン)
  • htmx + Alpine.js:htmx でサーバー連携、Alpine.js でクライアント側の軽量な状態管理(今回は扱いませんでしたが、人気の組み合わせです)

次の図は、プロジェクトの規模と複雑度に応じた技術選定の目安を示しています。

mermaidflowchart TD
    start["タブ切り替え機能を<br/>実装したい"] --> question1{"プロジェクト規模は?"}

    question1 -->|"小規模<br/>(1-2人)"| small["htmx 推奨"]
    question1 -->|"中規模<br/>(3-10人)"| medium{"Rails 利用?"}
    question1 -->|"大規模<br/>(10人以上)"| large["Stimulus 推奨"]

    medium -->|"Yes"| rails["Turbo + Stimulus"]
    medium -->|"No"| nonrails["htmx or Stimulus"]

    small --> check1{"複雑な UI?"}
    check1 -->|"Yes"| stimulus_small["Stimulus 追加検討"]
    check1 -->|"No"| htmx_only["htmx のみで OK"]

    large --> check2{"リアルタイム更新?"}
    check2 -->|"Yes"| turbo_large["Turbo Streams 検討"]
    check2 -->|"No"| stimulus_only["Stimulus のみで OK"]

この判断フローに沿って、プロジェクトの特性に合った技術を選択すると良いでしょう。

まとめ

今回の実験を通じて、htmx・Stimulus・Turbo の責務分担と使い分けの指針が明確になりました。

htmx は、HTML 属性だけで Ajax 通信と DOM 更新を実現し、コード量を最小限に抑えられます。プロトタイピングやシンプルな部分更新には最適ですが、複雑なロジックになると HTML が肥大化し、保守性が低下する可能性があります。

Stimulus は、ロジックを JavaScript のコントローラーに整理し、責務の分離と保守性を重視します。コード量は増えますが、テストしやすく、長期的な開発に向いています。Rails 以外のプロジェクトでも採用しやすい点も魅力ですね。

Turbo は、Rails との統合を前提とし、Turbo Drive・Turbo Frames・Turbo Streams を組み合わせることで、ページ遷移の高速化から部分更新、リアルタイム更新まで幅広くカバーします。ただし、学習コストが高く、Turbo Frame の構造をサーバーサイドで保つ必要があるため、小規模なプロジェクトでは過剰になることもあります。

実際のプロジェクトでは、これらを単独で使うのではなく、組み合わせるのが現実的でしょう。htmx で基本的な部分更新を実装し、複雑な UI だけ Stimulus で補う。Rails プロジェクトでは Turbo と Stimulus を併用する。このように、各技術の強みを活かした設計が重要です。

「どの技術が最も優れているか」ではなく、「どの技術が自分のプロジェクトに最適か」を見極めることが、成功への近道ですね。今回の比較データが、皆さんの技術選定の参考になれば幸いです。

関連リンク