[Next.js] 공식 문서만 보고 Next.js 익히기(4)

찐새·2023년 12월 5일
0

공식 문서만 보고

목록 보기
8/9
post-thumbnail

공식 문서 보기를 돌같이 하는 버릇을 고치자!

9. Adding Search and Pagination

이 장에서는 URL search params을 통한 검색과 pagination을 학습한다.

9-1. Why use URL search params?

URL search params를 이용하여 검색을 하면 몇 가지 이점이 있다.

  • 북마크 및 공유 가능 : 검색 정보가 URL에 담겨 있기 때문에 사용자는 북마크에 추가해 나중에 참조하거나 공유하기 쉽다.
  • 서버 측 렌더링과 초기 로드 : URL 매개변수를 서버에서 직접 조작하여 초기 상태 렌더링을 더 처리하기 쉽다.
  • 분석 및 추적: 검색 쿼리와 필터를 URL에 직접 넣으면 추가적인 클라이언트 측 로직 없이도 사용자 행동을 더 쉽게 추적할 수 있다.

URL search params를 이용하는데 Next.js의 다음 훅들을 사용한다.

  • useSearchParams : 현재 URL의 매개변수에 액세스한다. 예를 들어, /dashboard/invoices?page=1&query=pending에 대한 검색 매개 변수는{page: '1', query: 'pending'}이다.
  • usePathname : 현재 URL의 경로 이름을 읽는다. 예를 들어, /dashboard/invoices의 경우, 사용 경로명은 /dashboard/invoices를 반환한다.
  • useRouter : 클라이언트 구성 요소 내에서 경로 간 탐색을 활성화한다.

9-2. Adding the search functionality

검색의 첫 번째 순서는 유저의 입력 정보를 얻는 것으로, 클라이언트 컴포넌트에서 이루어진다. 때문에 검색 파일 최상단에 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으로 가져온 경로에 useRouterreplace를 이용하여 쿼리를 추가한다.
  • URL로 직접 이동했을 때 쿼리와 input을 동기화하려면 defaultValue를 설정한다.

9-3. Best practice: Debouncing

검색 기능을 최적화하자.

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

9-4. Adding pagination

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

10. Mutating Data

이전 장에서 CRUD 중 Read를 배웠으니 여기서는 Create, Update, Delete 기능을 추가한다.

10-1. What are Server Actions?

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과 긴밀하게 통합되어 있다. 서버 액션을 통해 양식이 제출되면 해당 액션을 사용하여 데이터를 변경할 수 있을 뿐만 아니라 revalidatePathrevalidateTag와 같은 API를 사용하여 관련 캐시의 유효성을 다시 검사할 수도 있다.

10-2. Create a Server Action

서버 액션에서 사용하는 함수를 모아둔 파일을 만들고 최상단에 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 엔드포인트를 자동으로 생성한다.

10-3. Validate and Revalidate and Redirect

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

10-4. Updating an invoice

invoice를 수정하기 위해서 개별 페이지를 만들어야 한다. id에 따라 보여지는 invoice 페이지가 다르므로 Dynamic Routes로 개별 페이지를 구현한다. invoices/[id]/edit/page.tsx 경로로 파일을 만든다.

만약 id1invoice를 수정한다면 경로는 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와 유사하다.

10-5. Deleting an invoice

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>
  );
}
profile
프론트엔드 개발자가 되고 싶다

0개의 댓글