Next.js Tutorial の学び
clsx を使った className の切り替え。
import clsx from 'clsx';
export default function InvoiceStatus({ status }: { status: string }) {
return (
<span
className={clsx(
'inline-flex items-center rounded-full px-2 py-1 text-sm',
{
'bg-gray-100 text-gray-500': status === 'pending',
'bg-green-500 text-white': status === 'paid',
},
)}
>
// ...
)}
<img>
の変わりに next が提供する <Image>
を使うと画像ファイルをいい感じに最適化してくれる。
page.tsx
は特別なファイル。
app
配下のディレクトリ構成を URL にマッピングし page.tsx
を表示する。
なので ./app/dashboard/page.tsx
を http://exmple.com/dashboard
にマッピングする。
特別なファイル群は下記を参照。
例えば、 layout
を使うと配下ディレクトリのページにレイアウトを強制(?)適用することができる。
Next はサーバサイドの React フレームワークであるが、クライアントサイドの SPA で画面遷移を制御する場合は React による制御となる。
このようなフロント側で制御する場合には特別なディレクティブである 'use client';
を宣言する必要がある。
package.json
に seed 用のスクリプトを追加するので npm run seed
で seed データを投入できる。
seed スクリプトの実体は scripts/seed.js
にある。
コンポーネント自体を async で非同期にできる。
export default async function Page() {
request waterfalls
というアンチパターン。
request waterfalls の対策1。
Promise.all
を使ってパラレルにデータを取得する。
const data = await Promise.all([
invoiceCountPromise,
customerCountPromise,
invoiceStatusPromise,
]);
しかし、特定のリクエストが遅いと引きづられてしまう。
unstable_noStore
を使うことで静的レンダリングをオプトアウトして動的レンダリングを強制する?方法がある。
// ...
import { unstable_noStore as noStore } from 'next/cache';
export async function fetchRevenue() {
// Add noStore() here to prevent the response from being cached.
// This is equivalent to in fetch(..., {cache: 'no-store'}).
noStore();
// ...
}
[MEMO]
これって api に書く? api 使っている箇所が軒並みに動的レンダリングになる?
[NOTE]
unstable_noStore
は実験的な機能、とのこと。
loading.js
という特別なファイルを作成することで、動的レンダリングなコンポーネントをロード中のローディングを表示することができる。
対象は「動的コンポーネントのみ」で静的コンポーネントには適用されない点に注意。
loading.js
や layout.js
などの特別なファイルは子や孫コンポーネントにも適用される。
が、子や孫に適用させずにカレントディレクトリのみに適用させたいケースがある(かもしれない)。
そんなときは Route Groups を使うことで実現できる。
下記のチュートリアルでは (overview)
ディレクトリを作って、その中に loading.tsx
と page.tsx
を入れることで loading.tsx
の適用対象を page.tsx
のみにしている。
論理的グループ化する?感じ。
async/await を使ってすべてを手続き型で処理したい。
だが、手続き型で処理すると request waterfalls にハマる。
外部からのデータ取得はパラレルで取得して、ロードできたものから表示したい。
が、 promise の then/catch と useState を使ったリアクティブな制御はしたくない。
そんなときには <Suspense>
を使うことでコンポーネントのロードをパラレルに(streaming に?)処理することができる。
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
[NOTE]
unstable_noStore に依存しているが、 unstable_noStore は実験的な機能、とのこと。
defaultValue と value について。
特別ファイル page.js
は params
と searchParams
を受け取ることができる。
params
は URL のパスに [id]
などのダイナミックルートセグメントで宣言した値が設定される。
searchParams
は query
と page
を持っており /xxx?query=query&page=1
のような値から取得できる。
クライアントコンポーネントとサーバーコンポーネント
use client
のディレクティブが ある とクライアントコンポーネント。
その場合は制御がクライアントになる。
フォーム入力や、検索条件をブラウザのルーティングに追加するなどの制御はクライアントになる。
use client
のディレクティブが ない ならサーバーコンポーネント。
nextjs の場合は基本がこれ?
その場合は nextjs が提供する機能(例えば特別ファイルの page.js とか)が使える。
では useSearchParams
のようなクライアント向けの hooks を使うコンポーネントの use client
ディレクティブをコメントアウトすると?
-> エラーになる
Server Actions
チュートリアルでは app/lib/actions.ts
を Server Actions と呼んでいる。
おそらく Server Actions という概念の、具体的な実装の一部が app/lib/actions.ts
を指すものと思われる。
もう少し具体的に言えば下記のような感じ?
-
use server
を宣言していて - DB 操作といったサーバー側の処理を行うもの
// 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
ディレクティブ
Server Actions は POST API エンドポイントを作成する?
Zod
TS 向けの検証ライブラリ。
coerce
を使うと強制キャストしつつ検証してくれる。
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
coerce
は強制の意。
revalidatePath
引数に設定したパスのキャッシュをクリアする。
クリアするタイミングは、対象のパスにアクセスしたとき。
宣言しないと、データを操作しても表示に反映されず、キャッシュされている古いデータが表示される。
削除時の動作がわかりやすい。
revalidatePath
をコメントアウトすると、削除しても画面に反映されなくなる。
js の bind
これを使って form のデータを引数に設定している?(流れがよくわからん)
next/navigation
が提供する redirect
は例外を投げるので try/catch を使う場合は注意が必要。
(catch (error)
すると拾ってしまう)
eslint-plugin-jsx-a11y
による accessibility の警告。
next lint
コマンドでチェックできる。
useFormState
react が提供するフック。
ただし 202404 現在では実験的な機能。
hook なので client side 向け。
const [state, dispatch] = useFormState(createInvoice, initialState);
- createInvoice : server action の実処理。 FormData 型の入力値を受け取りバリチェ、 DB 操作を行う。返り値は state に設定される
-
initialState :
state
に設定される初期値 -
state : 初期値は
initialState
で、 action 処理後は action の返り値になる -
dispatch :
<form action={action}>
のように form タグ設定用に制御が加えられたメソッド
useFormState を使うとなると server action 内で例外を飛ばすと意図しない振る舞いが発生する。(チュートリアル的にいうと zod のバリチェ)
このため zod のバリチェは safeParse
を使って検証と検証結果のチェックを別々に処理するとよい。
// 検証
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// 検証結果のチェック
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
}
// OK ならば検証結果から入力値を取得
const { customerId, amount, status } = validatedFields.data;
認証
NextAuth.js
auth.config.ts
認証用のコンフィグファイル。
nextjs や nextauth が提供しているファイルではない。
プロジェクトとしての 決め のファイルなのでファイル名やパスは任意でいいはず。。
(チュートリアルでは middleware.ts で NextAuth を初期化しているので、そこで書いてもいいはず)
今回は、認証はプロジェクトとしてユニークになるのでルートに置いている気がする。
NextAuth.js の設定オプションが含まれる。
middleware.ts
nextjs が提供するミドルウェア用のファイル。
プロジェクトルートにある必要がある。
また、1つのプロジェクトに1ファイルのみサポートしている。
bcrypt は Next.js ミドルウェアでは利用できない Node.js API に依存している。
auth.ts
auth.config.ts 拡張用(?)のファイル。
nextjs や nextauth が提供しているファイルではない。
プロジェクトとしての 決め のファイルなのでファイル名やパスは任意でいいはず。。
auth.config.ts と別にする理由はよくわらん。
ただ auth.config.ts は middleware.ts でも使っている。
認証処理に関するドメインと、ミドルウェアに関するドメインでドメインが異なるのでファイルを分けた?
認証処理自体は auth.ts に書かれている。
- zod による入力値の検証
- ユーザーの取得
- ユーザーがあればユーザー情報を返す、なければ null を返す
NextAuth()
による初期化後に返るオブジェクトは NextAuthResult 。
signIn, signOut
はメソッドで、サインインやサインアウト処理を呼び出す際に使用する。
useFormStatus
useFormStatus は、直近のフォーム送信に関するステータス情報を提供するフックです。
function LoginButton() {
const { pending } = useFormStatus();
return (
<Button className="mt-4 w-full" aria-disabled={pending}>
Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
</Button>
);
}
pending
が送信状態を boolean で返すので、送信状態で有効/無効を切り替えている。
favicon.ico
や opengraph-image.jpg
を /app
下に置くと自動的に反映される。
layout.tsx
で下記のメタデータを metadata: Metadata
として宣言して export すると自動的に反映される。
- title
- description
- metadataBase
また page.tsx
で上書きすると個別に設定可能。
layout.tsx
で次のような書き方で埋め込み式に書くことも可能。
title: {
template: '%s | Acme Dashboard',
default: 'Acme Dashboard',
},
page.tsx
に宣言があると template が、宣言がなければ default が適用される。
最終的なディレクトリ構成
.
|-- app
| |-- dashboard
| | |-- (overview)
| | | |-- loading.tsx
| | | `-- page.tsx
| | |-- customers
| | | `-- page.tsx
| | |-- invoices
| | | |-- [id]
| | | | `-- edit
| | | | |-- not-found.tsx
| | | | `-- page.tsx
| | | |-- create
| | | | `-- page.tsx
| | | |-- error.tsx
| | | `-- page.tsx
| | `-- layout.tsx
| |-- favicon.ico
| |-- layout.tsx
| |-- lib
| | |-- actions.ts
| | |-- data.ts
| | |-- definitions.ts
| | |-- placeholder-data.js
| | `-- utils.ts
| |-- login
| | `-- page.tsx
| |-- opengraph-image.png
| |-- page.tsx
| `-- ui
| |-- acme-logo.tsx
| |-- button.tsx
| |-- customers
| | `-- table.tsx
| |-- dashboard
| | |-- cards.tsx
| | |-- latest-invoices.tsx
| | |-- nav-links.tsx
| | |-- revenue-chart.tsx
| | `-- sidenav.tsx
| |-- fonts.ts
| |-- global.css
| |-- invoices
| | |-- breadcrumbs.tsx
| | |-- buttons.tsx
| | |-- create-form.tsx
| | |-- edit-form.tsx
| | |-- pagination.tsx
| | |-- status.tsx
| | `-- table.tsx
| |-- login-form.tsx
| |-- search.tsx
| `-- skeletons.tsx
|-- auth.config.ts
|-- auth.ts
|-- middleware.ts
|-- next-env.d.ts
|-- next.config.js
|-- package-lock.json
|-- package.json
|-- postcss.config.js
|-- prettier.config.js
|-- public
| |-- customers
| | |-- amy-burns.png
| | |-- balazs-orban.png
| | |-- delba-de-oliveira.png
| | |-- emil-kowalski.png
| | |-- evil-rabbit.png
| | |-- guillermo-rauch.png
| | |-- hector-simpson.png
| | |-- jared-palmer.png
| | |-- lee-robinson.png
| | |-- michael-novotny.png
| | |-- steph-dietz.png
| | `-- steven-tey.png
| |-- hero-desktop.png
| `-- hero-mobile.png
|-- scripts
| `-- seed.js
|-- tailwind.config.ts
`-- tsconfig.json
use client
を適用しているコンポーネント
file path | row num | note |
---|---|---|
app/dashboard/invoices/error.tsx | 1 | invoices 内のサーバサイド(?)で発生する error をフックする特別なファイル。クライアント側である必要があるとのこと |
app/ui/dashboard/nav-links.tsx | 1 | サイドバーやSP幅時のヘッダメニュー。静的に表示後はフロント側で動的に制御(ハイライトとか)したいのでクライアント側 |
app/ui/invoices/create-form.tsx | 1 | 新規登録のフォーム。入力の動的制御やサーバへのリクエストなどはクライアント側の制御 |
app/ui/invoices/edit-form.tsx | 1 | 編集のフォーム。入力の動的制御やサーバへのリクエストなどはクライアント側の制御 |
app/ui/invoices/pagination.tsx | 1 | ページネーション。一覧の再読込などはクライアント側で動的に制御するのが一般的(サーバ制御で再度全読み込みしてもいいけど) |
app/ui/login-form.tsx | 1 | ログインフォーム |
app/ui/search.tsx | 1 | 検索 |
use server
を適用しているコンポーネント
file path | row num | note |
---|---|---|
app/lib/actions.ts | 1 | actions に書かれているのは sql. なのでサーバ側で動くことが前提 |
app/ui/dashboard/sidenav.tsx | 23 | サインアウト処理に宣言されている部分適用。サインアウト処理はサーバ側で処理するので宣言が必要? |
use client
ディレクティブは、サーバー側で生成する静的なコンポーネント内に、クライアント側で制御したい動的なコンポーネントを含めたい場合に宣言する感じ
なので例えば page.tsx
に use client
が宣言されることはないはず。
あくまでも部分的なコンポーネントに宣言される。
(ページをまるっと動的にしたい場合は宣言するかも?)
use server
ディレクティブは server actions に宣言するディレクティブ。
つまり コンポーネントには宣言しない であり サーバー側で実行する処理に宣言する もの。
同期系の Page
file path | row num | code | note |
---|---|---|---|
app/dashboard/customers/page.tsx | 1 | export default function Page() { | 未実装のカスタマページ |
app/page.tsx | 7 | export default function Page() { | 静的なトップページ |
非同期系の Page
file path | row num | code | note |
---|---|---|---|
app/dashboard/(overview)/page.tsx | 8 | export default async function Page() { | ダッシュボードのトップコンポーネント。 Suspense で非同期にデータ取得している |
app/dashboard/invoices/[id]/edit/page.tsx | 6 | export default async function Page({ params }: { params: { id: string } }) { | インボイスの編集ページ。内部で await で DB アクセスあり |
app/dashboard/invoices/create/page.tsx | 5 | export default async function Page() { | インボイスの登録ページ。内部で await で DB アクセスあり |
app/dashboard/invoices/page.tsx | 15 | export default async function Page({ | インボイスの一覧ページ。内部で await で DB アクセスあり |