Top

Commentary

Details Panel

詳細パネル

どのような詳細パネルを作成するか #

タブメニューの作り方の紹介ではTurbo Framesを使いました。ここではTurbo Framesの別の使い方を紹介するために、詳細パネルの出し方を紹介します。違いはページのどこを部分置換するかだけです。

詳細パネルのデモも用意しています。そしてTurbo Framesを使った実装だけではなく、ただのMPAおよびNext.js/React useEffectを使った実装も用意し、比較したいと思います。

MPAによる詳細パネル #

タブメニューもMPAで作成できましたが、詳細パネルもMPAで作成できます。MPAの場合のデモはこちらで用意しています。

コード #

コードはGitHubに用意しています(HTMLテンプレートコントローラ)。

api/hotwire/details_panel_mpa/index.ts

  export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<string>,
  ) {
    const users: User[] = await allUsers()

    const id = req.query.id
    let userWithDetails: (User & UserDetail) | null = null
    if (id) {
      if (!(typeof id === 'string')) throw "Bad request"
      userWithDetails = await findUserWithDetails(parseInt(id))
    }

    const resultText = render("details_panel_mpa/index.ejs",
      {users, userWithDetails}
    )

    res.appendHeader("Content-Type", "text/html")
      .status(200)
      .send(resultText)
  }

templates/details_panel_mpa/index.ejs

  ...
  <a href="<%= `/api/hotwire/details_panel_mpa?id=${user.id}` %>" data-turbo-frame="user-detail-frame"
     class="block w-full h-full py-4 pl-4 pr-4 sm:pl-0"
     onclick="highlight({active: '#user-<%= user.id %>', inactive: '.user-row'})"
  >
    <%= user.name %>
  </a>
  ...
  <div class="mt-8 border p-4 rounded min-h-44">
    <%- userWithDetails ? include("_user.ejs", {userWithDetails}) : "" %>
  </div>
  ...
  • コントローラでHTTPのクエリパラメータidreq.query.idから取得して、該当するUserDetailfindUserWithDetails()で取得しています
  • UserDetailをHTMLテンプレートのdetails_panel_mpa/index.ejsに渡します
  • HTMLテンプレートのdetails_panel_mpa/index.ejsではパーシャルのdetails_panel_mpa/_user.ejsの内容を挿入しています。これが詳細パネルの内容に相当します
  • 一覧のリストの各行は/api/hotwire/details_panel_mpa?id=${user.id}へのリンクになっており、HTTPのクエリパラメータidに該当Userのidを含めています。このリンクをクリックするとコントローラでそのユーザの詳細が取得され、HTMLテンプレートの詳細パネルでそのユーザの詳細が表示されるわけです

なおポイントとしては、詳細パネルの中身だけが変更されるように見えますが、実際にはサーバかはページ全体がその都度送信され、ブラウザでも毎回ページ全体をロードしなおしています。

UI/UX #

MPAの場合のデモはこちらで用意しています。

  • 遅延(Delay)が小さい場合はすぐに詳細パネルの内容が表示され、ストレスがありません
  • MPAなのでクリックの都度画面全体がロードしなおされていますが、コンテンツシフトがないので気付きません。詳細パネルの中身だけが更新されているように感じます
  • 遅延(Delay)を大きくした場合は詳細パネルの内容が表示されるまで時間がかかりますが、ブラウザのプログレスバーを通したフィードバックがあるので、ブラウザが正常に動作していること、ネットワークのリクエストが飛んでいることが確認できます
  • スクロールして下の方のエントリをクリックしたときに分かる通り、再描画の都度画面の一番上までもスクロールします

まとめ #

  • 全体として不満のないUI/UXが実現されています
  • コードも非常にシンプルでわかりやすくなっています

Turbo Framesによる詳細パネル表示 #

詳細パネルでTurbo Framesを使うには、少しだけタブメニューの場合より考え方を膨らまします。クリックするHTML要素が所属するTurbo Frameを変えるのではなく、離れたところのTurbo Frameを更新するという考え方になります。

コード: 別のTurbo Frameを更新するパターン #

Turboはfetchでサーバに非同期的にリクエストを飛ばし、レスポンスを使ってページの部分置換をするライブラリです。Turbo DriveTurbo FramesTurbo Streamsの3種類がありますが、置換場所をどのように指定するかが異なります。

タブメニューを実装したときは、"Turbo Frames" > "トリガーを囲むturbo-frameタグ"を置換する 仕組みを使いました。今回の詳細パネルでは、"Turbo Frames" > "任意のturbo-frameタグ"を置換する 仕組みを使用します。

Turboはどこを置換するか?
技術置換場所注記
Turbo Drivebodyタグ全体
Turbo Framesトリガー を囲むturbo-frameタグ
任意のturbo-frameタグaタグやformタグのdata-turbo-frame属性で指定する
Turbo Streamsidで指定した任意の要素レスポンス中のturbo-streamタグのid属性で対象要素を指定する
※)トリガーは一般にaタグもしくはformタグになります。またGETでかつTurbo Streams以外であればJavaScriptからTurbo.visit()を呼び、Turboを稼働することも可能です。JavaScriptでGET以外、もしくはTurbo Streamのリクエストを飛ばしたい場合はrequest.jsライブラリを使うのが便利です。

コード: 右側の詳細パネルの部分置換をする #

下図のように、タブメニューの場合は、turbo-frameがクリックするaタグ(タブ)を囲んでいました。aタグをクリックした時、それを包む最も近いturbo-frameを探し出し、そのturbo-frameを部分置換すると判断したのです。

今回はaタグにdata-turbo-frame属性を持たせ、どのturbo-frameを部分置換するかを明示的に指定します。コードは下記のようになります。

特にuser-detail-frameを使って、aタグと詳細パネルを繋げていることに注目してください。

こうすることで、aタグのリンク先から返されるHTMLからuser-detail-frameの箇所が切り取られ、詳細パネルのturbo-frameの内部を置換します。

templates/details_panel/index.ejsaタグ周り

<a href="<%= `/api/hotwire/details_panel/user?id=${user.id}` %>"
   data-turbo-frame="user-detail-frame"
   class="block w-full h-full py-4 pl-4 pr-4 sm:pl-0"
   onclick="highlight({active: '#user-<%= user.id %>', inactive: '.user-row'})"
>
  <%= user.name %>
</a>

templates/details_panel/index.ejs の詳細パネル周り

<div class="mt-8 border p-4 rounded min-h-44">
  <turbo-frame id="user-detail-frame"
               class="turbo-with-loader">
    <div class="turbo-hide-on-loading"></div>
  </turbo-frame>
</div>
Details with Turbo Frames

UI/UX #

Turbo Frames版のデモはこちらで用意しています。

  • 遅延(Delay)が小さい場合はすぐに詳細パネルの内容が表示され、ストレスがありません
  • 遅延(Delay)を大きくした場合は詳細パネルの内容が表示されるまで時間がかかりますが、クリックした瞬間にローディングアニメーションが表示されますので、効果的なフィードバックがあります。ブラウザが正常に動いていること、ネットワークリクエストが飛んでいることがわかって、ユーザは安心します
  • Turboのprefetchが有効になっていますので、MPAの場合と比べてページが速くロードします
  • スクロールして下の方のエントリをクリックしても、スクロール位置は維持されます

まとめ #

  • 全体として不満のないUI/UXが実現されています
  • Prefetchが効いてページのロードが速くなっていますので、MPAよりも快適なUI/UXになっています
  • スクロール位置も維持されますので、詳細パネルだけが更新されてことが明確であり、MPAよりも優れたUI/UXになっています
  • コンセプトの理解に少し苦労しますが、コードは少なくわかりやすいものです

React useEffectによる詳細パネル #

タブメニューの場合と同様に、詳細パネルをNext.jsで実装するにはuseEffect()等を使う方法と、App RouterのParallel Routesを使う方法があります。ただし現時点では一般的であること、かつシンプルであることを考慮してuseEffect()による実装を確認します。

useEffect()を使ったタブメニューのデモはこちらにあります。

コード #

詳細パネルをReact/Next.jsで実装したコードはGitHubで公開しています。

pages/details_panel/index.tsx

...
export async function getServerSideProps() {
  console.log("Fetch start for Users SSR")
  const res = await fetch(process.env.URL + "/api/users")
  const users = await res.json()
  return {props: {users}}
}

export default function DetailsPanelIndex({users}: { users: User[] }) {
  const [selectedUser, setSelectedUser] = useState<User | null>(null);

  function showDetails(user: User) {
    setSelectedUser(user)
  }
...
  <tr key={i}
      className={`hover:cursor-pointer divide-x divide-gray-200 ${user.id === selectedUser?.id ? 'bg-yellow-100' : ''}`}
      onClick={() => showDetails(user)}
  >
...
  <div className="mt-8 border p-4 rounded min-h-44">
    { selectedUser && <UserDetailPanel id={selectedUser.id} /> }
  </div>
...

components/details_panel/UserDetailPanel.tsx

export default function UserDetailPanel({id}: {id: number}) {
  const [userDetail, setuserDetail] = useState<User & UserDetail|null>(null);

  useEffect(() => {
    setuserDetail(null)
    fetch(`/api/user/${id}`).then(res => res.json())
      .then(data => {
        setuserDetail(data)
      })
  }, [id])
  • selectedUserステートを作成し、現在選択されているUserをステートとして管理ます
  • selectedUserステートはshowDetails(User)によって更新されます
  • UserDetailPanelコンポーネントはselectedUseridをpropsとして受け取り、これが変更されるとuseEffect()の中でサーバからUserの詳細情報をサーバから受け取り、userDetailステートを更新します
  • userDetailステートに基づいてUserDetailPanel(詳細パネル)が再描画されます

UI/UX #

React useEffect版のデモはこちらで用意しています。

  • 遅延(Delay)が小さい場合はすぐに詳細パネルの内容が表示され、ストレスがありません
  • 遅延(Delay)を大きくした場合は詳細パネルの内容が表示されるまで時間がかかりますが、クリックした瞬間にローディングアニメーションが表示されますので、効果的なフィードバックがあります。ブラウザが正常に動いていること、ネットワークリクエストが飛んでいることがわかって、ユーザは安心します
  • Next.jsのprefetchはReactのuseEffect()には無効ですので、新しい内容が詳細パネルに表示されるまでの時間はMPAと変わりません。
  • スクロールして下の方のエントリをクリックしても、スクロール位置は維持されます

まとめ #

  • 全体として不満のないUI/UXが実現されています
  • Prefetchが効きませんので、新しい内容が表示されるまでの時間はMPAと変わりません
  • スクロール位置も維持されますので、詳細パネルだけが更新されてことが明確であり、MPAよりも優れたUI/UXになっています
  • Reactに慣れている人にとっては、コードは少なくわかりやすいものです

感想 #

React useEffectを使った方法がMPAより優れているのは、スクロール位置の維持など、ステートの維持になります。Turbo Framesを使うと、これに加えてprefetchによる高速なレスポンスが得られます。UI/UX的にもっとも優れているのはTurbo Framesといえます

一方で、ただのMPAでもかなり十分なUI/UXが実現できていました。コードも一番シンプルなので、敢えてTurbo FramesやReactを使うのではなく、ただのMPAで十分なケースも多いでしょう。またただのMPAにTurbo Driveを追加すればprefetchが使えます。これについては今回例示していませんが、タブメニューの方でも議論しました

このようにHotwireではTurbo Drive, Turbo Frameを使った複数の実装方法があります。UI/UXの要求とコードをどれだけシンプルに保ちたいかをバランスしながら、最適な方法を選択すれば良いと思います。