【11-1】Next.js app routerのチュートリアルやってみる(URLパラメーターを利用した検索機能)

# はじめに

Next.js app routerのチュートリアルの第11-1章のアウトプットをします。

https://nextjs.org/learn/dashboard-app/adding-search-and-pagination

<details><summary>前の記事</summary>

【01】Next.js app routerのチュートリアルやってみる

https://qiita.com/naoyuki2/items/af58da3d20cbc790e767

【02】Next.js app routerのチュートリアルやってみる

https://qiita.com/naoyuki2/items/edf450b3ee135e83d1e8

【03】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/612221eac233aa9cbb74

【04】Next.js app routerのチュートリアルやってみる

https://qiita.com/naoyuki2/items/62f9beccbfe36eaf7f90

【05】Next.js app routerのチュートリアルやってみる

https://qiita.com/naoyuki2/items/8b71b1d1df7c9435a9c9

【06】Next.js app routerのチュートリアルやってみる

https://qiita.com/naoyuki2/items/58130c3cfbaf8a573de2

【07】Next.js app routerのチュートリアルやってみる

https://qiita.com/naoyuki2/items/2c2da0f8071e60454679

【08】Next.js app routerのチュートリアルやってみる

https://qiita.com/naoyuki2/items/45f45fcb9cc14506f79f

【09】Next.js app routerのチュートリアルやってみる(loading.tsxとSuspenseでストリーミング)

https://qiita.com/naoyuki2/items/717694288ec6017a3af2

【10】Next.js app routerのチュートリアルやってみる(部分的な事前レンダリング)

https://qiita.com/naoyuki2/items/8062f755b0679fe925b1

</details>

# 第11-1章 URLパラメーターを利用した検索機能

この章では下記を学習しました。

- URL検索パラメータを使用して、検索機能とページネーション機能を実装
- `searchParams`,`usePathname`,`useRouter`の使い方
- デバウンスの実装

検索機能が結構重たかったので二つに分けることにしました。

今回は検索機能のみ取り扱います。

# 今回作業する場所

```
.
└── invoices/
    └── page.tsx/
        ├── <Search />
        ├── <Pagenation />
        └── <Table />
```

`/dashboard/invoices/page.tsx`とその中に`import`している３つのコンポーネントをいじります。

- `<Search />` --- いわゆる検索バーがあるコンポーネント
- `<pagenation />` --- ページネーションを実装するコンポーネント
- `<Table />` --- 検索結果を表示するコンポーネント

# 検索機能

今回の検索機能はURLパラメーターを使用して実装します。

### URLパラメーターを使う利点

検索機能にURLパラメーターを使うことによって以下のような利点があります。

- ブックマークが可能になる
- URLを共有できる
- URLパラメーターはサーバー側でも利用可能なため、初期ロード時間を短縮できる
(再読み込みしたときにサーバー側に検索キーワードがあるから？)
- ユーザーがどの検索条件をよく使うかなどの分析がしやすくなる
(Google Analyticsなどを使用するとユーザーが訪れたURLとパラメータを追跡できるため)

### 使用するフック

--- `usePathname` --- 
現在アクセスしているパスを取得することができます
例：`localhost:3000/dashboard/invoices` --> `/dashboard/invoices`を返す

--- `useSearchParams` --- 
現在のURLのパラメーターにアクセスできます
例：`/dashboard/invoices?page=1&query=pending` --> `{page: '1', query: 'pending'}`を返す

--- `useRouter` ---
クライアントコンポーネント内でルーティングに関する機能を使用できます
今回はその中でも`replace`というメソッドを使用します

### 実装手順

1. ユーザーが入力した検索キーワードを取得する
1. 検索キーワードを使用して、URLを更新する
1. URLと入力フィールドを同期させる
1. 取得したデータを表示する

### 1. ユーザーが入力した検索キーワードを取得する

検索キーワードを入力する`<search />`コンポーネントを編集していきましょう。

```diff_tsx:/app/ui/search.tsx
+'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
 
export default function Search({ placeholder }: { placeholder: string }) {
+ function handleSearch(term: string) {
+   console.log(term);
+ }
 
  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
+       onChange={(e) => {
+         handleSearch(e.target.value);
+       }}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}
```

ユーザーの入力内容を取得するには、`input`タグに`onChange`イベントを付けましょう。

そして、それによって発火する`handleSearch`関数を書きましょう。

`onChange`イベントはクライアントコンポーネントでしか使えないので、`use client`を忘れないようにしましょう。

これで`input`タグに入力した内容が`コンソール`に出力されるようになりました。

### 2. 検索キーワードを使用して、URLを更新する

続いて、URLを更新しましょう。

```diff_tsx:/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
+import { useSearchParams } from 'next/navigation';
 
export default function Search() {
+ const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
+   const params = new URLSearchParams(searchParams);
+   if (term) {
+     params.set('query', term);
+   } else {
+     params.delete('query');
+   }
  }
  // ...
}
```

`useSearchParams`を`next\navigation`から`import`しましょう。

そして、`searchParams`という定数に現在のURLパラメーターを代入しましょう。

現在のURLは`/dashboard/invoices`なので、`{}`が代入されます。

`URLSearchParams`は、URLパラメーターの整形を楽にするための`WebAPI`です。(これだけではURLの更新をすることはできません。[詳しく](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams))

これに現在のURLパラメーターである`searchParams`を渡してインスタンスを作成しましょう。

ユーザーの検索キーワードは`term`という名前で受け取っています。

`term`に検索キーワードが入っていたら、`params.set('query, trem')`によって、名前が`query`で、値が`trem`である、URLパラメーターを作成することができます。

例：`test`が入力された --> `/dashborad/invoices?query=test`となる

そして、もし`term`が空だったら`params.delele('query')`によって、名前が`query`であるURLパラメーターを削除します。

これによって、`params`にURLパラメーターが代入されたので、それによって下のコードでURLを更新しましょう。

```diff_tsx:/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
+import { useSearchParams, usePathname, useRouter } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const { replace } = useRouter();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
+   replace(`${pathname}?${params.toString()}`);
  }
}
```

`next/navigation`から追加で`usePathname`と`useRouter`を`import`しましょう。

`usePathname`をつかって、現在のURLを`pathname`に代入しましょう。

`useRouter`の中の`replace`というメソッドを使用するので分割代入しておきましょう。

`replace`メソッドはURLを更新することができるメソッドです。

そして、`pathnaem`と`params.toString()`を結合して、新たなURLとして更新しています。

--- 例 ---
`pathname` = `/dashboard/invoices`
`params` = `{'query','test'}`
replace = `/dashboard/invoices?query=test`

### 3. URLと入力フィールドを同期させる

キーワードを検索したあとにページを再読み込みしたり、リンクからアクセスしたり、した場合には`input`タグの中身が空になってしまいます。

そのため下記のようにして`input`タグにデフォルトの値として現在の検索キーワードである`query`の値を入れておきましょう。

```diff_tsx:/app/ui/search.tsx
<input
  className="..."
  placeholder={placeholder}
  onChange={(e) => {
    handleSearch(e.target.value);
  }}
+ defaultValue={searchParams.get('query')?.toString()}
/>
```

`defaultValue`ではなく、`value`としてしまうと、下のようなエラーが出てしまうので注意しましょう。

```
Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component.
```

> defaultValueとvalue 制御されたものと制御されていないもの
>
>`state`を使用して入力の値を管理している場合は、value属性を使用してそれを制御コンポーネントにします。これは、React が`input`の`state`を管理することを意味します。
>
>ただし、`state`を使用していないため、`defaultValue`を使用できます。これは、ネイティブ`input`が独自の`state`を管理することを意味します。検索クエリを`state`ではなく URL に保存しているため、これは問題ありません。

# 4. 取得したデータを表示する

最後にデータを検索し、表示しましょう。

データを表示する`<Table />`コンポーネントに検索キーワードである`query`と次回扱う`page`を渡しています。

```diff_tsx:/app/dashboard/invoices/page.tsx
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { Suspense } from 'react';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
 
+export default async function Page({
+  searchParams,
+}: {
+  searchParams?: {
+    query?: string;
+    page?: string;
+  };
+}) {
+ const query = searchParams?.query || '';
+ const currentPage = Number(searchParams?.page) || 1;
 
  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
+     <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
+       <Table query={query} currentPage={currentPage} />
+     </Suspense>
      <div className="mt-5 flex w-full justify-center">
        {/* <Pagination totalPages={totalPages} /> */}
      </div>
    </div>
  );
}
```

`Next.js`では、`page.tsx`で`searchParams`という`Props`を受け取ることができます。

その中には以下のようにURLパラメーターが入っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582046/e4cde9c3-0f7a-f1e4-77d4-0f1d4e0b0c1a.png)

https://nextjs.org/docs/app/api-reference/file-conventions/page

普通、`Props`はコンポーネントを呼び出す際に指定するものですが、これは例外のようですね。

そして、`<Table />`コンポーネント内で`query`を元にデータフェッチをしています。

```diff_tsx:/app/ui/invoices/table.tsx
// ...
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  // ...
}
```

`useSearchParam`はクライアントコンポーネントでしか使用できないため、サーバーコンポーネントである、`table.tsx`には`Props`を通して、URLパラメーターを渡しています。

# おまけ デバウンス

これでいったん検索機能は完成です。

ですが、今のままではキーストローク(キーボードを押すこと)ごとにデータフェッチが行われてしまいます。

`Emil`と検索しようとしても、計４回データフェッチをしてしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582046/910f7744-bc40-374c-e9cd-894f468fd165.png)

そこで`デバウンス`という技術を使用します。

`デバウンス`とは、関数が起動できる速度を制限するプログラミング手法です。

この例では、ユーザーが入力をやめたときにのみデータベースにクエリを実行する必要があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582046/f7b1c740-f334-06be-01c9-2f16279b150e.png)

これを実装するために`use-debounce`というライブラリを利用します。

```shell:ターミナル
npm i use-debounce
```

`<Search />`コンポーネントに`useDebouncedCallback`という関数を`import`します。

```diff_tsx:/app/ui/search.tsx
// ...
+import { useDebouncedCallback } from 'use-debounce';
 
// Inside the Search Component...
+const handleSearch = useDebouncedCallback((term) => {
  console.log(`Searching... ${term}`);
 
  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
+}, 300);
```

`デバウンス`を実装したい関数(`handleSearch`)を`useDebounceCallback`でラップします。

そして、ユーザーが入力をやめてから300ミリ秒が経過した後にこのコードを実行します。

# おわりに

11章の検索機能は結構重たかったので、２個に分けました。

大変だけど、力にはなってるはず。

# 次の記事

https://qiita.com/naoyuki2/items/fd00dc2b376e7d87fb44