Next.js-14 튜토리얼 따라가기 -2 (5~8장)
Next.js-14 튜토리얼 따라가기 -2 (5~8장)
1~4장을 통해서 간단하게 next에서는 UI. 이미지 . 라우팅. 폰트를 어떻게 구현 및 처리했는지를 확인했다. 5장 Navigating Between Pages 5장에서는 link, navigation 원리 , usePathname() 훅 , 에 대해서 알아볼 것
ungumungum.tistory.com
Next.js-14 튜토리얼 따라가기 -1
next.js 14버전이 나왔다. 물론 난 처음이다. 이력서 제출하려고 하는데, next.js 쓰는 회사가 생각보다 너무 많아서 튜토리얼이라도 우선적으로 학습하려고 한다.. 튜토리얼 따라가기 -2 링크 : 5~8장
ungumungum.tistory.com
5~8 장에서 동적랜더링에 대해 배웠는데, 이때 랜더링이 가장 느린 페이지를 기준으로 된다고 배웠다.
이제 이를 해결하여 UX를 향상하는 법을 배울 차례다.
9장 Streaming
9장에서 배울 내용은
1. 스트리밍이 뭐고 언제 쓸지
2. 스트리밍을 loading.tsx와 Suspense로 어떻게 구현할지
3. 로딩 스캘래톤이 뭔지
4. 루트 그룹이 무엇이고, 언제 쓸지
5. Suspense boundaries를 앱 어디에 위치시킬지
1. 스트리밍이란?
스트림은 데이터 전송 기술 중 하나로, route를 좀 더 작은 덩어리로 쪼갠 후에 각각의 덩어리들이 준비가 완료되면 점진적으로 서버에서 클라이언트로 전송하는 것이다.
이를 이용하면 동적 랜더링에서 겪은 가장 느린 요청 때문에 페이지 로딩이 밀리는 현상을 해결할 수 있다.
스트리밍은 두 가지 방법을 통해 구현할 수 있다.
1. loading.tsx
2. Suspense로 된 컴퍼넌트
#1 loading.tsx 처리하는 법은 간단하다
dashboard 폴더에 해당 파일을 만들면 이번엔 바로 이동하고, 대신 3초 동안 loading이라고 뜬다.
사이드바는 정적이기에 바로 나오고, 우측에 있는 동적인 부분만 로딩처리된다.
export default function Loading() {
return <div>Loading...</div>;
}
이 경우 로딩을 기다리지 않고 페이지 이동도 가능하다. ( interruptable navigation)
이후 단순하게 로딩처리했던 컴퍼넌트를 스켈레톤 이미지로 교체하면 , 일반적인 사이트들처럼 UX를 향상할 수 있다.
하지만 이 스켈레톤 이미지를 dashboard 폴더에서 그냥 처리하면, 하위에 있는 다른 라우트에도 영향을 준다.
우린 invoices나 Customers 말고 Home에서만 적용시키고 싶다. (index역할)
이를 사용하는 방법이 소괄호 ( ) 이다. 소괄호 안에 내용은 url path에 포함되지 않아서 /dashboard/(overview)/page.tsx 는 /dashboard.와 같아진다.
#2 각각의 컴퍼넌트를 Suspense를 통해 세분화해서 랜더링 하기
이 방식을 사용할 경우 로딩처리는 되었지만 Revenue , Card, latestInvoices 3가지 요청이 하나의 로딩을 통해서 처리가 된다. 이를 React Suspense를 사용하면 세분화해서 특정 컴퍼넌트를 랜더링 시킬 수 있다.
현재 Revenue 요청에 딜레이를 걸었기 때문에 해당 컴퍼넌트만 따로 Suspense처리하면 된다.
이후 매개변수로 Data를 받아오던 Revenue 컴퍼넌트를 직접 데이터 요청하는 것으로 수정하면 된다.
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';
// This component is representational only.
// For data visualization UI, check out:
// https://www.tremor.so/
// https://www.chartjs.org/
// https://airbnb.io/visx/
export default async function RevenueChart() {
const revenue = await fetchRevenue();
하지만 카드의 경우에는 각각을 Suspense 처리하면 갑자기 튀어나오는 듯이 느껴질 수 있다.
따라서 그룹화하여서 Suspense 처리하는 것이 좋다.
Suspense 영역은 3가지로 보통 구분을 한다
1. 유저가 어떻게 페이지를 경험하면 좋겠다고 생각하는지
2. 너가 어떤 컨텐츠를 우선시 여기는지
3. 컴퍼넌트가 대이터 요청에 의존하는지
그리고 이를 좀 더 세분화하려면 data fetching을 최대한 하단 컴퍼넌트에서 하는 게 좋다.
10장 Partial Prerendering
Next 14에 도입된 실험적인 기능이라, 스킵해도 된다고 한다.
현재 넥스트에서 noStore()를 사용하면서 Dynamic인지 Static인지 구분하고 있다. 대부분의 사이트들 또한 특정 부분은 정적이고 어떤 부분은 동적일 것이다.
프리랜더링을 사용하면 3가지 효과를 얻을 수 있다.
우선 프리랜더링에서 정적루트쉘이 제공돼서 로드를 빠르게 할 것이다.
해당 쉘은 홀(구덩이)들을 남기는데, 여기에 동적 컨텐츠가 비동기적으로 로드된다.
비동기적 홀들은 평행하게 로드되고, 전체적인 로드타임을 감소시킨다.
프리랜더링은 리엑트의 ConCurrent API와 Suspense를 사용하여 작동한다.
폴백은 초기 정적 파일에 정적 내용과 같이 존재하고, 정적인 부분은 Pre-Rendering 되고 동적인 부분은 유저가 해당 라우트 접속까지 지연된다.
이후 서스팬스로 동적인 부분과 정적인 부분을 구분하면 되고, next가 알아서 코드 수정없이 동적인부분과 정적인 부분을 구분해 준다.
위 택스트는 1~10장 내용을 요약해 놓은 것이다.
11장 Adding Search and Pagination
이제 Next.js APIs인 searchParams, usePathname, and useRouter를 써서 검색을 구현할 것이다.
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>
);
}
3가지 컴퍼넌트로 구성되어 있는데
1. Search : 특정 송장 검색
2. Pagination : 페이지네이션
3. Table : 송장들을 나열함.
유저가 검색하면 url이 변경되고, 서버로부터 새 데이터를 받은 후 리랜더링 될 것이다.
검색은 URL에 검색 내용이 담기는 방식으로 되어 있다. 이는 클라이언트 state로 관리하는 방식과는 다른데 3가지 장점이 있다.
- Bookmarkable and Shareable URLs:
검색 파라미터가 URL에 있어서, 유저가 현재 페이지를 내용과 함께 즐겨찾기 할 수 있다. - Server-Side Rendering and Initial Load
초기 상태를 랜더링 할 때, URL 파라미터가 직접적으로 이용되어 서버랜더링을 다루기 쉬워진다. - Analytics and Tracking
별 다른 클라이언트 로직없이도, 유저가 어떤 식으로 검색하는지 분석하기 쉬워진다.
이를 위해서 3가지 훅을 사용해서 검색을 구현할 수 있다
- useSearchParams
URL에 접근하기 위해 쓰며 /dashboard/invoices?page=1&query=pending 을 만들려면
{page: '1', query: 'pending'}을 하면 된다. - usePathname
현재 URL을 읽을 수 있다. /dashboard/invoices 이면 usePathname 훅은 '/dashboard/invoices''/dashboard/invoices'. 을 반환한다. - useRouter
클라이언트 컴퍼넌트가 내비게이션 할 때 사용함
실행 순서는 4가지로 구성된다
- 유저의 인풋을 캡처한다.
- URL을 검색 매개변수로 업데이트한다.
- URL을 인풋과 동기화한다.
- 검색 쿼리바탕으로 데이터를 업데이트한다.
이를 위해서 input이 있는 컴퍼넌트가 'use client'를 통해 훅과 이벤트가 사용 가능한 클라이언트 컴퍼넌트로 되어있는 걸 볼 수 있다.
1. 검색함수 추가해서 입력값 들어오는지 확인함
'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>
);
}
2-1. useSearchParams 훅으로 검색 시 params를 세팅함.
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
}
URLSearchParams 은 URL쿼리 파라미터를 조절하는 WEB API이다.
이때 비어있을 때는 쿼리문 자체 없애준다.
2-2. useRouter훅으로 세팅한 params를 교체해 줌
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
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');
}
console.log(params, term);
replace(`${pathname}?${params.toString()}`);
}
이때 next의 client-side navigation에 의해 페이지가 리로딩이 일어나지는 않는다.
3. 인풋 내용을 URL과 동기화함
<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()}
/>
초기값을 Param에서 얻도록 해서 URL로 페이지 이동시에도 INPUT창과 동시화를 시켜준다.
STATE를 따로 쓰고 있지 않아서, Value보단 초깃값만 defaultValue로 설정하는 게 좋다.
4. 테이블 업데이트 하기
https://nextjs.org/docs/app/api-reference/file-conventions/page
File Conventions: page.js | Next.js
API reference for the page.js file.
nextjs.org
export default async function Page({
searchParams,
}: {
searchParams?: {
query?: string;
page?: string;
};
}) {
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
우선 route에서 serachParams를 매개변수로 받는다.
이후 serach에 매개변수로 넘겨준다.
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>
);
}
이후 테이블에서 해당 매개변수를 받아서 데이터 요청을 한다.
import Image from 'next/image';
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons';
import InvoiceStatus from '@/app/ui/invoices/status';
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils';
import { fetchFilteredInvoices } from '@/app/lib/data';
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
const ITEMS_PER_PAGE = 6;
export async function fetchFilteredInvoices(
query: string,
currentPage: number
) {
noStore();
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
try {
const invoices = await sql<InvoicesTable>`
SELECT
invoices.id,
invoices.amount,
invoices.date,
invoices.status,
customers.name,
customers.email,
customers.image_url
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`} OR
invoices.amount::text ILIKE ${`%${query}%`} OR
invoices.date::text ILIKE ${`%${query}%`} OR
invoices.status ILIKE ${`%${query}%`}
ORDER BY invoices.date DESC
LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset}
`;
return invoices.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoices.');
}
}
useSearchParams() hook vs. the searchParams prop
클라이언트 컴퍼넌트에서는 훅을 사용하고, 아닌 경우 props로 route나 매개변수를 통해 전달받는다.
이후 페이지 이동을 너무 자주 하면 성능에 무리가 가니깐 debouce를 걸어준다.
예제에서는 쉽게 구현하기 위해 use-Debounce훅을 인스톨했다.
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const handleSearch = useDebouncedCallback((term) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
마지막으로 페이지네이션까지 ( 당연히 클라이언트 컴퍼넌트다 , 따라 useSerachParams,)
'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 }) {
// NOTE: comment in this code when you get to this point in the course
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const allPages = generatePagination(currentPage, totalPages);
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
createPageURL에서 query문에다가 pageNumber를 더해주어서 새로운 url을 만들어준다.
그리고 검색창에 초기 page를 1로 설정해 준다.
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
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);
12장 Mutating Data
아직은 CRUD 중에서 데이터를 읽기만 했다. 따라서 12장에서 배울 내용은
1. React Server Actions가 무엇이고, 데이터 변경할 때 어떻게 쓰는지
2. 서버 컴포넌트와 폼에서 어떻게 작동하는지
3. 폼데이터 사용 예제 (타입 유효성 포함)
4. revalidatePath APi를 써서 revalidate 하기 ( ISR)
5. 특정 아이디 값을 이용해 동적 라우트 어떻게 하는지
6. useFormStatus 훅으로 업데이트 최적화를 어떻게 하는지
우선 ServerAction이 무엇일까?
Server Actions는 비동기적 코드를 서버에서 직접적으로 쓰게 하는 것이다.
즉. 클라이언트나 서버 컴퍼넌트에서 비동기적 함수를 일으킬 수 있게 해 준다.
이러한 요청은 보안이 중요한데, POST requests(포스트 요청), encrypted closures(암호화된 클로져), strict input checks(엄격한 입력검사), error message hashing(에러 메시지 헤싱), and host restrictions(호스트 제한) 기능을 통해서 Server Actions는 보안을 신경 쓴다.
서버 컴퍼넌트와 폼에서 어떻게 작동하는지
// 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>;
}
form에서 서버컴퍼넌트를 사용해서 formData를 만들 수 있는데, 이러면 클라이언트 자바스크립트가 꺼져 있어도 폼데이터가 생긴다.
Server Actions는 넥스트의 캐싱과 깊게 통합되어 있다. Server Action을 통해 Form이 전송되면 데이터 변경뿐만 아니라,
캐싱을 revalidatePath and revalidateTag. 을 통해서 초기화시킬 수 있다. (데이터 최신화)
새 송장은 아래 순서로 이루어진다.
- 유저 인풋을 입력받는 폼 만든다
- 폼에서 Server Action을 일으킨다.
- 서버액션의 Form Data에서 데이터를 추출한다
- 디비에 입력할 데이터를 확인하고, 유효성 검증한다.
- 데이터 입력하고 에러 처리한다
- 캐시를 초기화하고 유저를 invoices 페이지로 이동시킨다.
이를 코드로 확인하면 아래 순서대로다
1. 우선 Create라는 새로운 Route를 폴더 생성을 통해 만든다
import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page() {
const customers = await fetchCustomers();
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Create Invoice',
href: '/dashboard/invoices/create',
active: true,
},
]}
/>
<Form customers={customers} />
</main>
);
}
2. 폼에 사용할 Server Action을 만든다.
/app/lib/actions.ts 경로에 아래 파일을 만든다. 이때 use server를 통해서 클라이언트 컴포넌트나 서버 컴퍼넌트에서 사용해도 서버 함수로 작동할 수 있도록 한다. (서버 컴퍼넌트인 경우 굳이 분리 안 해도 상관은 없다)
'use server';
export async function createInvoice(formData: FormData) {}
'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}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Choose customer
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
>
이때 form을 사용하는데 전송할 URL을 설정하지 않았다. 이는 React는 일반 HTML과 다르게 특별한 props 취급되어 모든 action을 전달받을 수 있다. 따라서 별다른 url 설정을 하지 않아도, Server Action이 작동한다. (설정한다면 API Endpoint를 사용할 수 있다)
3. 폼데이터에서 데이터 추출
'use server';
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
};
// Test it out:
console.log(rawFormData);
}
필드가 많다면 아래 식으로 확인
const rawFormData = Object.fromEntries(formData.entries())
이후 액션을 일으키면 콘솔이 찍히는 걸 확인할 수 있다 ( 서버 컴퍼넌트라서 터미널에 나옴)
4. 데이터 유효성 검증
이후 액션을 일으키면 콘솔이 찍히는 걸 확인할 수 있다 ( 서버 컴퍼넌트라서 터미널에 나옴)
하지만 type of를 통해서 input의 amount를 확인하면 number 타입으로 입력받았지만 전송되면서 string으로 변환된 걸 확인할 수 있다. 이때 우리는 초기 설정한 타입에 맞게 확인 및 변환할 필요가 있다.
export type Invoice = {
id: string; // Will be created on the database
customer_id: string;
amount: number; // Stored in cents
status: 'pending' | 'paid';
date: string;
};
이를 간단하게 하기 위해 zod라이브러리를 사용했고 코드는 아래와 같다.
'use server';
import { z } from 'zod';
const InvoiceSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = InvoiceSchema.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'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
}
( 센트이기에 100을 곱하여 타입을 넘버로 변환, 입력받지 않은 날짜를 설정.)
5. 디비에 데이터 삽입
이후 sql문으로 DB에 해당 값을 넣어주면 된다.
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
const InvoiceSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = InvoiceSchema.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'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
}
6. 데이터가 추가 됐으니 관련 페이지를 revalidate 해줌
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
const InvoiceSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = InvoiceSchema.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'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
추가된 내용은 /dashboard/invoices 에서 확인하니 해당 url을 revalidate 함
그리고 redirect 해주면 됩니다.
이제 수정과 삭제를 통해서
특정 아이디 값을 이용해 동적 라우트 어떻게 하는지에 대해서 알아볼 차례다.
업데이트는 아래 순서로 이루어진다.
- 송장아이디로 동적 루트를 만든다
- 파람으로 송장 아이디를 읽는다.
- 특정 송장을 아이디로 패칭 한다
- 수정 내용을 미리 채운다
- 디비 업데이트를 한다
1. 송장 아이디로 동적 루트 만들기
넥스트에서는 동적 루트들을 대괄호로 만들 수 있다.
export function UpdateInvoice({ id }: { id: string }) {
return (
<Link
href={`/dashboard/invoices/${id}/edit`}
className="rounded-md border p-2 hover:bg-gray-100"
>
<PencilIcon className="w-5" />
</Link>
);
}
그리고 업데이트 버튼에서 해당 루트로 이동한다.
2. 페이지 파람으로 송장 값 읽어오기 + 3. 특정 송장 정보 얻기
invoices/[id]/edit/page.tsx 에서는 create와 비슷하지만, 고객이름, 송장 값, 상태는 미리 받아와야 한다. 이를 id 값으로 요청할 수 있다.
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Edit Invoice',
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
{invoice && <Form invoice={invoice} customers={customers} />}
</main>
);
}
4. 서버 액션에 invoice 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>
);
}
서버액션에서는 매개변수로 id값을 전달할 수 없고 자바스크립트 바인딩을 이용해야 한다.
이때 updateInvoice는 create와 유사하다.
// Use Zod to update the expected types
const UpdateInvoice = InvoiceSchema.omit({ date: true, id: true });
// ...
export async function updateInvoice(id: string, formData: FormData) {
const { customerId, amount, status } = UpdateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
const InvoiceSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });
const UpdateInvoice = InvoiceSchema.omit({ date: true, id: 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'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
export async function updateInvoice(id: string, formData: FormData) {
const { customerId, amount, status } = UpdateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
날짜는 업데이트하지 않고, 나머지로직은 비슷하게 진행이 된다.
삭제 시에도 비슷하게 아이디를 바인딩해서 삭제하면 된다..
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>
);
}
action.ts의 함
const UpdateInvoice = FormSchema.omit({ date: true, id: true });
// ...
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
삭제는 invoices에서 진행했기에 redirect는 필요 없다.
튜토리얼 설명서와 다르게 optimistic updates는 없다.. 해당 내용은 성공 여부와 상관없이 낙천적으로 성공했다 치고 업데이트하는 것이다.
https://www.youtube.com/watch?v=BsPpIjaKyWQ
링크 참조해도 좋을 거 같다.
Next.js-14 튜토리얼 따라가기 -4 (13장~ 16장)
'프론트엔드 > Next' 카테고리의 다른 글
Auth.js로 Oauth 구현하기 (예시 : google) (0) | 2024.04.27 |
---|---|
Next.js에서 WebVitals 및 성능 측정하기 (0) | 2024.02.20 |
Next.js-14 튜토리얼 따라가기 -4 (13장~ 16장) (0) | 2023.11.12 |
Next.js-14 튜토리얼 따라가기 -2 (5~8장) (0) | 2023.11.10 |
Next.js-14 튜토리얼 따라가기 -1 (1~4장) (0) | 2023.11.09 |