이전 장에서는 스트리밍을 통해 대시보드의 초기 로드 성능을 개선했습니다. 이제 /invoices 페이지로 이동하여 검색 및 페이지 매김을 추가하는 방법을 알아보세요!
searchParams, usePathname 및 useRouter)를 사용하는 방법을 알아보세요./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>
);
}
작업할 페이지와 구성 요소에 익숙해지는 데 시간을 투자하세요.
<Search/> 사용자가 특정 송장을 검색할 수 있습니다.<Pagination/> 사용자가 청구서 페이지 간을 탐색할 수 있습니다.<Table/> 송장을 표시합니다.검색 기능은 클라이언트와 서버에 걸쳐 있습니다. 사용자가 클라이언트에서 송장을 검색하면 URL 매개변수가 업데이트되고 서버에서 데이터를 가져오며 테이블은 새 데이터로 서버에서 다시 렌더링됩니다.
위에서 언급한 대로 URL 검색 매개변수를 사용하여 검색 상태를 관리하게 됩니다. 클라이언트측 상태를 사용하여 수행하는 데 익숙하다면 이 패턴이 새로운 것일 수 있습니다.
URL 매개변수를 사용하여 검색을 구현하면 다음과 같은 몇 가지 이점이 있습니다.
검색 기능을 구현하는 데 사용할 Next.js 클라이언트 후크는 다음과 같습니다.
useSearchParams - 현재 URL의 매개변수에 액세스할 수 있습니다. 예를 들어, 이 /dashboard/invoices?page=1&query=pending URL에 대한 검색 매개변수는 다음과 같습니다: {page: '1', query: 'pending'}usePathname - 현재 URL의 경로명을 읽을 수 있습니다. 예를 들어 /dashboard/invoices 경로의 경우 usePathname은 '/dashboard/invoices'를 반환합니다.useRouter - 프로그래밍 방식으로 클라이언트 구성 요소 내의 경로 간 탐색을 활성화합니다. 사용할 수 있는 방법은 여러 가지가 있습니다.구현 단계에 대한 간략한 개요는 다음과 같습니다.
<Search> 구성 요소(/app/ui/search.tsx)로 이동하면 다음을 확인할 수 있습니다.
"use client"- 이것은 클라이언트 구성 요소입니다. 즉, 이벤트 리스너와 후크를 사용할 수 있습니다.<input>- 검색 입력입니다.새 함수 handleSearch를 만들고 <input> 요소 에 onChange 리스너를 추가합니다. 입력 값이 변경될 때마다 onChange는 onChangehandleSearch를 호출할 겁니다.
// /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>
);
}
개발자 도구에서 콘솔을 열어 올바르게 작동하는지 테스트한 다음 검색 필드에 입력하세요. 콘솔에 기록된 검색어가 표시되어야 합니다.
좋아요. 사용자의 검색 입력을 캡처하고 있습니다. 이제 검색어로 URL을 업데이트해야 합니다.
'next/navigation'에서 useSearchParams 후크를 가져와 변수에 할당합니다.
// /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) {
console.log(term);
}
// ...
}
handleSearch 내부에서, 새 URLSearchParams 변수를 사용하는 searchParams 인스턴스를 새로 만듭니다.
// /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); // 변수 선언
}
// ...
}
URLSearchParamsURL은 URL 쿼리 매개변수를 조작하기 위한 유틸리티 메서드를 제공하는 웹 API입니다. 복잡한 문자열 리터럴을 생성하는 대신 ?page=1&query=a와 같은 params 문자열을 가져올 수 있습니다.
다음으로 사용자 입력을 기반으로 set하는 params 문자열입니다. 입력이 비어 있으면 delete를 수행하고 싶습니다:
// /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);
// params 가져오기
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
}
// ...
}
이제 쿼리 문자열이 생겼습니다. Next.js의 useRouter와 usePathname 후크를 사용하여 URL을 업데이트할 수 있습니다.
'next/navigation'에서 useRouter 및 usePathname을 가져오고, handleSearch 내부에서 useRouter()로부터 replace 메서드를 사용합니다.
// /app/ui/search.tsx
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation'; // hook 불러오기
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()}`); // 메서드 사용
}
}
무슨 일이 일어나고 있는지 분석해 보면 다음과 같습니다.
${pathname}는 현재 경로입니다. 귀하의 경우, "/dashboard/invoices"params.toString()이 URL 친화적인 형식 입력으로 변환됩니다.replace(${pathname}?${params.toString()})는 사용자의 검색 데이터로 URL을 업데이트합니다. 예를 들어 사용자가 "Lee"를 검색하는 경우 /dashboard/invoices?query=lee입니다.입력 필드가 URL과 동기화되고 공유 시 채워지도록 하면 searchParams에서 defaultValue를 읽어 입력에 전달할 수 있습니다:
<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()} // value 전달
/>
defaultValuevsvalue/ 통제됨 vs 통제되지 않음
상태를 사용하여 입력 값을 관리하는 경우value속성을 사용하여 제어되는 구성 요소로 만듭니다. 이는 React가 입력 상태를 관리한다는 의미입니다.
그러나 state를 사용하지 않으므로defaultValue를 사용할 수 있습니다. 이는 기본 입력이 자체 상태를 관리한다는 의미입니다. 상태 대신 URL에 검색어를 저장하므로 괜찮습니다.
마지막으로 검색 쿼리를 반영하도록 테이블 구성 요소를 업데이트해야 합니다.
청구서 페이지로 다시 이동합니다.
페이지 구성요소는 searchParams이라는 prop을 허용하므로 현재 URL 매개변수를 <Table> 구성요소에 전달할 수 있습니다.
// /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> 구성 요소로 이동하면 두 개의 prop인 query 및 currentPage가 쿼리와 일치하는 송장을 반환하는 함수 fetchFilteredInvoices()에 전달되는 것을 볼 수 있습니다.
// /app/ui/invoices/table.tsx
// ...
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}
이러한 변경 사항이 적용되면 계속해서 테스트 해보세요. 용어를 검색하면 URL을 업데이트하여 서버에 새 요청을 보내고 서버에서 데이터를 가져오고 검색어와 일치하는 청구서만 반환됩니다.
useSearchParams()후크와searchParamsprop을 언제 사용해야 합니까?
검색 매개변수를 추출하기 위해 두 가지 다른 방법을 사용했다는 것을 눈치챘을 것입니다. 둘 중 하나를 사용하는지 여부는 클라이언트에서 작업하는지 서버에서 작업하는지에 따라 다릅니다.
<Search>는 클라이언트 구성 요소이므로useSearchParams()후크를 사용하여 클라이언트의 매개변수에 액세스했습니다.<Table>은 자체 데이터를 가져오는 서버 구성 요소이므로 페이지에서 구성 요소로searchParamsprop을 전달할 수 있습니다.일반적으로 클라이언트에서 매개변수를 읽으려면 서버로 돌아갈 필요가 없도록
useSearchParams()후크를 사용하십시오.
축하해요! Next.js로 검색을 구현했습니다. 하지만 이를 최적화하기 위해 할 수 있는 일이 있습니다.
handleSearch 함수 내에 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"을 입력하고 개발 도구에서 콘솔을 확인하세요. 무슨 일이 일어나고 있나요?
Searching... E
Searching... Em
Searching... Emi
Searching... Emil
키를 누를 때마다 URL을 업데이트하므로 키를 누를 때마다 데이터베이스를 쿼리하게 됩니다. 우리 애플리케이션이 작기 때문에 이것은 문제가 되지 않습니다. 그러나 애플리케이션에 수천 명의 사용자가 있고 각 사용자가 키를 누를 때마다 데이터베이스에 새로운 요청을 보내는 경우를 상상해 보십시오.
디바운싱은 함수가 실행될 수 있는 속도를 제한하는 프로그래밍 방식입니다. 우리의 경우에는 사용자가 입력을 중단한 경우에만 데이터베이스를 쿼리하려고 합니다.
디바운싱 작동 방식:
디바운싱 기능을 수동으로 생성하는 등 몇 가지 방법으로 디바운싱을 구현할 수 있습니다. 일을 단순하게 유지하기 위해 use-debounce 라이브러리를 사용하겠습니다.
use-debounce를 설치하세요
npm i use-debounce
<Search> 구성 요소 에서 useDebouncedCallback라는 함수를 가져옵니다.
// /app/ui/search.tsx
// ...
import { useDebouncedCallback } from 'use-debounce'; // 함수 불러오기
// Search 구성 요소 안에...
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의 내용을 래핑하고 사용자가 입력을 중지한 후 특정 시간(300ms) 후에만 코드를 실행합니다.
이제 검색창에 다시 입력하고 개발 도구에서 콘솔을 엽니다. 다음이 표시되어야 합니다.
Searching... Emil
디바운싱을 통해 데이터베이스로 전송되는 요청 수를 줄여 리소스를 절약할 수 있습니다.
검색 기능을 도입한 후에는 테이블에 한 번에 6개의 송장만 표시되는 것을 볼 수 있습니다. 이는 data.ts의 fetchFilteredInvoices() 함수가 페이지당 최대 6개의 송장을 반환하기 때문입니다.
페이지 매김을 추가하면 사용자가 여러 페이지를 탐색하여 모든 청구서를 볼 수 있습니다. 검색에서와 마찬가지로 URL 매개변수를 사용하여 페이지 매김을 구현하는 방법을 살펴보겠습니다.
<Pagination/> 구성 요소로 이동하면 그것이 클라이언트 구성 요소라는 것을 알 수 있습니다. 데이터베이스 비밀이 노출될 수 있으므로 클라이언트에서 데이터를 가져오고 싶지 않습니다 (API 레이어를 사용하지 않는다는 점을 기억하세요). 대신 서버에서 데이터를 가져와서 컴포넌트에 prop으로 전달할 수 있습니다.
/dashboard/invoices/page.tsx에서 fetchInvoicesPages라는 새 함수를 가져오고 querysearchParams을 인수로 전달합니다.
// /app/dashboard/invoices/page.tsx
// ...
import { fetchInvoicesPages } from '@/app/lib/data'; // 함수 불러오기
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는 검색어를 기준으로 총 페이지 수를 반환합니다. 예를 들어, 검색어와 일치하는 청구서가 12개 있고 각 페이지에 청구서 6개가 표시되는 경우 총 페이지 수는 2가 됩니다.
다음으로 totalPages prop을 <Pagination/> 구성 요소에 전달합니다.
// /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} /> // prop 전달
</div>
</div>
);
}
<Pagination/> 구성 요소로 이동하여 usePathname 및 useSearchParams 후크를 가져옵니다. 이를 사용하여 현재 페이지를 가져오고 새 페이지를 설정합니다. 이 구성요소의 코드 주석 처리도 제거해야 합니다. 아직 <Pagination/> 로직을 구현하지 않았으므로 애플리케이션이 일시적으로 중단됩니다. 지금 해봅시다.
'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 내에 createPageURL이라는 새 함수를 만듭니다. 검색과 마찬가지로, 새 페이지 번호를 설정하는 데 URLSearchParams를, URL 문자열을 만드는 데 pathName을 사용할 겁니다.
// /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은 현재 검색 매개변수의 인스턴스를 생성합니다.나머지 <Pagination> 구성 요소는 스타일 지정 및 다양한 상태(첫 번째, 마지막, 활성, 비활성화 등)를 처리합니다. 이 과정에서는 자세히 다루지 않지만 코드를 통해 createPageURL이 호출되는 위치를 확인하세요.
마지막으로 사용자가 새 검색어를 입력하면 페이지 번호를 1로 재설정하려고 합니다. <Search> 구성 요소 handleSearch 함수를 업데이트하면 됩니다.
// /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);
축하해요! URL 매개변수 및 Next.js API를 사용하여 검색 및 페이지 매김을 구현했습니다.
요약하면 이 장에서는 다음과 같습니다.
useRouter는 보다 원활한 클라이언트 측 전환을 위해 라우터 후크를 사용하고 있습니다.이러한 패턴은 클라이언트 측 React로 작업할 때 익숙했던 패턴과 다르지만 이제 URL 검색 매개변수를 사용하고 이 상태를 서버로 가져오는 것의 이점을 더 잘 이해할 수 있기를 바랍니다.