🦊

Learn Next.js の後半で学んだことをまとめました

2024/01/18に公開

はじめに

Nextjs Learn の各章で学んだことを
後半の8章から備忘録として書かせていただきました。

第8章 静的レンダリングと動的レンダリング

Nextjs キャッシュによって得られるメリットが3つあります。

・Web サイトの高速化
事前にレンダリングされたコンテンツをそのまま表示することで
サイトの高速化に繋がります。

・サーバー負荷の軽減
上記と同じ理由です。

・SEO 対策

静的レンダリングはデータのない UI、ブログや EC サイトの製品ページなど
ユーザーによって変わるものではないものは静的レンダリングに向いています。

ダイナミックレンダリング(動的レンダリング)とは
静的レンダリングとは逆で
ユーザーによって変わるものやリアルタイムに更新されるものに
動的レンダリングは向いています。

next/cache から unstable_noStore をインポートし
更新が必要な関数に unstable_noStore を指定することにより
ビルド時だけではなくリクエスト時にも
送ってくれるようになります。

セグメントごとにexport const dynamic = "force-dynamic"
指定することにより動的レンダリングとして
扱うことができるようになります。

Promise.all を使用した際など
1つのデータが他のすべてのリクエストより
遅い場合は全て引きづられてページの表示が
遅くなってしまいます。

第 9 章 ストリーミング

第8章最後の問題の解決法として
ストリーミングを行うことにより解決することができます。

ストリーミングとはルートをより小さな塊として分割させて
データ取得が整ったものから次第に UI を表示させることです。

ストリーミングを実装する方法として以下の方法ができます。

  1. loading.tsxファイルを使う
  2. <Suspense>というコンポーネントを使う

8章でfetchRevenue関数に
3秒遅らせて表示させるコードを書きました。d d

Incoices Page からホームに戻る際、
データを取得している間だけ loading.tsx 内で
書かれたコードが反映されるようになりました。

現段階では Invoices ページから Customers ページに
移動する際も一瞬だけ loading が表示されてしまいます。

この問題を解決するのがルートグループです。

ディレクトリ名に()で囲うことによりパスに影響を
与えないグループを作成することができます。
今回は dashboard ディレクトリ内に(overview)を作成し
その中にpage.tsxloading.tsxを入れます。

これにより loading.tsx の範囲が Home のみに絞られます。

コンポーネントごとにに<Suspense>で囲うことによりストリーミングを
行うことができます。
これによりコンポーネントごとにデータの送信が完了次第
画面に表示されるようになります。

現在カードが4つ一気に表示されているが
これを1つ1つデータの送信が終わり次第表示
することはできるが
あまりにも細かくストリーミングを行うと
ポップ効果が発生してしまう。

ポップ効果とはユーザーの目がチカチカしてしまうこと

コンポーネントのグループ化を行うことにより
<Suspense>で囲う部分を1まとめにすることができます。

データ取得はそのコンポーネントで行うこと、
そうすることにより静的レンダリングの部分も
データを取得してしまい、サイトが少し重くなってしまう。
親ではあんまりやらないほうがいい。

今回でいうと Dashboard の文字が一緒に
Loading されてしまう問題を避けています。

サスペンスの境界線はどこにボーダーを区切るか

  1. ストリーミング中にユーザーにページをどのように体験してもらいたいか。
  2. どのコンテンツを優先したいか。
  3. コンポーネントがデータの取得に依存している場合。

このようなボーダーがありますが
アプリケーションによって異なるので答えはない。
必要なコンポーネントでデータフェッチを行い、
特に重たいコンポーネントはサスペンスで囲いましょう。

第 10 章 部分的な事前レンダリング

ルート内でnoStore()cookies()関数を使用するとルート全体が動的になります。

Partial Prerendering(部分プリレンダリング)とは
一部の部分を動的に保ちながら静的なレンダリングを行うことができることです。
静的な骨組みみたいなのを作ってそこに動的な部分の
穴を作る仕組みです。

なぜ穴という言い方をしたかというと
React コアチームの Dan さんが部分プリレンダリングの話を
している時にホール(穴)というワードを使っていたためです。

全体が基本的には静的なページになっているため
高速でユーザーに返されます。
動的部分は9章で行った、ストリーミングを行うことで
準備ができた部分から表示され、
ユーザーの待ち時間をなくすことができます。

Partial Prerendering はまだ実験的なもので
運用環境に導入する準備ができていないことに注意すること。

第 11 章 検索とページネーションの追加

検索をした際に URL の末尾に検索したパラメータと一致させます
検索パラメーターと一致させるメリットとして

・ブックマーク可能および共有可能にすることができるから。

・検索パラメーターと一致させることにより
検索されたタイミングでサーバー側で処理してくれるため
処理が早くなる。
逆に useState()等を用いるとクライアンとになるため
処理が遅くなります。

検索機能の実装には以下のフックを使用します。

usePathname
パスネームを取ってくるものです。
今自分がどのパスにいるかを取ってくれます。

useRouter:
ルーティングの遷移を行うもので。
replace を使うことによって履歴とかを
残すことなく別の URL に書き換えることができます。

useSearchParams:
URLSearchParams というブラウザの API を
扱いやすくしています。
下記に実際に使用したコードを記入します。

search.tsx

  const searchParams = useSearchParams();

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);

    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
  }

上記のコードは引数の term が検索しているワードが入ります。
if 文でもし検索しているワードがあれば
query に検索ワードが入ります。
https://hoge?query=term
みたいになります。
検索ワードがなくなった時は query 自体なくしてくださいね
みたいな異動きをしています。

ページの URL に直接query=termみたいな感じで
アクセスされてしまった場合
同期されていないので input に反映されていません。

同期するには input 内にdefaultValue
query を指定してあげる必要があります。

search.tsx
<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()}
/>

next-usequerystate のパッケージを使うと
2行くらいでコードをかけるようになります。

更に shallow を false にすることにより
1つ1つ比較してくれるようになります。

search.tsx
const [query, setQuery] = useQueryState('query',{ shallow: false });
<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) => {
          setQuery(e.target.value);
        }}
        value={query || ''}

useQueryState と input に value と onChange をしていするだけで
検索パラメーターを使用することができます。

Suspence に key などの動的な部分を用いることで
囲ってある子要素の今回でいうと
querycurrentPageの部分が変わるたびに
子要素を再レンダリングしてくれます。

invoices/page.tsx

	<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
    	<Table query={query} currentPage={currentPage} />
    </Suspense>

なぜ useSearchParams を使っているかというと
クライアントコンポーネントだから使っている
検索機能自体動的だが、
Table のコンポーネントは親から query とかを渡していて
親要素である page.tsx では引数で searchParams を持っています
この引数はサーバーコンポーネントですが
searchParams の query をそのまま Table で使うことにより
page.tsx で searchParanms を使用してもサーバーコンポーネントになる

基本的には Nextjs ではサーバーコンポーネントにするのがベストのため
page.tsx の親要素で useSearchParams()を使用して
クライアントにしてもいいのだが、なるべく子要素で
クライアントコンポーネントにし、且できるならサーバーコンポーネント
にするコードを書きましょう。

現在 input 要素に検索文字を入力すると
1文字1文字リクエストが走っている状態になっています
これはあまりよくないので間引く処理を行います。
それがデバウンス処理と言います。

公式サイトでは use-debounce パッケージを用いて
デバウンスを行っていますが、
next-usequerystate で Throttling オプションで行えるのでそちらで処理します。

search.tsx
  const [query, setQuery] = useQueryState('query', {
    shallow: false,
    throttleMs: 1000,
  });

1000 と入力することでキーストロークから1秒後に
リクエストが走るようになります。

next-usequerystate でページネーションを使用する場合は
新しく page の state を指定します。
そしてインプット要素に setPage で初期値の1を指定してあげます。

search.tsx
  const [query, setQuery] = useQueryState('query', {
    shallow: false,
    throttleMs: 1000,
  });
  追加
    const [page, setPage] = useQueryState('page', {
    shallow: false,
  });

  <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) => {
			//追加
          setPage('1');
          setQuery(e.target.value);
        }}
        value={query || ''}

      />

こうすることにより URL に query とはまた別に page というパラメータを
追加することができます。

第 12 章 データの変更

サーバーアクションとはサーバー上で非同期コードを直接実行することができ
データを変更するために API エンドポイントを作成する必要がなくなり
セキュリティ面を大幅に強化してくれます。

export default function Page() {
  async function create(formData: FormData) {
    //use serverと入力することで
    //createアクションがサーバーコンポーネントになります。
    "use server";
    // サーバーアクションはサーバーでした実装されなくなるので
    //シークレットなどを含めることができます。
  }
  return <form action={create}>...</form>;
}

サーバーコンポーネント内でサーバーアクションを呼び出すことの利点は
クライアントで Javascript を無効にしても
フォームを機能することができます。

サーバーアクションは Nextjs キャッシュと結語サれており
revalidatePath や revalidateTag を用いて API に関する
キャッシュを再検証することができます。

サーバーアクションの作りかたとして
1つは先程のコードでやったように
サーバーコンポーネントの関数のトップレベルに
use serverとつけることと

もうひとつがモジュールごとサーバーアクション化させることです。

/app/lib/actions.ts
'use server';

export function name(params:type) {

}

export function name(params:type) {

}

こうすることにより全ての関数がサーバーアクションとなります。

<form action={createInvoice}>

form にアクションを関数を呼び出しています。
実は裏側では自動的に POST エンド API を作成してくれています。

本来 amount は請求書の額を示しているので
typescript の型は number である必要がありますが、
ネットワークを返した操作になってしまい
シリアライズ化できないため文字列化されてしまいます。

zod を用いて coerce オプションを使うことにより
型を number 型に強制させることができます。

import { z } from "zod";

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  //amountの型をnumberにする。
  amount: z.coerce.number(),
  status: z.enum(["pending", "paid"]),
  date: z.string(),
});
const date = new Date().toISOString().split("T")[0];

上記のコードは請求書の日付を表示する関数です。
.toISOString()とすることで日時と時間を
表すことができ、日付の後ろに T という文字が
あるのですが、それは Time の T で
ここから時間ですよと教えてくれます。
.split()で日付と時間を配列化させて
T を[0]すなわち表示させないようにしています。

現状 Invoices ページはサーバーコンポーネントであり
請求書作成してもデーターベースには保存されても
Web には反映されていません。
そこで revalidatePath の登場です。

revalidatePath("/dashboard/invoices");
redirect("/dashboard/invoices");

引数の/dashboard/invoicesのルートのデータは
revalidate(再検証)してくださいねという意味になります。
こうすることにより新しいデータがサーバーからフェッチされます。

最後に redirect を行うことで再検証後指定されたパスに
飛ぶようになっています。

請求書の編集はそれぞれ違う URL が必要であり
いわば動的になります。

export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
}

ページの引数に params というパラメーターを指定する
必要があるので上記のコードのようになります。
id = params.idid
url の id のことを指します。
仮にここが user にした場合
フォルダ名も[user]としなければなりません。

編集を行う際に現在の名前、金額などを
初期値としてデータをフェッチなければなりません。

const [invoice, customers] = await Promise.all([
  fetchInvoiceById(id),
  fetchCustomers(),
]);

Promise.all を使うことでデータを一気に取得しています。
fetchCustomeers()で選択肢一覧を全て取得しています。

更新を行う際の URL をみてみると UUID がとても長いです。
ですがこれは ID の衝突リスクや UUID の形式は読み取られにくい為
攻撃する人からのリスクを少なくすることができます。

const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);

bind 関数とは元の関数に対して引数を自動的に付与した状態で
新しい関数を作ったりすることができます。
すなわち updateInvoiceWithId とは
updateInvoice に対して id は invoice.id ですよと
予め決めた状態にして同じような関数を作成しています。

なぜそのようなことをするかと言うと
form についてる型をみてみればわかります。

  action?:
    | string
    | undefined
    | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS
      [keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS];

型の中身はこうなっています。

string に関しては URL とかを直接入力できたりできるのですが、
3 つ目のDO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS
この項目に関しては触るなよって書いてあります。
form の action 部分にカーソルをフォーカスすると正体があらわれます。


action?: string | ((formData: FormData) => void) | undefined

要するに先程の DO_NOT みたいなのは formData を持つ関数になっています。

export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath("/dashboard/invoices");
}

delete に関しては SQL の操作を行っており
invoicesテーブルからid が指定した id のところを
delete して revalidatePath を行っております。

本来 create だったり updateInvoice を作るってなったら
API レイヤーを自分で作り
その API をエンドポイントで呼び出すみたいな作業が必要でした。
今は関数を定義したらそれを form の action で呼び出すだけで、
API レイヤーをすっ飛ばしてサーバー actions を行うことが
できるようになりました。

第 13 章 エラーの処理

適切なエラーを処理する方法として
try/catchを使用します。

createInvoice,updateInvoice,deleteInvoice 関数内で await sql から

try {
  await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
} catch (error) {
  return {
    message: "Database Error: Failed to Create Invoice.",
  };
}

try/catch で囲いうまくいったら sql を上書きをし、
失敗した場合エラーメッセージを表示するようにしています。

try/catch外で redirect が呼び出されている理由として
sql がうまく行ったとしても
redirect で問題が起きてしまった場合
catch 文に捕まってしまう為 sql が成功した後に
redirect が呼び出されています。

エラーをコンポーネントごとに指定するのではなく
全てのエラーを処理できるerror.tsxを作成します。

"use client";

import { useEffect } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <main className="flex h-full flex-col items-center justify-center">
      <h2 className="text-center">Something went wrong!</h2>
      <button
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
        onClick={() => reset()}
      >
        Try again
      </button>
    </main>
  );
}

error.tsxは Nextjs が指定している
非常に重要なコンポーネントです。
引数として error と reset を持っています。
error.tsxuse clientを指定しなくてはいけません。

error に中に error の情報、メッセージとかが
全て入ります。

reset の中はそのルートをもう一度
復元させようとすることになります。

上記のコードを行うことでエラーが起きた際
レイアウトを残しつつエラーを吐いてくれます。

error.tsx で全部いいじゃんと思いますが
存在しないリソースがあると思います。
入力されてしまった場合、
同じくエラー画面に飛ぶのですが
引数の reset であるTry againボタンを
押しても何も変わらなくなってしまいます。

こういう場合は if 文を用いてnotFound()関数を使用します。

/dashboard/invoices/[id]/edit/page.tsx
export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

  if (!invoice) {
    notFound();
  }

  // ...

今回の場合請求書が無い場合
notFound ページに飛ぶようにしています。

notFound ページも Nextjs の規則に則った
not-found.tsxというのがあるので
そちらを作成します。

notFound は error.tsx より優先されるので
より具体的なエラーに処理したい場合は notFound を使用しましょう。

第14章 アクセシビリティの向上

アクセシビリティとは障害のある人を含む
誰もが使用できる Web アプリケーションを構築する方法です。

Nextjs にはeslint-plugin-jsx-a11yと呼ばれるプラグインがあります

package.json で

 "lint": "next lint"

を追加して npm run lint を行うと
ワーニングとエラーが表示されるようになります。

フォームのアクセシビリティの向上の為以下の3つを行いましょう。

・セマンティック HTML
div 要素などに onClick を付与するのは間違いで
onClick なら input,button,a タグなどで
それぞれの役割で付与していきましょうということです。

・ラベリング
フォームなどでラベル要素使って
この要素と紐づいていますよとわかりやすくしてくれるので
ラベルを使って行きましょう

・フォーカスアウトライン
自分がどこにいるかわかるため
フォーカスした際にアウトラインを使いましょうとのことです。

フォームの検証
input 要素にrequiredを入力すると
その input 要素が入力サれていない場合
送信を押した際に入力してくださいねと表示される。

サーバー側の検証

悪いデータがサーバー側に飛んでくるのを
防ぐ為にサーバー側で検証する必要があります。
防いだ後はクライアントに表示する必要があります。

その為にはまずはuse clientでクライアント化を行い
useFormStateを react-dom からもってきます。

useFormStateactioninitialStateの2つの引数を取り、
state,dispatchの2つの値を返します。

form の action に useFormState の dispatch をいれます。

return <form action={dispatch}>...</form>;

initialState として message は null,errors として空のオブジェクトを渡します。

const initialState = { message: null, errors: {} };

これでサーバーアクションを Zod を使っていい感じにエラーを表示させます。

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
+    invalid_type_error: 'Please select a customer.',
  }),
  amount: z.coerce
    .number()
+    .gt(0, { message: 'Please enter an amount greater than $0.' }),
  status: z.enum(['pending', 'paid'], {
+    invalid_type_error: 'Please select an invoice status.',
  }),
  date: z.string(),
});

customerId に何もなかった場合
Please select a customer.と表示することができます。

amount に関しては.gt と書いてあり
grater than の略でその次に0が指定してあります。
要するに 0 以上であれよということです。

status は customerId と同じく何もなかった場合に
エラー分を指示することができます。

useFormState を使用したら createInvoice の引数が変わるため
action の createInvoice を更新します

formData と新たに prevState として
新しい型の State を更新します。

export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};

export async function createInvoice(prevState: State, formData: FormData) {

prevState とは今回は使用しませんが必須の props です。

parse()からsafeParse()に変更します
safeParse()とは非同期なります。
parse()の場合は失敗したら必ずエラーって感じだったのですが
safeParse()にすることにより、自分でちゃんと成功したかどうかを
ハンドルすることができます。

if (!validatedFields.success) {
  return {
    errors: validatedFields.error.flatten().fieldErrors,
    message: "Missing Fields. Failed to Create Invoice.",
  };
}

safePase()にすることで成功したかどうかの if 文を用いて
分岐処理を行うことができるようになりました。

第15章 認証の追加

認証と認可の違い

認証はユーザー本人であることを確認することです。

認可はユーザーの身元が確認されるとどの部分が許可されるかが
決定されます。いわばセキュリティのお通りくださいみたいな感じです。

export const authConfig = {
  pages: {
    signIn: "/login",
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");
      if (isOnDashboard) {
        if (isLoggedIn) return true;
        return false; // Redirect unauthenticated users to login page
      } else if (isLoggedIn) {
        return Response.redirect(new URL("/dashboard", nextUrl));
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

認証機能で next-auth を使用する際は auth.config.ts が必要になります。
上記のコードで authConfig の callbacks は
認証した後に isLoggedIn で認証しているかどうか
その後いs OnDashboard/dashboard以降の URL にいるかどうか
その URL にいてログインしていれば true
だめな場合は false を返して
ログインができた場合/dashboardに URL を置き換える処理を行っています。
一番最後のsatisfiesは TS がpagescallbacksなどの KEY が
正しく合っているかどうか検証しています。

middleware.tsは Next.js の機能で
中間で動作するアプリケーションです。
例えばユーザーがリクエストを送った時に
アプリケーションが反応する前に
まずどういう処理を行いたいかを挟むことができる。

import NextAuth from "next-auth";
import { authConfig } from "./auth.config";

export default NextAuth(authConfig).auth;

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};

上記では NextAuth ということで
認証しているユーザーかどうかチェックしています。
configの部分でミドルウェアのソフトの設定を
行うことができ、
matcher というのは一致しているかどうかを調べています。
今回でいうと api とは静的ファイルを除いてくださいねと表しています。

第16章 メタデータの追加

ファイルベース: Nextjs ではフォルダーに入れるだけで
meta データや画像などが反映される特殊ファイルがあります・

・favicon.ico、apple-icon.jpg、icon.jpg: ファビコンとアイコンに使用されます
・opengraph-image.jpg および twitter-image.jpg: ソーシャルメディア画像に採用
・robots.txt: 検索エンジンのクロールの手順を説明します。
・sitemap.xml: ウェブサイトの構造に関する情報を提供します

ファイルは増えますけど書くコードが少なくなる為
できるものはファイルベースでやって方がいいです。

meta データの title にテンプレートをもたせることができます。

  title: {
    template: '%s | Acme Dashboard',
    default: 'Acme Dashboard',
  },

% の部分に各ページの title を指定することで
後ろの Acme の部分がじゅんに変更になった際でも
いちいち各ページで変更する必要がなくなります。
便利ですね。

動的なページの meta データの場
generateMetadata関数を使用します。

おわりに

Learn Next.js を通じて知らなかった部分や
こう書けばいいのかみたいなのが
たくさん出てきました。
15 章の認証に関してはまだまだわからない部分が
たくさんあるのでこれからも学んで行きます

Discussion