Top

Commentary

Tabbed Menus

タブメニュー

タブメニューはTurbo Framesで作ることが多い #

タブメニューはTurbo Framesで作ることが多く、Turbo Frames入門としては最適なUI要素だと私は思います。

まずタブメニューの作る方に入る前に、Turbo Framesの概略を説明します。

作るUIの例 (Turbo Frames)

Turboの序列を考える #

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の各技術
技術用途注記使用頻度
(著者経験)
Turbo Drive全画面置換キャッシュ> 90%
Turbo Frames画面の部分的置換関連機能が豊富~ 15%
Turbo Streams画面の部分的置換複数箇所を同時置換可能< 5%
Morphing差分的更新DriveやFramesと組み合わせて
変化したところだけ更新
~ 10%
※)頻度はあくまでも著者の経験であり、条件によって変わります。モーダル等を頻繁に使う場合や、少数の画面のUXに徹底的に注力する場合は異なります。また私はコードの単純化を優先してTurbo Framesを多く使いますが、逆にTurbo Streamsによる細かい制御を好む人もにいます。

Turbo Framesは部分的置換のパッケージ #

Turbo Driveがページ遷移、つまり画面全体を置換するのに対して、Turbo Framesはサーバから送られてきたデータを使って画面の部分置換をする時に使います。

そして「モーダル」「ポップアップ」「ドロップダウンメニュー」「ドロワーメニュー(引き出し)」「ライブ検索」、住所を入力するときに使う「階層メニュー」などは、インタラクティブと言われるUI要素も、実は大部分はシンプルな部分的置換と少しのJavaScriptで実現できます。つまりTurbo Framesがあれば、大半のインタラクティブUIは作れます

さらにTurbo Framesは部分置換だけではなく、周辺機能も提供してくれます。aタグやformタグとの連携Lazy loading (遅延ロード)prefetchURL同期ローダー表示用のCSSなど、インタラクティブなUIを作る上での便利機能も内包しています。

一方でReactなどの場合はuseStateフック条件付きレンダーなどのパターンを提供してくれますが、これを組み合わせてUI要素を作るのは開発者しだいです。その意味でReactはフルスクラッチでUI要素を作成するのに適している一方、HotwireはUIライブラリとまではいかないものの、パッケージしたものを提供していると言えます。

インタラクティブなUIは画面の部分置換で作る
Interactive UIs with partial updates

古典的なMPAによるタブメニューの作り方 (Turbo開発の出発点) #

本ページでは数あるインタラクティブ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側 )。タブより上の部分は全く同じなので、置換されていることに気づきません。一方でタブを含めた下の部分は異なる内容が表示されているので、ここだけが置換されたとユーザは錯覚します。

古典的MPA 実装方法 #

  • templates/tabbed_segments_no_js/index.ejs templates/tabbed_segments_no_js/products.ejsにMPAのページを用意します。
  • タブより上の部分は2つの画面で共通していますので、適宜_search.ejs, _tabs.ejsのpartialに切り出します
ファイル・フォルダ構成は下記のようになります
Tabs No JS

例えば食べログのサイトでも、このようなMPA流のタブメニューが使われています。別の問題としてページの読み込みが非常に遅いのが気になりますが、UX的には十分に優れたものになります。

食べログのサイトのタブメニュー

まとめ #

  • 実装が簡単
  • Prefetchが効かないので、レスポンスは比較的遅い
  • SSRなのでSEOに強い
  • タブを切り替えた時にスクロール位置がリセットされる
  • タブごとに専用のURLを持つので、タブを指定したブックマークやリンクを共有できる

Turbo Driveによるタブメニューの作り方 #

Turbo Driveによるタブメニューはここでお試しいただけます。コードはGitHubでご確認いただけます。

Turbo Driveを使う場合は、MPAのサイトにTurboのJavaScriptファイルをダウンロードするして、<head>の中で読み込むだけです。MPAの場合と同様、に画面全体の置換をしていますが、Turbo Driveによってヌルサクになった分だけ、タブの切り替えのUI/UXが大幅に向上します。

  • Turbo Driveのprefetchが効くので、レスポンスが大幅に高速化します
  • さらにTurbo Drive Page Cacheも効くので、プレビューが瞬間的に表示されます

しかし欠点がないわけではありません。見かけ上はタブのところだけを置換していますが、実際にはページ全体を置換しているため、下記の問題があります。

  • 少し下にスクロールした後にタブをクリックすると、タブの中身が置換されるだけではなく、トップにスクロールしてしまうことがわかります
  • Search のテキスト入力フィールドに文字を入力し、その後にタブを切り替えると、テキスト入力フィールドの文字は消えてしまいます。これは画面全体を置換する時にこのフィールドも丸ごと置換されるためです
  • なお、今回はTurbo Driveで画面全体が置換されると説明していますが、Morphingを使うと、置換するのではなく差分だけを更新することも可能です。Morphingについては後ほどまとめて紹介したいと思いますが、Reactに近い感じの更新を可能にするもので、かなり強力なものです
Turbo Driveによるタブメニュー
turbodrive image

Turbo Drive 実装方法 #

  • Turboをインストールします。Turboのライブラリは/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>

まとめ #

  • 実装が簡単 (Turboのファイルを読み込む<script>タグを配置するだけ)
  • Prefetchが効くので、レスポンスは速い
  • Turbo Drive Page cacheが効くので、同じタブを再訪問するときはプレビューが瞬間的に表示される
  • SSRなのでSEOに強い
  • タブを切り替えた時にスクロール位置がリセットされる
  • タブごとに専用のURLを持つので、タブを指定したブックマークやリンクを共有できる

Turbo Framesによるタブメニューの作り方 #

Turbo Framesによるタブメニューはここでお試しいただけます。コードはGitHubでご確認いただけます。

UI/UXについては一見するとTurbo Driveの場合とあまり差がありません。しかし細かく見ると以下の点が異なります。

  • 少し下にスクロールした後にタブをクリックしても、トップにスクロールしません。デフォルトではスクロール位置が維持されます。より細かく制御したい場合は、autoscroll属性で調整できます
  • Searchのテキスト入力フィールドに文字を入力し、その後にタブを切り替えても、テキスト入力フィールドの文字はそのまま維持されます。フォーカスも維持されます。今回設定したTurbo Framesでは、Searchのテキスト入力フィールドはTurbo Framesの外にあります(下図)。タブが切り替わっても、SearchのDOM要素はそのままなのです。だから文字およびフォーカスが維持されています。

このようにTurbo Framesの特徴は画面を枠で分割し、枠内を置換しつつ、枠外をそのままに維持するところです。これらの機能が必要な場合はTurbo Framesを選択し、そうでない場合はTurbo Driveを選択するのが良いでしょう。

Turbo Frames実装方法 #

Turbo Framesによるタブメニューの作り方はごく簡単です。まずはTurbo Driveのバージョンから出発します。そして、どこをTurbo Framesで囲むかを決めます。今回はSearchのテキスト入力フィールドの下のところからテーブルの最後までを囲むことにします。

Turbo Framesによるタブメニュー
turbo frames image

次にエディタで該当するEJSファイルの内容を確認し、囲みたいところを <turbo-frame id="[適当な名前]"></turbo-frame>のタグで囲みます。今回は2つのページ (UsersProducts)がありますので、双方の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>

以上でおしまいです!

  1. templates/tabbed_segments_turboframes/index.ejs<turbo-frame>タグを加える
  2. templates/tabbed_segments_turboframes/products.ejsに同じIDの``タグを加える
  3. 必要に応じて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つ小さなブラウザウィンドウができたような感じに設計されています。
  • 今回はTurbo Frameの中にタブ(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の値を揃えておく必要があります。
  • Turbo Framesは通常aタグなので、Turbo Driveの機能であるprefetchも働きます。このためprefetchによるUXの大幅向上、ヌルサクな体感も、何もしなくても勝手についてきます。
  • turbo-frameタグにdata-turbo-action="replace"のHTML属性をつけています。この属性をつけることにより、turbo frameのエンドポイントがブラウザURLになります。その結果、タブごとにブックマークを作ったり、リンクを共有したりできます。data-turbo-actionタグをつけない場合は、タブを推してもブラウザURLは変更されません。

余計なHTMLを送ることは悪いことじゃない #

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 Drive, Frames, Streamsとサーバレスポンス
Turbo Drive, Frames, Streams from the Server's perspective

ローダーの表示 #

また、タブのロードに時間がかかってしまう時のために、ローダーの表示もさせています。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>
ファイル・フォルダ構成は下記のようになります
Tabs Turbo Frames

まとめ #

  • 実装が比較的簡単 (少数のタグ埋め込みとHTML/CSSの追加だけです)
  • Prefetchが効くので、レスポンスは速い
  • Turbo Drive Page cacheは効かないので、同じタブを再訪問するときはprefetchの分しか速くならない
  • SSRなのでSEOに強い
  • タブを切り替えた時にスクロール位置等のステートがリセットされない
  • タブごとに専用のURLを持つので、タブを指定したブックマークやリンクを共有できる (この機能をオフにもできる)

React: useEffectを使う方法 #

Reactでタブメニューを実装する方法を3つほど紹介します。

  • useEffectを使って、ブラウザで部分的にタブの内容を書き換える方法
  • SSR/SSGを使用してページ全体をHTMLとしてサーバから送信し、古典的なMPATurbo Driveと同様の方法で、見かけ上タブだけが切り替わったように見せる方法(App RouterのServer Componentも同じことができます)
  • App Router Parallel Routesを使う方法

まず最初にuseEffectを使った方法を紹介します。デモはここでお試しいただけます。コードはGitHubでご確認ください。

useEffect実装方法 #

Reactのコードの特徴は以下の通りです。一般的なReactのデータフェッチのパターンを複数回使うだけですので、ロジックは追いやすいと思います。

  • 選択されたタブをステートとして持つ (useStateを使用)
  • 条件付きレンダーのパターンを使って、ステートのよってタブの中に表示するコンポーネントを切り替える(今回はここにあるUsers.tsxProducts.tsx)
  • データはUsersProductsコンポーネントの中の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);
      })
  },[])
  ...

まとめ #

  • 実装がさほど難しくない (stateを新たに作る必要がある程度)
  • SSGであってもPrefetchは効かないので、レスポンスは速くない。ただしローディング画面瞬時に出せる
  • CSRなのでSEOに比較的弱い
  • タブを切り替えた時にスクロール位置等のステートがリセットされない
  • タブごとに専用のURLを持たないので、タブを指定したブックマークやリンクを共有できない (最初のタブへのリンクしか作れない)

Next.js SSRを使った方法 #

Next.js Pages RouterのSSRはページ全体を遷移するのには適していますが、Turbo Framesのように部分置換をする機能がありません。部分置換をするには従来のSPAと同様に、ブラウザ上でuseEffectを使ってステートを更新し、CSRで更新する必要があります。

しかしuseEffectを使った場合はCSRになるので、SEOでは不利になってしまいます。そこで古典的なMPATurbo Driveと同様の方法を使うことができます。ページ全体をサーバでレンダリングしつつ、見かけ上はタブだけが切り替わったようになります。

デモはこちらからご覧ください。またコードはGitHubでご確認ください。

Next.js SSR 実装方法 #

  • Next.jsでMPAを作る感じになります。ごく普通にpages/tabbed_segments_ssr/index.tsx pages/tabbed_segments_ssr/products.tsxgetServerSideProps()を使った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を使い、タブメニューを実装しています。

ニューズピックスのタブメニュー

まとめ #

  • 実装は簡単 (教科書的なgetServerSideProps等でSSRのページを作るだけ)
  • SSGであればPrefetchは効くので、レスポンスが速い。SSRの場合はprefetchが効かないので、Pages Routerの場合は別途ローダーを用意する必要があります。App Routerならsuspenseでローダーを簡単に表示できます
  • SSRなのでSEOに強い
  • タブを切り替えた時にスクロール位置のステートがリセットされない (Linkタグにscroll={false}をつける)
  • タブごとに専用のURLを持つので、タブを指定したブックマークやリンクを共有できる

Next.js App Router Parallel Routesを使った方法 #

Next.js Pages RouterにはSSRを使った部分置換の仕組みがないことは上述しました。

一方でApp Routerは部分置換の仕組みが用意されています。LayoutParallel Routesがこれに相当します。特にこのページで紹介しているタブメニューについては、Parallel Routesのドキュメントにわざわざタブメニューの言及もあり、この方法が奨励されていると受け取れます。

LayoutやParallel Routesを使うと、Client Componentを使わずにServer Componentだけでタブメニューが実装できます。App RouterではなるべくServer Componentを使うことが推奨されていますし、なるべくサーバでデータフェッチをすることがベストプラクティスとされています。LayoutやParallel Routesを使うと、これが可能になるわけです。

Parellel Routesを使ったデモも用意しましたのでご確認ください。またコードはGitHubにあります。

Parallel Routesを使った場合のUI/UXの特徴は以下の通りです

  • どのタブを開いているかによってURLが変化します。つまり特定のタブを開いた状態をブックマークできる利点があります。これはTurbo Driveを使った場合、あるいはTurbo Framesでdata-turbo-actionを設定した時と同じ効果です。
  • loading.jsを配置することでローディングアニメーションが出せます
  • データフェッチはサーバ側で行いますが、動的なルート(Dynamic Rendering)の場合は最初のloading.jsファイルまでしかprefetchをしてくれません。そのため、コンテンツが表示されるまでにかかる時間は短くなりません。ReactでuseEffectを使った場合とほぼ同じUXになります。

Parallel Routes 実装方法 #

Parallel RoutesのコードはGitHubに用意しています。

  • app/tabbed_segments_app/layout.tsxに、タブより上の部分をレイアウトファイルとして用意します
  • それぞれのタブの中身を表すファイルは@tabsフォルダの中に含めますので、このフォルダを作成します。この@tabsSlotと呼びます
  • 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はローディングアニメーションを用意したいところに適宜配置します
ファイル・フォルダ構成は下記のようになります
Tabs Parallel Routes

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の場合と比べて学習コストが高いと感じます

まとめ #

  • 実装方法がわかりにくい
  • SSGであればPrefetchが効くので、レスポンスは速い。動的コンテンツの場合はprefetchは効かないが、App Loaderであればloading.tsxでローディング画面をすぐに出せる
  • SSRなのでSEOに強い
  • タブを切り替えた時にスクロール位置等のステートがリセットされない
  • タブごとに専用のURLを持つので、タブを指定したブックマークやリンクを共有できる

タブメニューのまとめ #

今回はTurbo Framesによるタブメニューを実装しました。簡単なものだったため、UX的にMPAと大きな差はありませんでした。しかしTurbo Framesを使うと、の外のステートが維持できていることが確認できました。スクロール位置やフォーム要素のステート維持が必要な場合は、Turbo Framesを使う必要があります。

React、Next.jsと比較すると、Turbo Framesによるタブメニューは実装が簡単でありつつ、機能的にはむしろ豊富であることがわかりました。一方のReactは、少なくとも現時点では自在にHTMLを分割し、繋ぎ合わせることを得意としておらず、Layoutをルーティングと組み合わせ、最初から計画的な分割・繋ぎ合わせを要しているようです。Hotwireが当初からHTMLを繋ぎ合わせることを主目的としていたのに対して、Reactはstateを更新し、ブラウザで必要箇所を再レンダリングするモデルだったことが影響しているのだろうと思います。ただしRSCをさらに活用することにより、より簡単に、自在に繋ぎ合わせられるようになるかもしれません。