Commentary
タブメニュー
タブメニューはTurbo Framesで作ることが多く、Turbo Frames入門としては最適なUI要素だと私は思います。
まずタブメニューの作る方に入る前に、Turbo Framesの概略を説明します。
作るUIの例 (Turbo Frames)
TurboがTurbo Drive, Turbo Frames, Turbo Streamsから構成されていることは別のページで紹介しています。
私の経験では、Turbo Drive, Turbo Frames, Turbo Streamsのうち、Turbo Driveはデフォルトですべてのページに適応されるので一番使います。ついで部分置換が必要な時は、Turbo Framesを使います。Turbo Framesでは難しい時に初めてTurbo Streamsを使います。
(ただしmorphingが導入されてからはTurbo Driveだけでできるものが非常に増えましたので、Turbo Streamsを使う頻度はさらに下がりそうです)
理由はコードの簡単さです。一番分かりやすく単純なのはTurbo Driveで、「画面に何を表示したいか」だけを考えます。Turbo Frames, Turbo Streamsに行くにしたがって、複数のパーツの相互作用や依存関係を考えなければならず、コードが複雑になってきます。私はメンテナンス性の良いコードを書きたいので、なるべくTurbo DriveやTurbo Framesを使います。
一方でTurbo Drive, Turbo Framesの限界を知っておき、Turbo Streamsが必要なところではこれを使うことも大切です。
技術 | 用途 | 注記 | 使用頻度 (著者経験) |
---|---|---|---|
Turbo Drive | 全画面置換 | キャッシュ | > 90% |
Turbo Frames | 画面の部分的置換 | 関連機能が豊富 | ~ 15% |
Turbo Streams | 画面の部分的置換 | 複数箇所を同時置換可能 | < 5% |
Morphing | 差分的更新 | DriveやFramesと組み合わせて 変化したところだけ更新 | ~ 10% |
Turbo Driveがページ遷移、つまり画面全体を置換するのに対して、Turbo Framesはサーバから送られてきたデータを使って画面の部分置換をする時に使います。
そして「モーダル」「ポップアップ」「ドロップダウンメニュー」「ドロワーメニュー(引き出し)」「ライブ検索」、住所を入力するときに使う「階層メニュー」などは、インタラクティブと言われるUI要素も、実は大部分はシンプルな部分的置換と少しのJavaScriptで実現できます。つまりTurbo Framesがあれば、大半のインタラクティブUIは作れます。
さらにTurbo Framesは部分置換だけではなく、周辺機能も提供してくれます。a
タグやform
タグとの連携、Lazy
loading (遅延ロード)、prefetch、URL同期、ローダー表示用のCSSなど、インタラクティブなUIを作る上での便利機能も内包しています。
一方でReactなどの場合はuseState
フックや条件付きレンダーなどのパターンを提供してくれますが、これを組み合わせてUI要素を作るのは開発者しだいです。その意味でReactはフルスクラッチでUI要素を作成するのに適している一方、HotwireはUIライブラリとまではいかないものの、パッケージしたものを提供していると言えます。
本ページでは数あるインタラクティブUIの中でも、比較的シンプルなタブメニューから紹介します。そして早くTurbo Framesを使った実装を紹介したいところですが、その前にやっぱり 基本を振り返るおくことが重要だ と思いますので、一歩下がって古典的なMPAを使った場合のタブメニューの実装方法です。
Turbo Framesの開発は、まず最初にMPAを作ることから始めることが多いです。Controller actionを作成し、データベースからデータを取得し、HTMLテンプレートファイルでレンダリングするところまではMPAのまま開発します。そして最後にTurbo Framesによる部分置換を実装します。
そして実際にやってみると、古典的なMPAだけ十分ということも珍しくありません。「あれ?!これはTurbo Framesすらいらないや」ってなることになったりします。実際のデモを体験してください。一切JavaScriptを使わないMPAでも、UI/UX的にほぼ十分です。またコードはGitHubでご確認ください。単なるMPAですのでコードは非常に簡単です。
MPAによるタブメニュー実装では、それぞれのタブに対応する画面を用意し、タブより上を全く同じにします( users側、products側 )。タブより上の部分は全く同じなので、置換されていることに気づきません。一方でタブを含めた下の部分は異なる内容が表示されているので、ここだけが置換されたとユーザは錯覚します。
templates/tabbed_segments_no_js/index.ejs
templates/tabbed_segments_no_js/products.ejs
にMPAのページを用意します。_search.ejs
, _tabs.ejs
のpartialに切り出します例えば食べログのサイトでも、このようなMPA流のタブメニューが使われています。別の問題としてページの読み込みが非常に遅いのが気になりますが、UX的には十分に優れたものになります。
Turbo Driveによるタブメニューはここでお試しいただけます。コードはGitHubでご確認いただけます。
Turbo Driveを使う場合は、MPAのサイトにTurboのJavaScriptファイルをダウンロードするして、<head>
の中で読み込むだけです。MPAの場合と同様、に画面全体の置換をしていますが、Turbo Driveによってヌルサクになった分だけ、タブの切り替えのUI/UXが大幅に向上します。
しかし欠点がないわけではありません。見かけ上はタブのところだけを置換していますが、実際にはページ全体を置換しているため、下記の問題があります。
/hotwire/javascript/turbo.es2017-esm.js
にありますので、これを読み込むscript
タグをhead
タグの中に入れておきますtemplates/layouts/header.ejs
<script src="/hotwire/javascript/turbo.es2017-esm.js" data-turbo-track="reload" type="module"></script>
<script>
タグを配置するだけ)Turbo Framesによるタブメニューはここでお試しいただけます。コードはGitHubでご確認いただけます。
UI/UXについては一見するとTurbo Driveの場合とあまり差がありません。しかし細かく見ると以下の点が異なります。
このようにTurbo Framesの特徴は画面を枠で分割し、枠内を置換しつつ、枠外をそのままに維持するところです。これらの機能が必要な場合はTurbo Framesを選択し、そうでない場合はTurbo Driveを選択するのが良いでしょう。
Turbo Framesによるタブメニューの作り方はごく簡単です。まずはTurbo Driveのバージョンから出発します。そして、どこをTurbo Framesで囲むかを決めます。今回はSearchのテキスト入力フィールドの下のところからテーブルの最後までを囲むことにします。
次にエディタで該当するEJSファイルの内容を確認し、囲みたいところを <turbo-frame id="[適当な名前]"></turbo-frame>
のタグで囲みます。今回は2つのページ
(Users
とProducts
)がありますので、双方のEJSファイルで同じ処理をします。結果はtemplates/tabbed_segments_turboframes/index.ejsおよびtemplates/tabbed_segments_turboframes/products.ejsにあります。
Turbo Framesを実現しているのは下記の部分です。
tabbed_segments_turboframes/index.ejs
<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
...
</turbo-frame>
tabbed_segments_turboframes/products.ejs
<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
...
</turbo-frame>
以上でおしまいです!
templates/tabbed_segments_turboframes/index.ejs
に<turbo-frame>
タグを加えるtemplates/tabbed_segments_turboframes/products.ejs
に同じIDの``タグを加えるdata-turbo-action
でURLと連動させたり、ロード時に自動的に追加されるbusy
属性を利用してCSSでローダーを表示したり、autoscroll
属性でスクロールの動作を変えるなど、機能を追加します。(data-turbo-action
については解説で、またbusy
についてはローダー表示で解説しています。この3つのステップだけで、Turbo Frames的なタブメニューができ上がりました!
<turbo-frame></turbo-frame>
で囲むことによって、Turbo
Frame中に含まれるa
タグやform
タグは通常と違う性質を持つようになります。通常であればTurbo Driveのような 全画面 遷移をします。しかし<turbo-frame></turbo-frame>
に囲まれている場合は同じTurbo
Frame内に限定された 部分画面 遷移をするように変化します(デフォルト;都度変更可能)。turbo-frame
に囲まれたところは独立性が高く、あたかもブラウザウィンドウの中に、もう1つ小さなブラウザウィンドウができたような感じに設計されています。a
タグ)を配置しましたので、Users
, Products
のタブはTurbo Frame内を部分置換するようになります。Turbo Frame内のみが変化するので、Searchのテキストフィールドもリセットされないわけです。a
タグと同じようにHTTPリクエストは飛びます。そしてHTMLがサーバから返ってきます。通常ならここで画面全体を置換するのですが、Turbo Framesの場合は新しいページの中にある<turbo-frame></turbo-frame>
を探し出し、元のページの中にある<turbo-frame></turbo-frame>
の中身と置換します。この時、id
属性をみてturbo-frame
のペアを認識するので、id
の値を揃えておく必要があります。a
タグなので、Turbo Driveの機能であるprefetchも働きます。このためprefetchによるUXの大幅向上、ヌルサクな体感も、何もしなくても勝手についてきます。turbo-frame
タグにdata-turbo-action="replace"
のHTML属性をつけています。この属性をつけることにより、turbo frameのエンドポイントがブラウザURLになります。その結果、タブごとにブックマークを作ったり、リンクを共有したりできます。data-turbo-action
タグをつけない場合は、タブを推してもブラウザURLは変更されません。Turboの大きな特徴は、余計なHTMLをサーバから送ることを気にしないことです。タブを切り替えるだけならタブの中身だけをサーバから送信すれば良いのですが、今回のケースは常にページ全体を送っています。タブ以外の部分もサーバから送り直すのは古典的なMPAによるタブメニューと完全に同じです。
これには大きなメリットがあります。サーバ側コードの簡略化です。画面フラグメントを返すエンドポイントと画面全体を返すエンドポイントを完全に共有化できます。サーバ側は1つのエンドポイントだけを用意し、常に同じレスポンスを返せば、あとはTurbo Framesがブラウザの状態に応じて適切に処理してくれます。タブが表示されている画面からのリクエストならタブ内容の置換だけしますし、別のページから飛んでくる場合は画面全体を表示します。いずれの場合もサーバは関知せず、常に全く同じ内容のHTMLを返します。
一方、余計な箇所をサーバで再レンダリングしても大きな負荷にならないことがほとんどです。データベースに負荷のかかる処理は、通常はタブの内側だけですし、また余計なデータをネットワーク越しに送信しても、その負荷は一般に微々たるものです。わずかな効果しか得られない最適化をやるよりは、コードをシンプルにすることを優先しようというのがTurboの考え方になります。
それでもサーバへの負担を心配するのであれば、サーバ側でキャッシュする機能などを使うこともできますので、サーバ側の最適化で問題を解決できます。
Turbo Frameリクエストの場合は、HTTPヘッダーTurbo-Frame
が送られてきますので、これをサーバ側から確認し、見て余計なところを送信しないという選択肢もあります(turbo-rails gemを使えば、コントローラに#turbo_frame_request?
メソッドが用意されているので、これは容易にできます)。しかしサーバ側コードが複雑になりますので、一般には気にしないことをお勧めします。
なお今回の話とはズレますが、Turbo Streamsの場合は画面断片だけをサーバから送ります。したがってTurbo Streams専用のエンドポイントを用意するか、もしくはリクエストヘッダー(Accept
)に応じた分岐ロジックと複数の処理をコントローラに書きます。またTurbo Framesでも画面の部分置換しか行わないエンドポイントを用意することもあります。実現したいUI/UXを考慮して、適宜選択します。
また、タブのロードに時間がかかってしまう時のために、ローダーの表示もさせています。Turbo Framesはサーバに問い合わせするときにbusy
属性がつくのを利用します。コードはそこそこの量になりますが、HTML/CSSだけです。
input.css
.turbo-with-loader[busy] .turbo-hide-on-loading {
visibility: hidden !important;
}
.turbo-with-loader {
position: relative;
}
.turbo-with-loader[busy] .turbo-hide-on-loading::before {
content: '';
visibility: visible !important;
display: block;
background-image: url('../../images/rocket.gif');
width: 64px;
height: 64px;
margin: 48px auto;
}
tabbed_segments_turboframes/index.ejs
<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
...
<div class="my-10 px-4 sm:px-6 lg:px-8 turbo-hide-on-loading" >
<!-- [ここがロード中に隠れ、代わりにローダーが表示される] -->
</div>
...
</turbo-frame>
tabbed_segments_turboframes/products.ejs
<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
...
<div class="my-10 px-4 sm:px-6 lg:px-8 turbo-hide-on-loading" >
<!-- [ここがロード中に隠れ、代わりにローダーが表示される] -->
</div>
...
</turbo-frame>
Reactでタブメニューを実装する方法を3つほど紹介します。
まず最初にuseEffectを使った方法を紹介します。デモはここでお試しいただけます。コードはGitHubでご確認ください。
Reactのコードの特徴は以下の通りです。一般的なReactのデータフェッチのパターンを複数回使うだけですので、ロジックは追いやすいと思います。
useState
を使用)Users.tsx
とProducts.tsx
)Users
、Products
コンポーネントの中のuseEffect
の中のfetch
で行うpages/tabbed_segments/index.tsx
export default function TabbedSegmentsIndexPage() {
const [selectedTab, setSelectedTab] = useState<number>(0)
...
{selectedTab === 0
? <Users/>
: selectedTab === 1
? <Products/>
: null}
...
}
components/tabbed_segments/Users.tsx
export default function Users() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState<boolean>(true)
useEffect(() => {
fetch("/api/users").then(res => res.json())
.then(data => {
setUsers(data)
setLoading(false)
})
}, [])
...
components/tabbed_segments/Products.tsx
export default function Products() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/products")
.then((res) => res.json())
.then((json) => {
setProducts(json)
setLoading(false);
})
},[])
...
Next.js Pages RouterのSSRはページ全体を遷移するのには適していますが、Turbo Framesのように部分置換をする機能がありません。部分置換をするには従来のSPAと同様に、ブラウザ上でuseEffect
を使ってステートを更新し、CSRで更新する必要があります。
しかしuseEffect
を使った場合はCSRになるので、SEOでは不利になってしまいます。そこで古典的なMPAやTurbo Driveと同様の方法を使うことができます。ページ全体をサーバでレンダリングしつつ、見かけ上はタブだけが切り替わったようになります。
デモはこちらからご覧ください。またコードはGitHubでご確認ください。
pages/tabbed_segments_ssr/index.tsx
pages/tabbed_segments_ssr/products.tsx
でgetServerSideProps()
を使ったSSRのページを作ります<Link ... scroll={false}>
を利用しますpages/tabbed_segments_ssr/index.tsx
export async function getServerSideProps() {
const response = await fetch(process.env.URL + "/api/users")
const data: User[] = await response.json()
return {props: {users: data}}
}
export default function TabbedSegmentsIndexPage({users}: { users: User[] }) {
...
<nav aria-label="Tabs" className="-mb-px flex space-x-8">
<Link href="/tabbed_segments_ssr" key={0} scroll={false}
className="border-orange-500 text-orange-600 whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium hover:cursor-pointer"
>
Users
</Link>
<Link href="/tabbed_segments_ssr/products" key={1} scroll={false}
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium hover:cursor-pointer"
>
Products
</Link>
</nav>
...
}
pages/tabbed_segments_ssr/products.tsx
export async function getServerSideProps() {
const response = await fetch(process.env.URL + "/api/products")
const data : Product[] = await response.json()
return {props: {products: data}}
}
export default function TabbedSegmentsIndexPage({products}: {products: Product[]}) {
...
<nav aria-label="Tabs" className="-mb-px flex space-x-8">
<Link href="/tabbed_segments_ssr" key={0} scroll={false}
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium hover:cursor-pointer"
>
Users
</Link>
<Link href="/tabbed_segments_ssr/products" key={1} scroll={false}
className="border-orange-500 text-orange-600 whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium hover:cursor-pointer"
>
Products
</Link>
</nav>
...
}
なおこのパターンをしようしている例として、News Picksが挙げられます。Next.jsのPages RouterのSSRを使い、タブメニューを実装しています。
Link
タグにscroll={false}
をつける)Next.js Pages RouterにはSSRを使った部分置換の仕組みがないことは上述しました。
一方でApp Routerは部分置換の仕組みが用意されています。LayoutとParallel Routesがこれに相当します。特にこのページで紹介しているタブメニューについては、Parallel Routesのドキュメントにわざわざタブメニューの言及もあり、この方法が奨励されていると受け取れます。
LayoutやParallel Routesを使うと、Client Componentを使わずにServer Componentだけでタブメニューが実装できます。App RouterではなるべくServer Componentを使うことが推奨されていますし、なるべくサーバでデータフェッチをすることがベストプラクティスとされています。LayoutやParallel Routesを使うと、これが可能になるわけです。
Parellel Routesを使ったデモも用意しましたのでご確認ください。またコードはGitHubにあります。
Parallel Routesを使った場合のUI/UXの特徴は以下の通りです
data-turbo-action
を設定した時と同じ効果です。loading.js
を配置することでローディングアニメーションが出せますloading.js
ファイルまでしかprefetchをしてくれません。そのため、コンテンツが表示されるまでにかかる時間は短くなりません。ReactでuseEffectを使った場合とほぼ同じUXになります。Parallel RoutesのコードはGitHubに用意しています。
app/tabbed_segments_app/layout.tsx
に、タブより上の部分をレイアウトファイルとして用意します@tabs
フォルダの中に含めますので、このフォルダを作成します。この@tabs
はSlot
と呼びますapp/tabbed_segments_app/layout.tsx
が@tabs
slotを参照するように、コンポーネントにtabs
propsを持たせ、slotが挿入されるべき箇所に{tabs}
を配置しますapp/tabbed_segments_app/@tabs/users/page.tsx
app/tabbed_segments_app/@tabs/products/page.tsx
にタブの内容のコンポーネントを作りますgetUsers()
, getProducts()
等)loading.tsx
はローディングアニメーションを用意したいところに適宜配置しますapp/tabbed_segments_app/layout.tsx
export default function TabbedSegmentsLayout(
{tabs}: { tabs: ReactNode }) {
return (
<div className="bg-white">
<div className="px-6 py-24 sm:px-6 sm:py-32 lg:px-8">
<div className="mx-auto max-w-2xl text-center">
<h1 className="demo-h1">
タブメニューUI
</h1>
<TabbedSegmentTechNav selected="parallel"/>
<div className="mt-10">
<div className="mb-2">
<label htmlFor="search" className="text-sm mr-2">Search</label>
<input id="search" type="search" className="border rounded p-1" placeholder="ステート維持確認用"/>
</div>
{tabs}
</div>
</div>
</div>
</div>
)
}
app/tabbed_segments_app/@tabs/users/page.tsx
async function getUsers(): Promise<User[]> {
const res = await fetch(process.env.URL + "/api/users")
const users = await res.json()
return users
}
export default async function TabbedSegmentsUsersPage() {
const users = await getUsers()
const time = new Date().toLocaleTimeString()
...
}
app/tabbed_segments_app/@tabs/products/page.tsx
// Simulate Next.js acting as a BFF for a JSON API server
async function getProducts(): Promise<Product[]> {
const res = await fetch(process.env.URL + "/api/products")
const products = await res.json()
return products
}
export default async function TabbedSegmentsProductsPage() {
const products = await getProducts()
const time = new Date().toLocaleTimeString()
...
}
書かなければならないコードは特に多いわけではありませんが、厳密に役割が規定されたフォルダとファイルを適切に配置する必要があるので、Turbo Framesを使った場合やuseEffectを使った場合、SSRの場合と比べて学習コストが高いと感じます
loading.tsx
でローディング画面をすぐに出せる今回はTurbo Framesによるタブメニューを実装しました。簡単なものだったため、UX的にMPAと大きな差はありませんでした。しかしTurbo Framesを使うと、枠の外のステートが維持できていることが確認できました。スクロール位置やフォーム要素のステート維持が必要な場合は、Turbo Framesを使う必要があります。
React、Next.jsと比較すると、Turbo Framesによるタブメニューは実装が簡単でありつつ、機能的にはむしろ豊富であることがわかりました。一方のReactは、少なくとも現時点では自在にHTMLを分割し、繋ぎ合わせることを得意としておらず、Layoutをルーティングと組み合わせ、最初から計画的な分割・繋ぎ合わせを要しているようです。Hotwireが当初からHTMLを繋ぎ合わせることを主目的としていたのに対して、Reactはstateを更新し、ブラウザで必要箇所を再レンダリングするモデルだったことが影響しているのだろうと思います。ただしRSCをさらに活用することにより、より簡単に、自在に繋ぎ合わせられるようになるかもしれません。