🅰️

プリフェッチを活用して検索ページのUXを向上させる

2024/11/13に公開

はじめに

Next.js の Link コンポーネントを使用して、検索フォームに入力したタイミングで検索結果をプリフェッチすれば、ユーザー体験を良くできるかも、というアイデアをベースに実装してみました。実装時にあれやこれやと考えたことをメモ書きとして残しておきます。

環境

Next.js の 14.2.14 バージョンの App Router を使用しています。また、サンプルのコードはコピペで動作するようには考慮していません。

検索フォームのページ構成

よくある構成で検索フォームを実装しており、登場するページ数は2ページです。

ページA) 検索フォームにキーワードを入力する(クライアントコンポーネント)
ページB) URL パラメーター経由でキーワードを受け取り、検索結果を表示する(サーバーコンポーネント)

URL にすると以下のようになります。

ページA) https://sample.dev/search
ページB) https://sample.dev/search/result?q=nextjs

今回は、ページAでのキーワード入力を元に検索結果をプリフェッチし、ページBの検索結果表示の体感速度を速くする、という実装をしました。あくまでも体感速度が速くなっただけであり、検索結果表示にかかる処理は速くなっていません。

既存の検索フォーム

既存の検索フォームを修正する形で進めます。既存フォームの実装は以下です。わりと良くある実装だと思いますが...フォームに入力された値を受け取り、それらを URL のパラーメーターにのせて、onClick イベントをトリガーに router.push をしていました。

// ページAの既存実装
"use client";

export default function Page() {
  const [name, setName] = useState("");
  const router = useRouter();

  return (
    <div>
      <Input setValue={setName} value={name} label="名称" />

      <SubmitButton
        onClick={() => {
            router.push(
            `/search/result?name=${name}`,
            );
        }}
        disabled={!name}
      >
      {"検索"}
      </SubmitButton>
    </div>
  );
}

検索結果を表示するページBの実装は以下です。searchParams を受け取って、それを元に検索結果を表示します。
SearchResult コンポーネントの処理内容が外部 API へのリクエストか、DB へのアクセスかは今回は関係ないので、SearchResult の実装は割愛します。また、今回は検索フォームを持つページAに対する修正になるので、以降、ページBは登場しません。

// ページBの既存実装
export default function Page(args: {
  searchParams: {
    name: string;
    offset: string;
    limit: string;
  };
}) {
  return (
    <Suspense fallback={<Loading />}>
      <SearchResult args={args} />
    </Suspense>
  );
}

上記に示したページAとページBの実装の場合だと、検索結果の取得にかかる時間とコンポーネントのレンダリングにかかる時間をユーザーはフルで待つことになるため、検索結果の表示に時間がかかっていました。

プリフェッチを活用した検索フォーム

以下の実装で、検索フォームの値の変更の度に Link コンポーネントのプリフェッチが実行されるようにしています。

"use client";

export default function Page() {
  const [name, setName] = useState("");

  const query = useMemo(() => {
    return `name=${name}`
  }, [name]);

  return (
    <div>
      <Input setValue={setName} value={name} label="名称" />

      <Link
        href={`/search/result?${query}`}
        disabled={(!name)}
      >{"検索"}</Link>
    </div>
  );
}

検索結果表示ページはサーバーコンポーネントのため、検索結果を含む RSC がプリフェッチの取得対象になります。そのため、プリフェッチが完了している場合のページ遷移はコンポーネントの差し替えのみになるため、ユーザー体験としてはかなり速い印象になります。

ブラウザのネットワークを見ると以下のように RSC がプリフェッチされていることが確認できます。

ただ、プリフェッチはユーザー体験がよくなる一方、サーバー側の負荷を上げる、ユーザーの通信量増加を引き起こします。次以降で、一部のパターンでプリフェッチを抑止するなどの改善をします。

(改善案1) 不要なプリフェッチは抑止する

不要なプリフェッチは積極的に抑止するべきですが、とりあえず、今回抑止したのは以下の2つのパターンです。

  1. 検索フォームに値がない場合にプリフェッチを抑止する
  2. 入力中の場合はプリフェッチを抑止する

検索フォームに値がない場合にプリフェッチを抑止する

初期レンダリングのタイミングでは検索フォームに値がないため、プリフェッチは不要です。
Next.js の Link コンポーネントは prefetch というプロパティからプリフェッチを抑止できます。

<Link
  href={`/search/result?${query}`}
  // NOTE: クエリーが空の状態で遷移は発生しないため prefetch を抑止する
  prefetch={query !== ""}
>{"検索"}</Link>

入力中の場合はプリフェッチを抑止する

検索フォームに入力するたびにプリフェッチすると、入力が終わっていない文字で検索が実行されることになります。

これを防ぐには以下の2パターンが考えられますが、今回はデバウンス機構を使用しました。

  1. onBlur イベントをトリガーにプリフェッチをする
  2. デバウンス機構を使用する

以下にデバウンス機構を Hooks として実装しています。

import { useEffect, useState } from "react";

type DebounceState = "idle" | "debouncing" | "ready";
export const useDebounce = <T>(value: T, delay: number): [T, DebounceState] => {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  const [state, setState] = useState<DebounceState>("idle");

  useEffect(() => {
    setState("debouncing");
    const timer = setTimeout(() => {
      setDebouncedValue(value);
      setState("ready");
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return [debouncedValue, state];
};

デバウンスの時間は 500 ms にしていますが、ここは感覚です。これで検索フォームに値が入力される度にプリフェッチが実行されるのを抑止できました。

ちなみに、入力してから 500 ms 以内に検索ボタンを押下されると、古いクエリーで検索されることになるため、これはデバウンスの状態(ready 以外の場合は Link コンポーネントを disable にする)を管理することで防いでいます。

"use client";

export default function Page() {
  const [name, setName] = useState("");

  const memorizedQuery = useMemo(() => {
    return name ? `name=${name}` : ""
  }, [name]);

  const [query, debounceState] = useDebounce(memorizedQuery, 500)

  return (
    <div>
      <Input setValue={setName} value={name} label="名称" />

      <Link
        href={`/search/result?${query}`}
        disabled={(!name) || debounceState !== "ready"}
        prefetch={query !== ""}
        loading={debounceState !== "ready"}
      >{"検索"}</Link>
    </div>
  );
}

(改善案2)プリフェッチによるレスポンスサイズを小さくする

プリフェッチの抑止はしているものの、ある程度の数のプリフェッチは実行されます。そのため、それを処理するサーバー側の負荷は考える必要があります。

プリフェッチによるリクエストをサーバー側はキャッシュで返せれば、負荷軽減に繋がります。しかし、検索結果の表示を扱うコンポーネントは、searchParams を扱うため、Dynamic コンポーネントになり(Next.js の仕様)、サーバー側にキャッシュされません。

以下、コンポーネントの分類です。

(Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand

いちおう、検索結果取得のためのクエリーにキャッシュを効かせる(=データキャッシュを効かせる)ことは可能ですが、入力される検索フォームのバリエーション的に効果は限定的になる可能性が高いです(キャッシュヒット率が低い)。

そのため、当たり前にはなりますが、検索結果が軽くなるように offset/limit によるページング処理、不要なプロパティを含めない等の工夫をするのが良いと思います。

また、プリフェッチによるレスポンスの結果が何バイトかは確認しておくのが良いと思います(何バイトまでなら OK かはアプリケーションによる)。

例えば、以下の場合は 297 Byte です(軽すぎる...)。

プリフェッチのキャッシュの挙動の整理

最後に...Next.js のキャッシュの挙動は、設定によってかなり変わるので、整理しておきます。

プリフェッチのキャッシュは、ルーターキャッシュに分類され、クライアント側のメモリに保存されます。キャッシュが有効かどうか、キャッシュの有効期間は、プリフェッチ対象とプリフェッチの仕方によって変わります。

今回、プリフェッチをする対象である、検索結果を表示するコンポーネントは、Dynamic コンポーネントです(以下のページB)。

ページA) 検索フォームにキーワードを入力する(クライアントコンポーネント)
ページB) URL パラメーター経由でキーワードを受け取り、検索結果を表示する(サーバーコンポーネント)

プリフェッチの仕方は、以下のように明示的に prefetch のプロパティを有効にしています(※ 明示的に渡さない場合は Static はキャッシュ有効、Dynamic はキャッシュ無効)。

<Link
  href={`/search/result?${query}`}
  disabled={(!name) || debounceState !== "ready"}
  prefetch={query !== ""}
  loading={debounceState !== "ready"}
>{"検索"}</Link>

そのため、検索結果を表示するコンポーネントがプリフェッチされた場合、キャッシュは有効であり、有効期間は 5 分間になります。メモリ保存なため、ブラウザのリフレッシュでクリアされます。

もし、仮に検索結果が刻一刻と変更される場合は今回の実装パターンは避けた方が良いかと思います。

まとめ

ありがち実装パターンだったらすみません...以上です。

Discussion