Learn Next.js 官方教學筆記 - Chapter 11: Adding Search and Pagination

此系列為 Next.js 官方教學 Learn Next.js 的筆記。

文章連結:Chapter 11: Adding Search and Pagination


在上一篇文章裡,利用 streaming 的方式改善了 dashboard 最初載入的效能。接下來換成來處理 /invoices 的頁面,並學習如何加入搜尋功能以及頁碼。

在這一章節可以學到以下幾個項目:

  • 學習如何使用 Next.js API: searchParamsusePathname 以及 useRouter
  • 利用 URL search params 實作搜尋以及頁碼。

Starting code

在 /dashboard/invoices/page.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 { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';

export default async function Page() {
  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>
  );
}

可以稍微熟悉一下這邊的程式碼,因為都是接下來會用到的 component:

  1. <Search/> 可以讓使用者去搜尋特定的發票。
  2. <Pagination/> 可以讓使用者在發票頁面間移動。
  3. <Table/> 顯示發票的資訊。

搜尋的功能會包含 server 端以及 client 端的部分。當使用者在 client 搜尋發票時,URL params 會被更新,並且 data 會在 server 被 fetch,最後 table 會在 server 透過新的 data 來 re-render 。

Why use URL search params?

如同上面提到的,你可以使用 URL search params 來管理 search state。而若你以前比較常在 client 端處理這件事,這樣的 pattern 對你可能就會比較陌生。

那用 URL params 來實作搜尋功能是有一些好處的:

  • 標記與分享 URLs:因為 search parameters 在 URL 的關係,使用者可以標記網頁目前的狀態,這包含了它們的 search queries、過濾條件等等,為了在未來可以參考或分享。
  • Server-Side Rendering 以及最初的載入:URL parameters 可以在 server 上直接被使用來 render 最初的狀態,讓 server rendering 可以比較容易。
  • 分析與追蹤:在 URL 上顯示 search queries 以及搜尋條件使用的方式,可以在不需要額外的 client 端邏輯的狀態下,讓追蹤使用者行為這件事變得比較簡單。

Adding the search functionality

以下是在 Next.js client 端你會用來實作搜尋功能的 hooks:

  • useSearchParams :可以讓你拿到目前 URL 的參數。比如說:這個 URL 的 search params 為 /dashboard/invoices?page=1&query=pending 會像這個 {page: '1', query: 'pending’}
  • usePathname :可以讀取目前 URL 的路徑。比如說目前是這樣 /dashboard/invoices 的 route ,usePathname 就會回傳 '/dashboard/invoices'
  • useRouter :以 client 端程式的方式讓使用者在各個 route 間移動。這裡有多種方式可以實作。

以下快速的概覽實作的步驟:

  1. 取得使用者的輸入。
  2. 利用 search params 更新 URL 。
  3. 保持 URL 與輸入框的內容同步。
  4. 以搜尋參數更新 table 內容。

1. Capture the user's input

<Search> Component (/app/ui/search.tsx),你會注意到:

  • "use client" - 代表這是一個 client coponent ,它可以讓你使用 event listeners以及 hooks。
  • <input> - 這是搜尋的輸入框。

建立一個新的 handleSearch function,並增加 onChange listener 到 element。當 input value 改變的時候就會利用onChange 來呼叫 handleSearch

// /app/ui/search.tsx

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';

export default function Search({ placeholder }: { placeholder: string }) {
  function handleSearch(term: string) { // ADD
    console.log(term); // ADD
   // ADD

  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) => { // ADD
          handleSearch(e.target.value); // ADD
        }} // ADD
      />
      <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>
  );
}

可以在輸入文字的時候,利用 Dev tools 看看輸入的文字是不是有正常印出來。

2. Update the URL with the search params

'next/navigation' import useSearchParams hook,並 assign 給一個變數:

// /app/ui/search.tsx

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation'; // ADD

export default function Search() {
  const searchParams = useSearchParams(); // ADD

  function handleSearch(term: string) {
    console.log(term);
  }
  // ...
}

在 handleSearch 中,利用 searchParams 變數建立一個新的 URLSearchParams 實例,

// /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); // ADD
  }
  // ...
}

URLSearchParams 是一個 Web API,它可以提供多種方法來操作 URL query parameters。比起建立複雜的字符,用它可以取得像 ?page=1&query=a 的 params string。

根據使用者的 input 來設置 params string。判斷若 input 是空的,那就把它刪除的邏輯。

// /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) { // ADD
      params.set('query', term); // ADD
    } else { // ADD
      params.delete('query'); // ADD
    } // ADD
  }
  // ...
}

現在有了 query string. 你可以使用 Next.js 的 useRouter 以及 usePathname hooks 來更新 URL。

'next/navigation' Import useRouter 以及 usePathname ,並在 handleSearch 裡使用從 useRouter() 裡提出的 replace 方法。

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation'; // Update

export default function Search() {
  const searchParams = useSearchParams();
  const pathname = usePathname();  // ADD
  const { replace } = useRouter(); // ADD

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`); // ADD
  }
}

以下解釋發生了什麼:

  • ${pathname} 是表示目前的路徑,以範例來說,是 "/dashboard/invoices".
  • 當使用者在輸入框輸入文字時, params.toString() 會把輸入內容轉換成 URL-friendly 的格式,
  • replace(${pathname}?${params.toString()}) 會依照使用者輸入的資料來更新 URL。比方說,若使用者搜尋 ’Lee’ 的話,就會是 /dashboard/invoices?query=lee
  • 多虧 Next.js 的 client 端(會在 navigating between pages 的章節學到),URL的更新並不會讓頁面重新整理。

3. Keeping the URL and input in sync

為了確保輸入框可以與 URL 保持同步,並且要能在分享時也能自動填寫,你可以藉由讀取 searchParams 來把 defaultValue 傳遞給輸入框。

// /app/ui/search.tsx
<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);
  }}
  defaultValue={searchParams.get('query')?.toString()}
/>

defaultValue vs. value / Controlled vs. Uncontrolled

若你正使用 State 來管理輸入框的值,比較好的方式是使用 value 的屬性來控制 component。這代表 React 來管理輸入框的 State。

然而,因為目前沒有使用到 State,你可以使用 defaultValue。這代表輸入框會自己管理自己的 State。這樣的方式是可以的,因為目前已經把 search query 存到 URL 上,而不是使用 State。

4. Updating the table

再來是需要依據 search query 讓 table component 顯示相應的資料。

到 invoices 的頁面。Page component 接受叫做 searchParams 的 props,所以你可以傳遞當下的 URL params 到

component。

// /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>
  );
}

而到 <Table> Componen 的檔案看的話,會發現有兩個 props,query 以及 currentPage,被送到 fetchFilteredInvoices() function 中,並回傳被 query 篩選後的發票資料

// /app/ui/invoices/table.tsx

// ...
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  // ...
}

在做了以上的改變後,可以去測試看看。若你在輸入框搜尋,你將會更新 URL,並且會發一個 request 給 server,而資料會從 server 上 fetch 下來,而只有與 query 對應的發票內容會被回傳。

什麼時候要使用 useSearchParams() hook 或是 searchParams prop

目前會注意到說使用了兩種不同的方法來取得 search params。而你要使用哪一個會取決於你在 client 端或是 server 端運作?

  • <Search> 是一個 Client Component,所以你使用 useSearchParams() hook 來從 client 端取得 params。

  • <Table> 是一個 Server Component ,它會 fetch 自己的資料,所以你可以把 searchParams prop 從 page 傳到 component 上。

一般來說,若你想要讀取從 client 端讀取 params,可以使用 useSearchParams() hook 來避免又要跑回 server 去要資料。

Best practice: Debouncing

目前已經在 Next.js 完成了搜尋的功能。但這邊還有一些可以更優化的事。

handleSearch function 中加入以下的 console.log

// /app/ui/search.tsx
function handleSearch(term: string) {
  console.log(`Searching... ${term}`);

  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}

並且輸入 "Emil" 到搜尋框中,並到 dev tools 看會發生什麼事?

// Dev Tools Console
Searching... E
Searching... Em
Searching... Emi
Searching... Emil

現在的狀況就是當你每做一個改變就會更新 URL 一次,並且會 query database 一次。當網站規模小的時候可能不會是個問題,但當網站有好幾千個使用者,每個都發送好多個 request 的時候,就會出很大的問題。

而這個問題可以利用 Debouncing 來解決,它可以限制一個 function 被觸發的速度。若以我們的狀況來說,我們只希望使用者停止輸入才去 query the database。

Debouncing 如何運作呢?

  1. Trigger Event: 當一個應該被 debounced 的事件發生時(就像開始在輸入框打字那樣),會有一個計時器開始計時。

  2. Wait: 若在計時器時間到之前有一個新事件被觸發了,那計時器就會重置。

  3. Execution: 而若計時器順利結束,這個事件才會真的被執行。

實作 debouncing 的方式有很多種,也可以用自己的方式設計。但為了簡單一些,這邊使用 library use-debounce 來完成。

安裝 use-debounce :

// Terminal
npm i use-debounce

<Search> Component 中 import 一個 function 叫做 useDebouncedCallback

// /app/ui/search.tsx

// ...
import { useDebouncedCallback } from 'use-debounce'; // ADD

// Inside the Search Component...
const handleSearch = useDebouncedCallback((term) => { // Update
  console.log(`Searching... ${term}`);

  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}, 300); // Update

這個 function 會包裹住 handleSearch,並只會在使用者停止輸入的 300 ms 後才真的執行這段程式碼。

現在到搜尋框再次輸入文字,並且到 dev tools 看看,會看到下面的情況:

// Dev Tools Console
Searching... Emil

藉由 debouncing,你可以減少像 database 送 request 的次數,來減少資源的消耗。

Adding pagination

在做完搜尋的功能後,你會發現目前 table 只會一次顯示六筆發票的資訊。這是因為在 data.ts 裡的 fetchFilteredInvoices() function 在每頁只會回傳最多六筆發票資料。

所以新增頁碼可以讓使用者透過瀏覽不同頁來看到所有發票。接下來會展示如何利用 URL params 來實作頁碼的功能。

<Pagination/> component 後會注意到這是一個 client component。而你又不想要在 client 端去 fetch 資料,因為這樣會暴露資料庫的金鑰資訊。所以這邊就需要在 server 取得資料,再把資料以 props 的形式送給 component。

/dashboard/invoices/page.tsx 中 import 一個新的 function 叫做 fetchInvoicesPages,並把來自 searchParamsquery 當作引數:

// /app/dashboard/invoices/page.tsx

// ...
import { fetchInvoicesPages } from '@/app/lib/data'; // ADD

export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string,
    page?: string,
  },
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;

  const totalPages = await fetchInvoicesPages(query);

  return (
    // ...
  );
}

fetchInvoicesPages 會根據 search query 來回傳頁面的總數。舉例來說,如果有 12 筆發票資料與 search query 吻合,那每頁就會有 6 張,所以頁數就有 2 頁。

接下來把 totalPages prop 送到 <Pagination/> component:

// /app/dashboard/invoices/page.tsx

// ...

export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;

  const totalPages = await fetchInvoicesPages(query);

  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>
  );
}

<Pagination/> component 並且 import the usePathname 以及 useSearchParams hooks。我們會利用這些來取得當前的頁面並設置新的頁面。要確保取消註解在 component 中的程式碼。這時網頁可能會暫時壞掉,因為還沒處理好 <Pagination/> 的邏輯。

// /app/ui/invoices/pagination.tsx

'use client';

import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';

export default function Pagination({ totalPages }: { totalPages: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;

  // ...
}

接下來在 <Pagination> Component 內建立一個新的 function ,並叫 createPageURL 。而就像搜尋一樣,你會使用 URLSearchParams 來設定新的頁碼,而 pathName 用來設定 URL 字串。

// /app/ui/invoices/pagination.tsx

'use client';

import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';

export default function Pagination({ totalPages }: { totalPages: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;

  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };

  // ...
}

這邊需要注意幾件事:

  • createPageURL 建立當前 search parameters 的實例。
  • 因此他會根據所提供的頁面號碼來更新 page 的參數。
  • 最後它利用 pathname 以及更新的 search parameters 來重構完整的 URL 。

而剩下的 <Pagination> component 用來處理 styling 以及不同的 state。這部分不會太詳細解說,但可以看看哪些地方會呼叫 createPageURL

最後當使用者輸入新的 search query,你會希望把頁碼重置回 1。所以你可以利用在 <Search> component 內的 handleSearch function 來達成:

// /app/ui/search.tsx

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const { replace } = useRouter();
  const pathname = usePathname();

  const handleSearch = useDebouncedCallback((term) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }, 300);

Summary

目前利用了 URL Params 以及 Next.js APIs 來完成搜尋以及分頁功能。

這邊做個小結:

  • 這邊利用 search parameters 來完成搜尋以及分頁功能,而不是 client state。
  • 在 server 上 fetch data。
  • 利用 useRouter router hook 完成更滑順的 client 端轉移。

這些模式跟以前使用 React client 端的方式很不同,但幸運的是,現在理解到使用 URL search params 以及傳送 state 到 server 上的好處了。