Commentary
モーダル
モーダルダイアログはUI要素としてウェブデザインで非常によく使われます。よって簡単にモーダルダイアログが作れることが開発者としては重要です。
HTMLにはdialog
タグも用意されていますので、表示自体は難しくありません。ただし内容をサーバからロードする場合はネットワーク遅延への対応などが発生します。これにうまく対応するか否かでUI/UXに差が出てきます。
下記ではUXおよび実装の簡単さを重視しつつ、モーダルダイアログの作り方を複数検討します。Hotwireに加え、Next.jsのApp RouterとPages Routerについてもモーダルの出し方を検討し、比較します。またdialog
タグはまだ新しいので、従来のHTML/CSSの組み合わせでモーダルダイアログを作ります。
モーダルダイアログ (Hotwire Stimulusバージョン)
モーダルはAJAXを使ったインタラクティブUIの代表例ですが、実はMPAでも実装できます。AJAXもfetchも使うことなく(完璧ではないものの)、実用的なUI/UXが非常に簡単に実現できます。
ポイントはページ遷移、タブメニュー、詳細パネルと同じで、画面全体は更新しつつ、一部しか変更されていないように見せます。
pages/api/hotwire/modal_mpa/index.ts
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<string>,
) {
const users: User[] = await allUsers()
const id = req.query.userId
let userDetail: User & UserDetail | null = null
if (id) {
if (!(typeof id === 'string')) throw "Bad request"
userDetail = await findUserWithDetails(parseInt(id))
}
const resultText = render("modal_mpa/index.ejs",
{users, userDetail}
)
res.appendHeader("Content-Type", "text/html")
.status(200)
.send(resultText)
}
/api/hotwire/modal_mpa
もしくは/api/hotwire/modal_mpa?userId=1
のようなURLでアクセスされることを想定しています。最後の?userId=1
はモーダルを表示するときの、モーダルの中に表示するUserのIDになりますallUsers()
を使って、users
変数にすべてのUserの情報を入れますreq.query.userId
でURLの?userId=1
の部分からuserIdを取得し、findUserWithDetails()
を使って詳細情報(userDetail
)を取得します。これはモーダルの中に表示する情報ですusers
とモーダルに表示する情報userDetail
の双方を、ejsテンプレートのmodal_mpa/index.ejs
に送りますtemplates/modal_mpa/index.ejs
...
<% if (userDetail) { %>
<%- include("_modal.ejs", {userDetail}) %>
<% } %>
...
pages/api/hotwire/modal_mpa/index.ts
エンドポイントから送られるuserDetail
が指定されていれば、_modal.ejs
パーシャルを表示しますpages/api/hotwire/modal_mpa/index.ts
エンドポイントから送られてくるuserDetail
がnull
の場合は、_modal.ejs
パーシャルを表示しません(モーダルを表示しないということ)templates/modal_mpa/_modal.ejs
<a class="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
href="/api/hotwire/modal_mpa">
a
タグを使用して、?userId=*
が無いページをリクエストしています。つまりモーダルがないページですMPAで作ったデモを用意していますので、ご覧ください。遅延(Delay)を設定できますので、300msの場合と2000msの場合を比較してください。
モーダルダイアログ (MPA バージョン)
次はHotwire/Turbo Framesを使って、カスタムのJavaScriptを書かずにモーダルを出す方法です。「Hotwireを使うとJavaScriptなしでモーダルダイアログが作れる」という話はよく聞きますが、その時の手法です。
カスタムJavaScriptなしのデモはこちらに用意しています。またコードはGitHubにあります。
サーバから動的な内容をモーダルを表示する際は、通常は2つステップが必要です。
しかし、サーバからのレスポンスと一緒にモーダルの「枠」も同時に送り返せば、モーダルを開くJavaScriptが必要なステップ1.は省略可能になります。先のMPAの場合と同じですが、この仕組みを引き継いでいるのがこの方法の特徴です。
pages/api/hotwire/modal_no_js/index.js
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<string>,
) {
const users: User[] = await allUsers()
const resultText = render("modal_no_js/index.ejs",
{users}
)
res.appendHeader("Content-Type", "text/html")
.status(200)
.send(resultText)
}
pages/api/hotwire/modal_no_js/modal/[userId].ts
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<string>,
) {
const id = req.query.userId
if (!(typeof id === 'string')) throw "Bad request"
const userDetail = await findUserWithDetails(parseInt(id))
const resultText = render("modal_no_js/modal.ejs",
{id: userDetail.id, userDetail}
)
res.appendHeader("Content-Type", "text/html")
.status(200)
.send(resultText)
}
templates/modal_no_js/modal.ejs
<turbo-frame id="modal">
...[モーダルのコンテンツおよびモーダルの枠]...
</turbo-frame>
<turbo-frame id="modal">
がありますので、ブラウザはこれを受信すると、ブラウザですでに表示されている<turbo-frame id="modal">
の箇所を置換しますtemplates/modal_no_js/index.ejs
<a href="/api/hotwire/modal_no_js/modal?id=<%= user.id %>"
data-turbo-frame="modal"
class="text-orange-600 underline inline-block active:scale-105"><%= user.name %></a>
a
タグをクリックすると、/api/hotwire/modal_no_js/modal?id=1
からモーダルの中身(HTML)がダウンロードされます<turbo-frame id="modal">
がありますので(上述)、ブラウザですでに表示されている<turbo-frame id="modal">
の箇所を置換します)templates/modal_no_js/index.ejs
<turbo-frame id="modal"></turbo-frame>
templates/modal_no_js/modal.ejs
<a class="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
href="/api/hotwire/modal_no_js">
onclick
等のイベントハンドラが使えません。ブラウザのインタラクションはa
タグやform
タグに限定されますa
タグを使用して、モーダル表示前のページをリクエストしています。その結果、モーダルが閉じたように見えますブラウザが受け取るHTMLレスポンスには空のturbo-frame
が含まれています。Turbo Framesはブラウザに表示されている既存のturbo-frame
をこの空のturbo-frame
に入れ替えます。その結果、ブラウザに表示されているturbo-frame
の中身は空になり、枠も無くなるのでモーダルが閉じます。ブラウザが受け取るHTMLはturbo-frame
だけではなく、templates/modal_no_js/index.ejs
のすべてですが、画面に反映される(差し込まれる)のはturbo-frames
の中身(空)だけであることに注意してください。画面全体が書き換えられずにモーダルの中だけが置換されますので、モーダルを閉じるときもスクロール位置が維持されます。
カスタムJavaScriptなしで作ったデモを用意していますので、ご覧ください。遅延(Delay)を設定できますので、300msの場合と2000msの場合を比較してください。
:active
を使っていますが、十分なフィードバックとは言えません)モーダルダイアログ (HotwireカスタムJavaScriptなし バージョン)
data-turbo-frame
属性を追加すること、<turbo-frame>
タグを追加することが必要になりますが、引き続き非常にシンプルなコードになっています上記のTurbo Frames: カスタムJavaScriptなしの問題点を解消し、ネットワークが遅い場合でも十分なUI/UXを提供するためには、レスポンスが来る前にフィードバックを提供する必要があります。そのためにはボタンを押した瞬間に起動するカスタムJavaScriptが必要です。
上記ではモーダルの「枠」をサーバから送ることでモーダルを開くステップを省略していました。しかしここではカスタムJavaScriptを使って、サーバレスポンスの遅延の有無に関わらず、リクエストを投げた瞬間にモーダルを開きます。これがユーザに対するフィードバックとなり、UI/UXの改善に繋がります。
なおHotwireでカスタムJavaScriptを書く場合は、一般にはStimulusを使います。しかし最初はとっつきにくいので、まずはインラインJavaScriptを使った例から紹介します。
カスタムJavaScriptなしの場合と比較して、以下のコードが追加されます。
turbo-frame
を入れておきますtemplates/modal_w_js/index.ejs
<div class="modal-dialog relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="modal-backdrop fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<div class="modal-panel fixed inset-0 z-10 w-screen overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
...
<div class="!min-w-64 !min-h-40 mt-5 sm:mt-6">
<turbo-frame id="modal" class="turbo-with-loader no-delay">
<div class="turbo-hide-on-loading ">
Loading...
</div>
</turbo-frame>
</div>
...
</div>
</div>
</div>
hotwire/styles/input.css
.modal-dialog .modal-backdrop {
@apply ease-out duration-300 opacity-0 invisible
}
.modal-dialog.modal-open .modal-backdrop {
@apply ease-out duration-200 opacity-100 visible
}
.modal-dialog .modal-panel {
@apply ease-out duration-300 translate-y-4 sm:translate-y-0 sm:scale-95 opacity-0 invisible
}
<turbo-frame id="modal">
が含まれていますtemplates/modal_w_js/modal.ejs
<turbo-frame id="modal">
...[モーダルのコンテンツ]...
</turbo-frame>
<turbo-frame id="modal">
がありますので、ブラウザがこのページを受け取ると、すでに表示されている<turbo-frame id="modal">
の箇所をこの内容で置換しますtemplates/modal_w_js/index.ejs
<script>
function openModal() {
const turboFrame = document.querySelector("turbo-frame#modal")
turboFrame.innerHTML = '<div>Loading... </div>'
const modal = document.querySelector(".modal-dialog")
modal.classList.add("modal-open")
}
function closeModal() {
const modal = document.querySelector(".modal-dialog")
modal.classList.remove("modal-open")
}
</script>
script
タグで囲んでインラインで書いています。通常であればStimulusを使うのですが、ここでは説明するためのわかりやすさを優先し、インラインJavaScriptを使っています。.modal-open
のCSSクラスをつけたり外したりするだけですopenModal()
では、turbo-frame
の"Loading..."と表示させ、ローディング中の表示をさせています。内容が異なっても同じモーダルを使うので、毎回リフレッシュするためです。これをやらないと前回開いた時の内容が一瞬モーダル内に表示されてしまいますtemplates/modal_w_js/index.ejs
<a href="/api/hotwire/modal_w_js/modal?id=<%= user.id %>"
data-turbo-frame="modal"
onclick="openModal()"
class="text-orange-600 underline inline-block active:scale-105">
<%= user.name %>
</a>(Inline JS)<br>
a
タグをクリックするとモーダルを表示するようにしています。onclick
属性でopenModal()
を呼び出して、モーダルを表示させていますa
タグはdata-turbo-frame="modal"
属性もありますので、同時にhref
で指定したエンドポイントにリクエストを飛ばし、返ってきたHTMLをturbo-frame id="modal"
に差し込みます。ここは上述の「カスタムJavaScriptなし」のケースと同じです「カスタムJavaScriptなし」のケースと大きく異なるのは、a
タグにこの2つの属性をつけることで、モーダルを表示させるコードとTurbo Frameにデータを読み込むコードを分割したことです。そのため、サーバからのレスポンスが遅延しても、それとは無関係に先にモーダルを表示しています
「モーダルの開閉」と「サーバからのデータの読み込み」を独立させたことにより、UXを改善しているわけです。
templates/modal_w_js/modal.ejs
<button class="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
onclick="closeModal()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="mx-auto size-6 text-orange-600">
...
</svg>(Inline JS)
</button>
button
タグでモーダルを閉じます。onclick="closeModal()"
がありますので、これが先ほど用意したcloseModal()
を呼び出して、モーダルを閉じています。JavaScriptを使った例のデモを用意していますので、お試しください。他の例も同じデモ画面に含めていますので、"Inline Javascript"のUI/UXを確認する場合は、"Inline JS"と書いてあるボタンを押してください。
モーダルダイアログ(Inline JavaScriptバージョン)
Turbo Framesを使うとカスタムJavaScriptなしでもモーダルが作れます。宣伝文句としてはインパクトがあるせいか、このやり方を非常に多く見かけるように思います。しかしHotwireがStimulusとセットになっていることからも分かるように、HotwireはむしろカスタムJavaScriptを書くことを奨励しています。Hotwire流とはJavaScriptを全く書かないことではなく、JavaScriptをたくさん書かないことです。そしてここで確認したように、わずか10行程度のJavaScriptでUI/UXを大きく改善できました。
ぜひ「JavaScriptなし」の宣伝文句に踊らされず、UI/UXを改善するたも少量の簡単なJavaScriptを使うようにしていただければと思います。
Inline JavaScriptの項では、カスタムJavaScriptを使ったおかげでモーダルが瞬時に表示されるようになりました。そしてサーバからデータが送られるのを待つ間は"Loading..."と表示されるようにしました。
これでも良いのですが、Turboはロード中にbusy
のHTML属性を自動的に付けてくれますので、CSSだけでローディングアニメーションを表示させることもできます。ここではこの方法を紹介します。
デモで使用しているコードはもう少し複雑ですが、ポイントを下に記しました。
hotwire/styles/input.css
.turbo-with-loader .turbo-hide-on-loading {
/* GIFイメージは`position: absolute`で配置するので、
ここを`position: relative` にする*/
position: relative;
}
.turbo-with-loader .turbo-hide-on-loading::before {
visibility: hidden !important;
opacity: 0 !important;
}
.turbo-with-loader[busy] .turbo-hide-on-loading {
visibility: hidden !important;
}
.turbo-with-loader[busy] .turbo-hide-on-loading::before {
content: '';
visibility: visible !important;
opacity: 1 !important;
position: absolute !important;
display: block;
background-image: url('../../images/rocket.gif');
width: 64px;
height: 64px;
top: 40px;
left: 50%;
transform: translateX(-50%);
}
turbo-frame
にはturbo-with-loader
CSSクラスを付けています。turbo-hide-on-loading
で囲っています。ここの::before
擬似要素にローディングのGIFアニメーションを表示しますturbo-with-loader
CSSクラスがついているturbo-frame
のbusy
属性で切り替えます(.turbo-with-loader[busy]
セレクタ)。busy
なら隠したい部分(turbo-hide-on-loading
を持つ)が非表示になり、かつ::before
のところのGIFアニメーションが表示されますturbo-with-loader
とturbo-hide-on-loading
を適切にHTMLに付けてあげるだけでローディングアニメーションが動きますデモ画面の"CSS loader"と付いているボタンを押してください。このボタンを使うと、turbo-hide-on-loading
がついたHTML要素が使われ、適切にアニメーションが動きます。特にサーバレスポンスを遅延させてお試しください。
モーダルダイアログ(CSSローディングアニメーションバージョン)
turbo-frame
要素にbusy
属性を付けてくれます(busy-aria
属性も付けてくれます)openModal()
の中でHTMLを直接変更し、"Loading..."を表示させることもできます。適宜使い分けていただくのが良いと思います上記のように、Turbo FramesとカスタムJavaScriptおよびCSSを使うと、かなりUI/UXの良いモーダルダイアログが作成できます。サーバからリクエストを受け取ってデータを表示しているにも関わらず、必要なコードは少なく、処理の流れも直感的でわかりやすくなっています。
しかしHTMLのonclick
属性を使用しているところは問題です。onclick
属性等のインラインのイベントハンドラーはとても使いやすいのですが、一般には使用するべきではないとされています。そこでここではHotwireに含まれるStimulusを使います。
Stimulusは下記の特徴があります。
addEventListener()
より使い勝手が良いですStimulusはDOM MutationObserver APIを使用してDOMの変更を監視し、そのタイミングでイベントハンドラーを接続します。従来はload
イベントを受け取りelement.addEventListener()
を使うことが多かっのですが、SPAではload
イベントが発火しないために問題があります。動的に読み込まれる要素が多いウェブページであっても、Stimulusは確実にかつ簡単にイベントハンドラーを接続してくれます。なおReactの場合はuseEffect
を使ってコンポーネントがロードを検知できるので、SPAのload
問題を回避できています
Stimulusを使うと、コードを小さいコントローラに分割することになりますので、コードの整理整頓につながります。またdata-controller="..."
属性をHTML側につけますので、どのHTML要素がどのStimulus Controllerを呼び出すのが明快です。jQueryや素JavaScriptのaddEventListener()
を多用した場合と比べると大きな改善です
今回は各ポイントに軽く触れながら、Stimulusで書いたコードを紹介します。CSSローディングアニメーションをつけたデモをベースに話します。
templates/modal_w_js/index.ejs
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"
data-controller="labeler"
data-labeler-selector-value=".modal-dialog"
data-labeler-label-class="modal-open"
>
...
<a href="/api/hotwire/modal_w_js/modal?id=<%= user.id %>"
data-turbo-frame="modal"
data-action="click->labeler#add"
class="text-orange-600 underline inline-block active:scale-105">
<%= user.name %>
</a>(Stimulus)
...
</div>
a
タグをクリックしたときにモーダルが表示されますpublic/hotwire/javascript/labeler_controller.js
下で解説)のに記述されています。data-controller
, data-labeler-selector-value
, data-labeler-label-class
, data-action
のHTML属性の組み合わせにより、下記のStimulus Controllerのコードが呼び出されています
data-controller="labeler"
は、このdiv
タグに囲まれている部分をjavascript/labeler_controller.js
に繋げますdata-labeler-selector-value=".modal-dialog"
、data-labeler-label-class="modal-open"
はjavascript/labeler_controller.js
の動きをカスタマイズするための引数です。どのモーダル「枠」を制御するか、および開閉指示に使用するCSSクラスを指定しています。一見すると冗長ですが、これによって他の場面でも同じStimulus Controllerが使用でき、再利用性が高まりますa
タグをクリックすると、data-action="click->labeler#add"
属性の指示により、Stimulus Controllerのadd()
メソッドが呼び出されますpublic/hotwire/javascript/labeler_controller.js
import {Controller} from "/hotwire/javascript/stimulus.js"
export default class extends Controller {
static values = {"selector": String}
static classes = ["label"]
add() {
const labelable = document.querySelector(this.selectorValue)
labelable.classList.add(this.labelClass)
}
remove() {
const labelable = document.querySelector(this.selectorValue)
labelable.classList.remove(this.labelClass)
}
}
add()
メソッドはthis.selectorValue
で指定されたDOM要素に、this.labelClass
で指定されたCSSクラスをつけます。remove()
は逆にCSSクラスを外しますthis.selectorValue
およびthis.labelClass
は、それぞれ上述の「モーダル表示のトリガー」で指定したdata-labeler-selector-value=".modal-dialog"
およびdata-labeler-label-class="modal-open"
の値が入ってきていますa
タグのdata-action="click->labeler#add"
により、クリック時にadd()
が呼び出されますtemplates/modal_w_js/index.ejs
<button class="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
data-action="click->labeler#remove keydown.esc@window->labeler#remove">
button
タグのdata-action="click->labeler#remove"
により、クリックされると上述のStimulus Controllerのremove()
メソッドが実行され、モーダルを閉じますbutton
タグのdata-action="keydown.esc@window->labeler#remove"
により、window
にkeydown
イベントハンドラーが接続されていますので、Escキーが押されたときにStimulus Controllerのremove()
が呼び出され、モーダルを閉じてくれますこのようにStimulusを使うとキーボードショートカットも非常に簡単に実装できます。
Stimulusを使ったデモで"Simulus"ボタンをクリックすることでUI/UXを確認できます。CSS loaderのInline JavaScriptを使った場合と動作は同じですが、"ESC"ボタンでモーダルを閉じる機能が追加されています。
比較のためにReactのuseEffectでモーダルを出す一般的な方法をおさらいします。こちらにuseEffectのデモを用意しています。コードはGitHubにあります。
pages/modal/index.tsx
export default function ModalIndex({users}: {users: User[]}) {
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
return (
<Layout>
<>
...
<button className="underline text-orange-600 inline-block active:scale-105"
onClick={() => setSelectedUserId(i + 1)}>{user.name}</button>
...
</>
{ selectedUserId && <Modal id={selectedUserId} closeModal={() => setSelectedUserId(null)} />}
</Layout>
)
}
selectedUserId
をuseState()
で作っています。Hotwireの場合は明示的にステートを用意せず「画面に表示されているものがステートだ!」という立場を取っているのと対照的です。button
タグのonClick={() => setSelectedUserId(i + 1)}
属性により、クリックするとselectedUserId
ステートにIntegerがセットされます{ selectedUserId && <Modal id={selectedUserId} ... />}
の箇所で、条件付きレンダーを使っています。selectedUserId
がセットされているとModal
コンポーネントをレンダリングしますbutton
タグのcloseModal={() => setSelectedUserId(null)}
はモーダルを閉じる時の関数です。selectedUserId
ステートをnull
にすればモーダルは閉じられますcomponents/Modal.tsx
export default function Modal({closeModal, id}: {
closeModal: () => void,
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])
useEffect(() => {
const close = (e: KeyboardEvent) => {
if(e.key === "Escape"){
closeModal()
}
}
window.addEventListener('keydown', close)
return () => window.removeEventListener('keydown', close)
}, [])
return <>
<div>
<div className="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
onClick={closeModal}>
...
</div>
</div>
<div className="mt-5 sm:mt-6">
{
!userDetail
? <div>
<Image src={rocketImage} alt="loader" className="m-auto mt-10 w-16 h-16"/>
</div>
: <div>
...[モーダルの内容]...
</div>
}
</div>
</>
}
Modal
コンポーネントにはuseEffect
が2つあります
id
が変更されたときにサーバにリクエストを投げて、userDetail
取得しています。これはモーダルの内容を表示するために使用されますuseEffect
はEscapeキーでモーダルが閉じられるように、keydown
イベントハンドラーをwindow
に接続するものですuserDetail
がまだロードされていない時は、条件付きレンダーでローディング画面を表示しますモーダルダイアログ(React useEffectバージョン)
ReactではRSC(React Server Components)が話題になっていますので、Client Componentを使ってモーダルを作りました。こちらにClient Componentのデモを用意しています。コードはGitHubにあります。
結論から言うと、useEffectでモーダルを出す場合と書き方もUI/UXも差がありません。確認した限りではClient Componentの書き方はPages routerでuseEffect
を使った場合と同じになります。
(useEffect
の場合とほぼ同じため、ここでは解説を省きました)
モーダルはインタラクティブな要素ですので、上記のようにClient Componentで作るのが王道に思えます。公式ドキュメントでもClient Componentのメリットを下記のように紹介しています。
Interactivity: Client Components can use state, effects, and event listeners, meaning they can provide immediate feedback to the user and update the UI.
一方でParallel RoutesとIntercepting Routesを使って、Server Componentでモーダルを実現する例が公式ドキュメントで紹介されています。
そこで、ここでは実際にServer Componentでモーダルを作って見ながら、そのUI/UXやコードの書き味を確認したいと思います。ただしIntercepting routesとParallel routesは複雑なので、まず最初に簡易法を紹介し、その次にParallel routesによるを紹介します。なお簡易法はここのブログ記事で知りました。
コードは簡単なものです。考え方はMPAを使った方法とほぼ同じです。
app/modal_app/page.tsx
async function getUsers(): Promise<User[]> {
console.log("Fetch start for Users")
const res = await fetch(process.env.URL + "/api/users")
const users = await res.json()
return users
}
export default async function ModalAppPage({searchParams}: { searchParams: { userId: string | undefined } }) {
const userId = searchParams.userId
const users = await getUsers()
return (
<>
...[ページのコンテンツはここ]...
<Link href={`/modal_app?userId=${user.id}`}
className="underline text-orange-600 inline-block active:scale-105"
scroll={false}>
{user.name}
</Link>
...[ページのコンテンツはここ]...
{userId && <Modal userId={userId}/>}
</>
)
}
/modal_app
でこのページにアクセスした場合はモーダルが表示されない/modal_app?userId=1
でこのページにアクセスした場合は、userId=1のUserの情報がモーダルに表示されるこれを実装するために以下のロジックになっています。
Link
タグのところをクリックすると、/modal_app?userId=1
などに遷移しますuserId = searchParams.userId
のところで、URLの?userId=1
の箇所を読み取り、userId
変数に格納します{userId && <Modal userId={userId}/>}
で条件付きレンダーをして、userId
がセットされている場合はModal
コンポーネントを表示しますなおLink
タグにはscroll={false}
がありますので、リンク先に遷移する場合でもスクロール位置が変わらず、元の画面のステートが保たれます。
このように、「モーダルだけ」を表示するように見せかけているが、実際にはページ全体が再表示されています。でもネットワークが速い場合はこれでもUI/UXとしては十分で、全く気になりません。
app/modal_app/components/Modal.tsx
export default async function Modal({userId}: { userId: string }) {
const userDetail = await getUserDetails(userId);
return (
...[モーダルの内容]...
<Link className="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
href={`/modal_app`}
scroll={false}
prefetch={true} >
...[ボタンのイメージ]...
</Link>
...[モーダルの内容]...
)
}
a
タグとform
タグしか使えません。ここではa
タグを/modal_app
に向けました。/modal_app
は1. で示した通り、モーダルを表示しませんので、実際には画面全体が再表示されているのですが、見かけ上はモーダルだけが消えたように映りますServer Componentsでモーダルを出す簡易法のデモはこちらで、コードはGitHubにあります。
モーダルダイアログ(Server Components簡易法)
Server Componentを使ったもう1つの方法を紹介します。Parallel Routesを使用します。
私が理解している限りでは、Reactは元々コンポーネント全体をレンダリングすることを想定していました。ただのテキストであるHTMLと異なり、Turbo FramesやTurbo Streamsのように単純に分割したり貼り付けたりすることが得意ではありません。そこでNext.jsがApp Router導入したのがLayoutやParallel routesです。
そしてParallel Routesを使ったモーダルの作り方が公式ドキュメントで紹介されています。ここではそのやり方を紹介します。
コードはGitHubに掲載しています。またデモはこちらでご確認いただけます。
パーツが多いので、概要だけ示します
layout.tsx
: 部分置換をしても変わらない部分はlayout.tsx
に配置します。今回のケースではUserのリスト(UserList
コンポーネントとして分割)はlayout.tsx
に配置します。またlayout.tsx
にはモーダルを表示するための「窓」を{modal}
として用意しています。これは@modal
フォルダの中を参照するようにNext.jsで自動的に関連づけられています@modal/page.tsx
: ブラウザが/modal_app_parallel
にアクセスした時、上記のlayout.tsx
の{modal}
に差し込まれるのが@modal/page.tsx
です。モーダルは表示しませんので、何も表示しません。そこでnull
を返します@modal/users/[userId]/page.tsx
: ブラウザが/modal_app_parallel/users/1
にアクセスした時、上記のlayout.tsx
の{modal}
に差し込まれる内容です。[userId]
の内容を読みとり、モーダルの枠およびUserのデータを返します@modal/loading.tsx
: ブラウザが/modal_app_layout/users/1
にリクエストを投げ、レスポンスを待っている間にlayout.tsx
の{map}
に差し込まれる内容です。モーダルの枠およびローディングアニメーションを含んでいます。コードの詳細の解説は今回は省略させていたきますが、難しいのは各ファイル・フォルダの役割と配置です。他の実装方法と比較するとかなり複雑で、かなり慣れが必要です。
モーダルダイアログ(Server Components Parallel Routes法)
コードが複雑なのがこのアプローチの欠点だと思います。
今回は大きく分けて7通り、細かく分けると9通りのモーダルダイアログの出し方を検討しました。これでも全然網羅的ではなく、他の方法もきっとあるはずです。その中で以下のことが言えるのではないかと思います。
useEffect
などを使い、ブラウザでデータをフェッチし、かつJavaScriptでDOMを操作するのが良さそうです。これはPages RouterでもApp Router (Client Component)でも変わりません。コードも簡単でわかりやすく、UI/UXも優れています最後に、現時点のReact Server ComponentsはUI/UX的にも、コードを書くときの考え方にしても、MPAと酷似していると感じます。RSC payloadを自在に組み立てられるようになると、Hotwire的な使い方ができるようになるのではないかと思います。実際、Server ActionからJSX (というかRSC payload)が返せるという話もあるので、近い将来にこの方向に行く可能性も感じます。