Commentary
詳細パネル
タブメニューの作り方の紹介ではTurbo Framesを使いました。ここではTurbo Framesの別の使い方を紹介するために、詳細パネルの出し方を紹介します。違いはページのどこを部分置換するかだけです。
詳細パネルのデモも用意しています。そしてTurbo Framesを使った実装だけではなく、ただのMPAおよびNext.js/React useEffectを使った実装も用意し、比較したいと思います。
タブメニューも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>
...
id
をreq.query.id
から取得して、該当するUserDetail
をfindUserWithDetails()
で取得していますUserDetail
をHTMLテンプレートのdetails_panel_mpa/index.ejs
に渡しますdetails_panel_mpa/index.ejs
ではパーシャルのdetails_panel_mpa/_user.ejs
の内容を挿入しています。これが詳細パネルの内容に相当します/api/hotwire/details_panel_mpa?id=${user.id}
へのリンクになっており、HTTPのクエリパラメータid
に該当Userのidを含めています。このリンクをクリックするとコントローラでそのユーザの詳細が取得され、HTMLテンプレートの詳細パネルでそのユーザの詳細が表示されるわけですなおポイントとしては、詳細パネルの中身だけが変更されるように見えますが、実際にはサーバかはページ全体がその都度送信され、ブラウザでも毎回ページ全体をロードしなおしています。
MPAの場合のデモはこちらで用意しています。
詳細パネルでTurbo Framesを使うには、少しだけタブメニューの場合より考え方を膨らまします。クリックするHTML要素が所属するTurbo Frameを変えるのではなく、離れたところのTurbo Frameを更新するという考え方になります。
Turboはfetch
でサーバに非同期的にリクエストを飛ばし、レスポンスを使ってページの部分置換をするライブラリです。Turbo Drive、Turbo Frames、Turbo Streamsの3種類がありますが、置換場所をどのように指定するかが異なります。
タブメニューを実装したときは、"Turbo Frames" > "トリガーを囲むturbo-frame
タグ"を置換する 仕組みを使いました。今回の詳細パネルでは、"Turbo Frames" > "任意のturbo-frame
タグ"を置換する 仕組みを使用します。
技術 | 置換場所 | 注記 |
---|---|---|
Turbo Drive | body タグ全体 | |
Turbo Frames | トリガー※ を囲むturbo-frame タグ | |
任意のturbo-frame タグ | a タグやform タグのdata-turbo-frame 属性で指定する | |
Turbo Streams | id で指定した任意の要素 | レスポンス中の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.ejs のa
タグ周り
<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>
Turbo Frames版のデモはこちらで用意しています。
タブメニューの場合と同様に、詳細パネルを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
コンポーネントはselectedUser
のid
をpropsとして受け取り、これが変更されるとuseEffect()
の中でサーバからUserの詳細情報をサーバから受け取り、userDetail
ステートを更新しますuserDetail
ステートに基づいてUserDetailPanel
(詳細パネル)が再描画されますReact useEffect版のデモはこちらで用意しています。
useEffect()
には無効ですので、新しい内容が詳細パネルに表示されるまでの時間はMPAと変わりません。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の要求とコードをどれだけシンプルに保ちたいかをバランスしながら、最適な方法を選択すれば良いと思います。