Top

Commentary

Live Search

ライブ検索

はじめに #

ライブ検索はリアルタイムで検索するUIです。「検索」ボタンを押さなくても、入力が途中でも検索が実行されるものです。「検索」ボタンを押すイベントではなく、input要素のonInputイベントに反応してサーバに問い合わせを行うだけなので、MPAであっても一見実装は簡単です。

しかしMPAだと大きな問題が一つあります。検索フィールドのステートがリセットされることです。このためMPAだけではまともなUIを作るのは困難です。

ここではMPAのときの問題を紹介するところかは初めて、次にHotwireで検索フィールドのステートをどのように保持するかを紹介します。最後にReactを使った方法を紹介し、UI/UXを比較します。

またリアルタイム検索の場合はサーバの遅延が大きいと、どうやっても辛いUI/UXになってしまうので、遅延が300msの場合しか想定しません。

ここで作るUI (Hotwire Turbo Framesの例)

MPAによるライブサーチの試み #

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を自動的に送信します

UI/UX #

  • 検索文字列を入力すると、サーバにリクエストを投げ、絞り込まれた内容に正しく更新されます
  • しかしinputタグからfocusが外れてしまうので、次の文字を入力するのが困難です

まとめ #

  • MPAの実装にoninput="this.form.requestSubmit()"を追加するだけでライブ検索が実現します
  • しかしinputタグからfocusが外れてしまいますので、実用的ではありません
  • focusが外れてしまうのは、MPAだと内容更新のたびにページ全体を完全に入れ替えてしまうために起こります。ページを完全にリセットしてしまうために、focusのステートが消えてしまうのが問題です
  • autofocusを使って、ページリロード後にfocusを再びinputに持ってくることも可能ですが、少し無理があります

Hotwire Turbo Morphによるライブサーチ #

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>
  • Turbo Drive Morphingを使用するためには<head>タグの中身を書き換える必要があります。そこでlayouts/header_morph.ejsを読み込みます
  • Turbo DriveのMorphingはページをリフレッシュ(refresh)するときに限ってオンになります。同じページを再読み込みしたり、URLのクエリを変えただけの場合、かつreplace型のナビゲーションになっているときにTurboはリフレッシュと判断します。ここではformdata-turbo-action="replace"をつけて、Turboにリフレッシュをさせます

これ以外はMPAと全く同じです。

UI/UX #

  • 検索文字列を入力すると、サーバにリクエストを投げ、絞り込まれた内容に正しく更新されます
  • inputタグはfocusも維持しますので、問題のないUI/UXになっています

まとめ #

  • MPAのときのinputタグからfocusが外れてしまう問題については、Turbo Drive Morphingを導入するだけで解消しました

Hotwire Turbo Framesによるライブサーチ #

上記の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>
  • MPAのコードとほとんど同じです
  • formdata-turbo-frame="search-results"が追加されています。これによって、form送信後にサーバから返ってきたレスポンスは、search-resultsという名のturbo-frameに挿入されます
  • 検索結果の表を<turbo-frame id="search-results">で囲みます

UI/UX #

  • 検索文字列を入力すると、サーバにリクエストを投げ、絞り込まれた内容に正しく更新されます
  • inputタグはfocusも維持しますので、問題のないUI/UXになっています

まとめ #

  • MPAのときのinputタグからfocusが外れてしまう問題については、turbo-frameを導入するだけで解決できました

Next.js useEffectによるライブサーチ #

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()を呼び出し、サーバに検索結果をリクエストしています

UI/UX #

まとめ #

  • React useEffect()を使う場合は、MPAとは書き方が少し異なりますが、慣れている人にとってはシンプルな内容です
  • MPAのときのに起こる、inputタグからfocusが外れてしまう問題はuseEffect()を使った場合は起こりません。useEffect()usersステートしか更新せず、inputタグのステートには影響しないためです

MorphingとTurbo Framesの違い #

Turbo Drive MorphingとTurbo Framesの双方を紹介しました。UI/UXにおいては認識できる差はないのですが、やっていることの違いを細かく確認したいと思います。

Turbo Drive Morphingの場合

  • 検索フィールドの文字列を変更すると、少し間をおいてinputタグのvalue属性が更新されることが確認できると思います。value属性の変更はサーバ側のHTMLテンプレートで行われていますので、検索結果を受け取るのと同時にvalueの値が変更されています
  • このようにHotwireのMorphingでは基本的にサーバから送られてきたHTMLに置換されます。しかしinputタグの場合はこれとは別に a) focusの有無、b) 現時点で入力されている値、c) カーソル位置(選択範囲) のステートを持ちます。これはブラウザ固有のものであり、Morphingはこれをリセットしまわないようにしています

Turbo Framesの場合

  • Turbo Framesの場合はinputタグのvalue属性が更新されません
  • Turbo Framesではturbo-framesタグの中身だけが更新されます。今回はinputタグはturbo-framesの外側ですので、一切影響を受けません

蛇足になりますが、双方のケースで、サーバにリクエストが飛んでいる間はformタグにaria-busy="true"の属性が自動的についていることが確認できるかと思います。これを使ってローディングアニメーションをCSSで実装することもできます

最後に #

  • MPAでライブ検索を実装するときにfocusが外れる問題について、HotwireのMorphingやTurbo Framesを使うと簡単に解決できます
  • React useEffectを使っても簡単に実装できます
  • Next.js v15ではFormタグた追加され、React Server Componentを使った新しい検索フォームのパターンが導入されています。これを使ったライブ検索については、また別途紹介したいと思います

Reactではステートが全面に出ますが、Hotwireもステートを意識することがあります。今回はinputタグのfocusがそうでした。他にもスクロール位置やより複雑なものも意識したりします。HotwireのMorphingはブラウザのステートを維持したり、コントロールしたりするのに有効です(例えばブラウザのステートだけでなく、ブラウザのHTMLも維持したいときはdata-turbo-permanent属性が使えます)。