TurboとAlpine.jsで支えるスポジョブの開発
1. 導入
Railsで開発を行おうとした際に、ネックになるのがフロントエンド問題だと思います。
多くのモダンな企業では、フロントエンドにReactやVueなどのフレームワークを採用しバックエンドのRailsとJSONをやり取りしていることが多いと思います。
私も以前関わった案件では、この構成が多かったです。
また、Rails 7以降はHotWire(Stimulus)を使用するという選択肢も出てきているかと思います。
今回は、当社のプロダクトで採用している高速に開発が可能かつ可読性も良いAlpine.jsとTurboを組み合わせた方式について記載したいと思います。
2. 使用しているアーキテクチャ
- バックエンド: Rails 7 / Ruby 3、ViewComponent、Turbo
-
フロント: Vite + TypeScript + Tailwind(DaisyUI)。
vite.config.tsで@js/@cssエイリアスを定義。 -
状態共有:
app/frontend/javascript/container.tsが Turbo・Alpine・ブラウザストレージを束ねるappContainerを提供。サーバ側はHTMLのdata-*で初期値を渡し、クライアントはAlpineのstoreを通じて再利用します。
この構成により、SSRでSEOと初速を確保しつつ、必要な箇所だけをインタラクティブに仕上げています。
2.5 Alpine.jsを採用して良かった点
Alpine.jsを使うとHTML側で x-if や x-for をそのまま宣言でき、テンプレートを開いた瞬間に条件分岐やループが把握できます。さらに init や $watch といったライフサイクルフックが揃っているため、DOM更新やイベント登録のタイミングも明示的に管理できる点が良かったです。
3. Turbo × Alpine.js の動かし方
3.1 entrypointで同時初期化
まずは最小構成のコードを示します。Turbo・AlpineともにappContainerに登録し、AlpineControllerが data オブジェクトを一括でマウントします。
appContainer はアプリケーション全体の状態管理と主要ライブラリへのアクセスを一元化しています。
主な役割は以下の通りです:
-
ライブラリへのアクセサ:
TurboやAlpineのインスタンスを保持し、アプリ内のどこからでもappContainer.alpineのようにアクセス可能にします。 -
状態管理:
sessionStorageやlocalStorageへの効率的なアクセスを提供し、検索条件やUIの状態(開閉状態など)を管理します。 - 共有リソースの保持: 共通のエラーメッセージハンドラなどを保持します。
実際のコード(簡略版)は以下のようになっています。
class AppContainer {
setApp() {
// 初期化時にライブラリを注入する関数
}
get alpine(): Alpinejs {
// どこからでもAlpineインスタンスを取得可能な関数
}
}
export const appContainer = new AppContainer();
// app/frontend/entrypoints/application.ts(簡略化)
import * as Turbo from '@hotwired/turbo';
import Alpine from 'alpinejs';
import { appContainer } from './container';
import { AlpineController } from './alpineController';
Turbo.start();
appContainer.setApp();
new AlpineController();
AlpineController 側では document.addEventListener('alpine:init', …) 内で appContainer.alpine.data('itemForm', () => ({ ...itemForm })) のように各モジュールを登録し、alpine-turbo-drive-adapter を読み込んでTurbo遷移後の再初期化を自動化しています(以降のケーススタディでも同じ itemForm を題材にします)。
3.2 Turbo Frames / Streams の使い方
-
画面の縦割り: 一覧やモーダルなど再描画頻度が高い領域は
turbo_frame_tagで囲み、遷移や保存をしてもページ全体を更新しないようにします。 -
差分更新のバッチ化: ひとつのアクションでページの複数箇所を更新したい場合は
turbo_stream.updateとturbo_stream.replaceを使用し、turbo_streamで更新します。 -
Alpine再初期化: TurboがDOMを書き換えた直後に
<div x-init="listFilter().afterPatch()"></div>のようなフックを仕込み、Storeの状態とDOMを同期させます。
以下は管理画面でレコード状態を更新した際のイメージです。
<%= turbo_stream.update "record_summary" do %>
<%= render Records::SummaryComponent.new(record:) %>
<div x-init="recordsCalendar().afterPatch()"></div>
<% end %>
<%= turbo_stream.replace dom_id(record) do %>
<%= render Records::RowComponent.new(record:) %>
<% end %>
4. ケーススタディ:モーダル内フォームの汎用パターン
4.1 モーダルを出して編集→更新するパターン
例えば、管理者が求人一覧画面で各求人の情報を確認して、そのうちの一部を編集・更新する場合で考えてみます。
画面遷移をすることもあるかと思いますが、Turboを使用することで更新が完了したら即座にその部分だけ差し替えることができます。
4.2 ビュー側の構成(擬似コード)
Turbo Frame でモーダルを差し込む場合は「targetとなるidを用意してから → edit.turbo_stream.erb で差し替える」という二段構成にします。
<!-- index.html.erb -->
<%= turbo_frame_tag 'target_modal_id' %>
<!-- items/edit.turbo_stream.erb -->
<%= turbo_stream.replace 'target_modal_id' do %>
<%= form_with scope: :item,
url: submit_path,
html: {
'x-data': 'itemForm',
'x-cloak': true,
data: {
returnUrl: back_path,
initialLabel: item.label
}
} do |form| %>
<%= render ModalComponent.new(title: modal_title) do |c| %>
<% c.with_custom_footer do %>
<button class="btn" @click.prevent="cancel">キャンセル</button>
<%= form.button '保存', class: 'btn btn-primary', ':disabled': 'isDisabled' %>
<% end %>
<%= form.text_field :label,
class: 'input',
'x-model': 'label',
'@input': 'validate()' %>
<p class="text-xs text-right"><span x-text="label.length"></span>/30</p>
<% end %>
<% end %>
<% end %>
x-data: 'itemForm' を <form> に付与しているので、その内側に描画されるボタンや x-text などのディレクティブは itemForm に定義した関数を参照できます。
4.3 Alpine data(擬似コード)
JS側はTypescriptですが、以下のように記載します。
// app/frontend/javascript/itemFormController.ts
import { appContainer } from './container';
const MAX_LENGTH = 30;
export default {
returnUrl: '',
form: null as HTMLFormElement | null,
label: '',
isDisabled: true,
init() {
const el = this.$el;
this.returnUrl = el.dataset.returnurl ?? '';
this.label = el.dataset.initiallabel ?? '';
this.form = el as HTMLFormElement;
this.validate();
},
submit() {
this.form?.requestSubmit();
},
cancel() {
appContainer.turbo.visit(this.returnUrl);
},
validate() {
const length = this.label.length;
this.isDisabled = length === 0 || length > MAX_LENGTH;
}
};
ポイント
- Turbo Frame を使ってモーダルを表示すると、背景ページのスクロールや検索状態を維持したまま編集できます。
- init()を使用することでDOMが読み込まれた際に最初にやる内容を明示的に記載することができます。
5. 開発体験を崩さない工夫
-
Vite + ESLint:
npm run devでTypeScriptの型チェック→esbuildバンドルを自動再実行。Lintはnpm run lint:tsに集約し、CIと同じルールをローカルですぐ確認できます。
まとめ
TurboとAlpine.jsを使用することで、React等のフレームワークに詳しくないメンバーでも直感的に記載することが可能になりました。
Turboで書き換える部分とAlpine.jsで書き換える部分が明確に分離することで、責務を分けやすくなっていることも利点になっていると思います。
ただ、Alpine.jsはReactやVueほどドキュメントが豊富にないということとTurboとAlpine.jsでの活用事例が少ない点は少しデメリットかと思います。
今後も Turbo と Alpine.jsの資産を有効活用し、高速に開発を継続していきたいと考えています。