Commentary
ライブ検索
ライブ検索はリアルタイムで検索するUIです。「検索」ボタンを押さなくても、入力が途中でも検索が実行されるものです。「検索」ボタンを押すイベントではなく、input
要素のonInput
イベントに反応してサーバに問い合わせを行うだけなので、MPAであっても一見実装は簡単です。
しかしMPAだと大きな問題が一つあります。検索フィールドのステートがリセットされることです。このためMPAだけではまともなUIを作るのは困難です。
ここではMPAのときの問題を紹介するところかは初めて、次にHotwireで検索フィールドのステートをどのように保持するかを紹介します。最後にReactを使った方法を紹介し、UI/UXを比較します。
またリアルタイム検索の場合はサーバの遅延が大きいと、どうやっても辛いUI/UXになってしまうので、遅延が300msの場合しか想定しません。
ここで作るUI (Hotwire Turbo Framesの例)
MPAライブサーチのデモを用意していますので、ご確認ください。UI/UXのところで詳しく解説しますが、70%ぐらいはできるのですが、残りの30%で大きな課題があります。
pages/api/hotwire/live_search_mpa/index.ts
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<string>,
) {
const queryString = req.query["query"]
let users = (typeof queryString === "string" && queryString.length > 0)
? await searchUsers(queryString)
: await allUsers()
const resultText = render("live_search_mpa/index.ejs",
{users, queryString}
)
res.appendHeader("Content-Type", "text/html")
.status(200)
.send(resultText)
}
req.query["query"]
で検索文字列を受け取り、空白の場合はallUsers()
で全ユーザを返し、何か文字が含まれていたらsearchUsers(queryString)
を返しますtemplates/live_search_mpa/index.ejs
<form method="get">
<label for="search" class="text-sm mr-2">Search</label>
<input id="search" type="search"
oninput="this.form.requestSubmit()"
name="query"
value="<%= queryString %>"
class="border rounded p-1"
/>
</form>
form
を使ってinput
の値を現在のエンドポイントに送信しますoninput="this.form.requestSubmit()"
のところだけです。通常はSubmitボタンを押したり、input
タグの中でEnterを押すことによってform
が送信されますが、リアルタイム検索を実現するためにoninput
イベントハンドラからthis.form.requestSubmit()
でform
を自動的に送信しますinput
タグからfocusが外れてしまうので、次の文字を入力するのが困難ですoninput="this.form.requestSubmit()"
を追加するだけでライブ検索が実現しますinput
タグからfocusが外れてしまいますので、実用的ではありませんautofocus
を使って、ページリロード後にfocusを再びinput
に持ってくることも可能ですが、少し無理がありますHotwireのTurbo Driveには、Morphingという機能があります。これは既存ページのステートの維持を実現するものです。
スクロール位置の他、画面の特定領域のステートを保持できます。
templates/live_search_morph/index.ejs
<%- include("../layouts/header_morph.ejs") -%>
...
<form method="get" data-turbo-action="replace">
<label for="search" class="text-sm mr-2">Search</label>
<input id="search" type="search"
oninput="this.form.requestSubmit()"
name="query"
value="<%= queryString %>"
class="border rounded p-1"
/>
</form>
<head>
タグの中身を書き換える必要があります。そこでlayouts/header_morph.ejs
を読み込みますform
にdata-turbo-action="replace"
をつけて、Turboにリフレッシュをさせますこれ以外はMPAと全く同じです。
input
タグはfocusも維持しますので、問題のないUI/UXになっていますinput
タグからfocusが外れてしまう問題については、Turbo Drive Morphingを導入するだけで解消しました上記のTurbo Drive Morphingでは、ページ全体を書き換えつつもステートを維持するやり方を採用していました。一方でTurbo Framesを使うと画面の一部分のみを書き換えることができます。
...
<form method="get" data-turbo-frame="search-results">
<label for="search" class="text-sm mr-2">Search</label>
<input id="search" type="search"
oninput="this.form.requestSubmit()"
name="query"
value="<%= queryString %>"
class="border rounded p-1"
/>
</form>
...
<turbo-frame id="search-results">
...
[検索結果はここに表示される]
...
</turbo-frame>
form
にdata-turbo-frame="search-results"
が追加されています。これによって、form
送信後にサーバから返ってきたレスポンスは、search-results
という名のturbo-frame
に挿入されます<turbo-frame id="search-results">
で囲みますinput
タグはfocusも維持しますので、問題のないUI/UXになっていますinput
タグからfocusが外れてしまう問題については、turbo-frame
を導入するだけで解決できましたReactを使い、useEffectで全Userのデータ(users
)を取得している例を紹介します。
export default function LiveSearchIndex() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log("Fetch start for Users useEffect")
handleSearch("")
}, [])
function handleSearch(query: string) {
const escapedQuery = encodeURIComponent(query)
fetch(`/api/users/search?query=${escapedQuery}`).then(res => res.json())
.then(data => {
setUsers(data)
setLoading(false)
})
}
return (
...
<div className="mb-2">
<label htmlFor="search" className="text-sm mr-2">Search</label>
<input id="search" type="search"
onChange={(e) => handleSearch(e.target.value)}
className="border rounded p-1"/>
</div>
...
)
useEffect()
を使った一般的な書き方になっていますusers
ステートに入れていますinput
タグでは、onChange={(e) => handleSearch(e.target.value)}
によってhandleSearch()
を呼び出し、サーバに検索結果をリクエストしていますuseEffect()
を使う場合は、MPAとは書き方が少し異なりますが、慣れている人にとってはシンプルな内容ですinput
タグからfocusが外れてしまう問題はuseEffect()
を使った場合は起こりません。useEffect()
はusers
ステートしか更新せず、input
タグのステートには影響しないためですTurbo Drive MorphingとTurbo Framesの双方を紹介しました。UI/UXにおいては認識できる差はないのですが、やっていることの違いを細かく確認したいと思います。
Turbo Drive Morphingの場合
input
タグのvalue
属性が更新されることが確認できると思います。value
属性の変更はサーバ側のHTMLテンプレートで行われていますので、検索結果を受け取るのと同時にvalue
の値が変更されていますinput
タグの場合はこれとは別に a) focusの有無、b) 現時点で入力されている値、c) カーソル位置(選択範囲) のステートを持ちます。これはブラウザ固有のものであり、MorphingはこれをリセットしまわないようにしていますTurbo Framesの場合
input
タグのvalue
属性が更新されませんturbo-frames
タグの中身だけが更新されます。今回はinput
タグはturbo-frames
の外側ですので、一切影響を受けません蛇足になりますが、双方のケースで、サーバにリクエストが飛んでいる間はform
タグにaria-busy="true"
の属性が自動的についていることが確認できるかと思います。これを使ってローディングアニメーションをCSSで実装することもできます
Form
タグた追加され、React Server Componentを使った新しい検索フォームのパターンが導入されています。これを使ったライブ検索については、また別途紹介したいと思いますReactではステートが全面に出ますが、Hotwireもステートを意識することがあります。今回はinput
タグのfocusがそうでした。他にもスクロール位置やより複雑なものも意識したりします。HotwireのMorphingはブラウザのステートを維持したり、コントロールしたりするのに有効です(例えばブラウザのステートだけでなく、ブラウザのHTMLも維持したいときはdata-turbo-permanent
属性が使えます)。