공식 문서 보기를 돌같이 하는 버릇을 고치자!
이 장에서는 URL search params을 통한 검색과 pagination을 학습한다.
URL search params를 이용하여 검색을 하면 몇 가지 이점이 있다.
URL search params를 이용하는데 Next.js의 다음 훅들을 사용한다.
useSearchParams : 현재 URL의 매개변수에 액세스한다. 예를 들어, /dashboard/invoices?page=1&query=pending에 대한 검색 매개 변수는{page: '1', query: 'pending'}이다.usePathname : 현재 URL의 경로 이름을 읽는다. 예를 들어, /dashboard/invoices의 경우, 사용 경로명은 /dashboard/invoices를 반환한다.useRouter : 클라이언트 구성 요소 내에서 경로 간 탐색을 활성화한다.검색의 첫 번째 순서는 유저의 입력 정보를 얻는 것으로, 클라이언트 컴포넌트에서 이루어진다. 때문에 검색 파일 최상단에 use client를 작성하여 클라이언트 컴포넌트임을 명시해야 이벤트 리스너나 훅을 사용할 수 있다.
'use client'; // client component
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
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()}`);
}
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);
}}
defaultValue={searchParams.get('query')?.toString()}
/>
</div>
);
}
URLSearchParams는 Web API 메서드로, 쿼리 파라미터를 ?page=1&query=a와 같은 문자열로 만들어준다. set으로 검색어를 추가하고 delete로 비운다.usePathname으로 가져온 경로에 useRouter의 replace를 이용하여 쿼리를 추가한다.input을 동기화하려면 defaultValue를 설정한다.검색 기능을 최적화하자.
Searching... S
Searching... St
Searching... Ste
Searching... Stev
Searching... Steve
Searching... Steven
지금은 입력할 때마다 요청을 보내 서버 부하를 유발한다. 입력 이벤트가 끝났을 때만 쿼리를 보내도록 Debounce로 이벤트를 제어한다. 여기서는 use-debounce 라이브러리를 사용한다.
// ...
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);
300ms이내에 아무런 입력값이 없을 때 쿼리 요청을 보낸다. 띄엄띄엄 입력했을 때의 결과다.
Searching... ste
Searching... steven
pagination도 비슷한 과정으로 진행한다.
'use client';
// ...
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()}`;
};
// ...
}
검색했을 때는 페이지가 1이 되도록 Search를 수정한다.
export default function Search({ placeholder }: { placeholder: string }) {
// ...
const handleSearch = useDebouncedCallback((term) => {
// ...
params.set('page', '1');
// ...
}, 300);
이전 장에서 CRUD 중 Read를 배웠으니 여기서는 Create, Update, Delete 기능을 추가한다.
React Server Actions는 서버에서 실행되는 비동기 함수를 클라이언트나 서버에서 호출하여 사용하고, API 엔드포인트 없이 데이터 변경이 가능하다. Next.js가 서버 액션을 권장하는 이유는 보안 때문이다. 다양한 공격으로부터 데이터를 안전하게 보호하고 접근을 보장하는 효과적인 보안 솔루션을 제공한다고 한다. POST 요청, 암호화, 엄격한 입력 확인, 오류 메세지 해싱, 호스트 제한과 같은 기술을 통해 보안 목표를 달성하면서 앱의 안정성을 크게 향상시킨다.
JS의 내장 API인 FormData를 통해 action 속성으로 입력값을 수신할 수 있다.
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
use server는 서버 컴포넌트를 가리키는데, 서버 컴포넌트에서 서버 액션을 호출하면 클라이언트의 JS가 비활성화되어 있더라도 양식이 작동하는 이점이 있다.
Next.js에서의 서버 액션은 Next.js Caching과 긴밀하게 통합되어 있다. 서버 액션을 통해 양식이 제출되면 해당 액션을 사용하여 데이터를 변경할 수 있을 뿐만 아니라 revalidatePath 및 revalidateTag와 같은 API를 사용하여 관련 캐시의 유효성을 다시 검사할 수도 있다.
서버 액션에서 사용하는 함수를 모아둔 파일을 만들고 최상단에 use server 지시문을 작성한다. 해당 지시문이 추가된 파일에서 내보낸 함수는 서버 함수로 표시되어 클라이언트나 서버에서 다양하게 사용할 수 있다.
// app/lib/actions.ts
'use server';
export async function createInvoice(formData: FormData) {}
생성한 서버 액션 함수를 form에 전달한다.
'use client';
import { customerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions';
export default function Form({
customers,
}: {
customers: customerField[];
}) {
return (
<form action={createInvoice}>
// ...
)
}
HTML의 <form>과 다른 점은 action에 URL이 아닌 함수가 들어갔다는 점이다. React에서는 특별한 속성으로 간주되어 액션을 호출할 수 있도록 그 위에 빌드됨을 의미한다. 서버 액션은 뒤에서 POST API 엔드포인트를 자동으로 생성한다.
form을 제출하여 서버 액션이 실행되었을 때 다음과 같은 타입을 기댓값으로 원한다.
export type Invoice = {
id: string; // Will be created on the database
customer_id: string;
amount: number; // Stored in cents
status: 'pending' | 'paid';
date: string;
};
하지만 console.log(typeof rawFormData.amount)를 해보면 number가 아닌 string으로 찍히는 것을 볼 수 있다. input type="number"를 했다손 쳐도 FomData에서는 string을 반환한다. 이러한 검증을 수동으로 할 수도 있지만, 여기서는 Zod 라이브러리를 사용하여 검증한다.
'use server';
import { z } from 'zod';
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// ...
}
데이터를 새로 생성하면 기존의 invoices 페이지가 stale한지 아닌지 검증해야 한다. 또한, 작성이 완료되었으므로 생성 페이지에서 invoices 페이지로 리다이렉트한다.
'use server';
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// ...
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
invoice를 수정하기 위해서 개별 페이지를 만들어야 한다. id에 따라 보여지는 invoice 페이지가 다르므로 Dynamic Routes로 개별 페이지를 구현한다. invoices/[id]/edit/page.tsx 경로로 파일을 만든다.

만약 id가 1인 invoice를 수정한다면 경로는 dashboard/invoices/1/edit이 될 것이다.
id를 받아 업데이트하는 서버 액션을 만든다.
// ...
import { updateInvoice } from '@/app/lib/actions';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
return (
<form action={updateInvoiceWithId}>
<input type="hidden" name="id" value={invoice.id} />
</form>
);
}
bind를 사용한 이유는 action에 id 인수를 담은 updateInvoice(invoice.id)를 사용할 수 없기 때문이다. 하지만 이렇게는 사용할 수 있더라. 이후 로직은 Create와 유사하다.
Delete는 id를 받아 삭제 요청을 보내면 된다.
import { deleteInvoice } from '@/app/lib/actions';
// ...
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
return (
<form action={deleteInvoiceWithId}>
<button className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Delete</span>
<TrashIcon className="w-4" />
</button>
</form>
);
}