Next.js + TypeScript マニュアル(初心者向け / 実務サンプル付き)

0. はじめに(この資料の読み方)

この資料は「Next.jsを業務で使う時に困らない」ことが目的です。
まずは Next.jsの役割(Reactに何が足されるのか)→ フォルダ構成ルーティングサーバー/クライアント境界 の順に理解します。
後半に 業務にありがちな画面(一覧/登録/編集/詳細) を、イメージ(ワイヤーフレーム)と Reactコード例つきで載せています。
ここで言う「ステップ」は HTMLの行数 です。読み物としての密度を上げるため、説明→例→注意点の順で繰り返します。

1. Next.jsとは(Reactに何を足したもの?)

結論
Reactは「画面部品」。Next.jsは「Webアプリに必要な土台(ルーティング/ビルド/SSR/データ取得/最適化)」をまとめたものです。

1-1. 何が嬉しい?(業務視点)

2. 事前準備(WindowsでNext.js開発を“事故らず”始める)

ここは薄い説明だと確実に詰まります。
このセクションでは「何を入れるか」「どう入れるか」「作る→動かす→ビルド→配布」を Windows目線でそのまま手順として書きます。

2-1. Windowsで開発するためにインストールが必要なもの

ソフト 必須? 役割(なぜ必要?)
Node.js(LTS) 必須 Next.jsを動かす土台。npm(依存パッケージ管理)と一緒に入る
Git 推奨 ソース管理。GitHub/Vercelへの公開が一気に楽になる(業務ではほぼ必須)
VS Code 必須(推奨) TypeScript/React/Next.jsの開発が最もやりやすい(補完・整形・Lint・デバッグ)
Windows Terminal 任意 コマンドが見やすい(PowerShellでもOK)

2-2. どのようにインストールするか(確認コマンドつき)

2-2-1. Node.js(LTS)をインストール

2-2-1. Node.js(LTS)をインストールする

Node.js は Next.js を動かすために必須です。
LTS(Long Term Support / 安定版) を選びます。
  1. ブラウザで Node.js 公式サイト を開く
  2. 「LTS」と表示されているバージョンを選択する
  3. Windows 用(.msi)インストーラーをダウンロードする
  4. ダウンロードした .msi を実行し、基本は「Next」を押して進める
特別な設定は不要です。途中で表示されるオプションはすべてデフォルトのままで構いません。

2-2-2. インストールできたか確認する

インストールが正しく完了したかは、コマンドで確認します。
node -v
npm -v
バージョン番号が表示されれば成功です。
表示されない場合は、ターミナルの再起動または PC 再起動を行ってください。

2-2-2. Git をインストール(推奨)

「GitHubへpushしてVercelで公開」までやるなら、最初から入れておくのが安全です。
git --version

2-2-3. VS Code をインストール

VS Code 本体 + 拡張機能(次項)で環境が完成します。

2-3. VS Codeで入れるべきExtension(具体名)

Extension何をしてくれる?初心者メリット
ESLint 危険な書き方・間違いを警告(静的解析) 「動かない原因」が早めに分かる
Prettier コード整形(自動で見た目を揃える) 読みやすくなる/レビューもしやすい
EditorConfig インデント/改行の差を揃える 「なぜか差分が大量」みたいな事故が減る
GitLens(任意) 誰がいつどこを変更したか見える 業務で強い(原因調査が速い)
DotENV(任意) .env の色付け 環境変数のミスが減る

2-4. プロジェクトの作成と起動(ここまで一気にやる)

プロジェクトを作成したら、必ず一度起動して動作確認を行います。
「作れたつもり」で次に進まないためです。
# 作業フォルダへ移動(例)
cd C:\work

# Next.js プロジェクト作成(公式推奨)
npm create next-app@latest my-app

# 作成したフォルダへ移動
cd my-app

# 開発サーバーを起動(最初の動作確認)
npm run dev
ブラウザで http://localhost:3000 を開き、Next.js の初期画面が表示されれば成功です。

2-5. デバッグサーバー(開発サーバー)の起動方法

一般に「デバッグサーバー」は Next.js の 開発サーバー(dev server) を指します。
保存すると自動反映(ホットリロード)するので、開発中はこれを使います。
npm run dev
ブラウザで http://localhost:3000 を開きます。

2-6. 本番用ビルド(release build)を作る

開発中は npm run dev で動かしますが、これは「開発専用モード」です。
公開(配布)する前には、必ず 本番用にビルド して、本番モードで起動できるか を確認します。
# 本番用ビルドを作成
npm run build
これで「本番で動かすための成果物」が生成されます。
ただし、ビルドしただけでは起動しません。次の手順で “本番モード” で起動して確認します。

2-7. 本番モードで起動して動作確認する(重要)

ビルド後は、開発サーバーではなく npm run start で起動します。
これが「リリース前の動作確認」の基本手順です。
# 本番モードで起動(build が終わっていることが前提)
npm run start
npm run dev は開発専用モードです。
例えば次のような違いがあります。
  • データ取得のキャッシュ挙動が本番と異なる
  • Server / Client の境界エラーが dev では表面化しない
  • 環境変数は build 時点で固定される
  • エラーページの表示内容が異なる
そのため、リリース前には必ず npm run build → npm run start で確認します。

2-8. リリースビルドを配布する方法(現実的な3パターン)

2-8-1. パターンA:Vercelへ配布(最も簡単)

GitHubにpushしてVercel連携すると、pushのたびに自動ビルド&公開できます。
Next.jsのSSRやAPIなども「そのまま」扱いやすいです。
Vercel は Next.js を開発している会社が提供するホスティングサービスです。
GitHub と連携すると、push のたびに自動で
  • 依存関係のインストール
  • ビルド
  • 公開
が行われます。
「公開(URL発行)」とは、HTMLファイルが置かれるだけではなく、
Next.js アプリが Vercel 上で実行され続ける という意味です。
Vercel では、Next.js の
  • SSR(サーバーでHTMLを生成する処理)
  • API(app/api 配下のサーバー処理)
も含めて、サーバー側のコードが実行可能です。
そのため、ログイン画面・管理画面・業務用ページなどもそのまま動かせます。
また、Vercel には 無料プラン があり、
  • 個人開発
  • 学習用途
  • ポートフォリオ
であれば、ほとんどの場合 無料の範囲内 で利用できます。
なお、Vercel の無料プランでは、SQLite3 のような「サーバー内にファイルとして保存するデータベース」や、 サーバー上のファイルを永続的に保存する使い方はできません。 実行中に SQLite ファイルを作成したり、ファイルを書き込むこと自体は一時的には可能ですが、 サーバーの再起動や再デプロイのたびに内容は消えます。 そのため、SQLite3 を業務データの保存先として使ったり、 アップロードしたファイルをサーバー内に保持し続ける用途には向いていません。 Vercel で永続的にデータを扱う場合は、 外部のデータベースサービス(例:クラウドDB)や 外部ストレージサービスを利用する設計が前提になります。
無料プランでは、同時実行数や実行時間、アクセス量などに上限があります。
ただし、通常の学習用・検証用・小規模業務システムでは問題になることはほとんどありません。
つまり Vercel は、
「GitHub に push するだけで、実行可能な Next.js アプリを無料で公開できる」
ホスティングサービスです。
SSR(Server Side Rendering) は、アクセスがあるたびにサーバー側で HTML を生成する仕組みです。
ログインユーザーごとに内容が変わる画面や、業務システムでは必須になります。
API は、画面や他の処理から呼び出せるサーバー側の処理です。
Next.js では app/api 配下にファイルを置くだけで API を作成できます。
Vercel は SSR と API の両方を、特別な設定なしでそのまま動かせるため、
Next.js の機能を最も素直に利用できるホスティング環境です。

2-8-2. パターンB:Nodeサーバーとして配布(社内サーバー/VM)

配布先(本番サーバー)で npm ci(依存を固定インストール)→ build → start が基本です。
「配布物をzipで渡す」より、Gitで取り込んでビルドする方式が事故が少ないです。
# 本番サーバーで(推奨)
npm ci
npm run build
npm run start

補足:npm ci が必要な理由(初心者が一番混乱する所)

結論:npm ci は「成果物を作るコマンド」ではありません。
同じ依存関係(ライブラリの組み合わせ)を、毎回まったく同じ状態で再現するためのコマンドです。
業務では「開発PCでは動くのに、本番サーバーで動かない」を防ぐために重要です。

1) node_modules とは何?

node_modules は、このアプリを動かすために必要な部品(Next.js / React / その他ライブラリ)が入るフォルダです。
中身は数千〜数万ファイルになることもあり、人が手で管理する前提ではありません。

2) 「node_modules を手で消せばいい」はダメなの?

手で消すこと自体はできます。ただし、消しただけでは終わりません
重要なのは「同じ部品を、同じバージョンで、同じ組み合わせで」入れ直せるかどうかです。
手で消して npm install を実行すると、依存関係のバージョンが微妙にズレることがあります。
その結果、開発者Aでは動くのに、開発者Bや本番サーバーでは動かないという事故が起こります。

3) npm install と npm ci の違い(重要)

項目 npm install npm ci
目的 開発用(柔軟に依存を入れる) 再現用 / 本番用(固定の依存を再現)
基準 package.json を中心に解決 package-lock.json の通りに固定
node_modules の扱い 残ることがある(差分更新) 必ず削除して作り直す
バージョンのズレ 起こり得る 起こりにくい(固定)

4) npm ci を実行すると「何が生成される」の?

npm ci が生成(再生成)するのは、基本的に node_modules フォルダです。
これは「配布物」ではなく、その環境でビルドや実行をするための部品セットです。

5) 「生成物を1個だけ渡せばいい」の?

いいえ。node_modules を1個だけ渡して終わり、という配布は基本しません。
理由:OSやNodeの差で壊れる、サイズが巨大、セキュリティ的にも良くない、など。

6) 本番(リリース先)でも npm ci を実行するの?

はい。本番サーバーでこそ npm ci を使います。
本番サーバーでは「決められた依存関係を、毎回同じ状態にする」ことが重要だからです。
ただし、エンドユーザー(利用者)が npm ci を実行するわけではありません。
実行するのは、運用担当・本番サーバー・またはVercelなどのホスティングサービスです。

7) 業務での基本手順(本番サーバー)

# 本番サーバー側で(例)
git pull
npm ci
npm run build
npm run start
まとめ:
npm ci は「手でnode_modulesを消す」の上位互換で、依存関係を固定して再現するためのコマンドです。
本番では「npm run build → npm run start」だけでなく、その前に npm ci を入れることで事故を減らします。

2-8-3. パターンC:静的サイトとして配布(静的で足りる場合)

すべてのNext.jsアプリが静的配布できるわけではありません(SSRや動的API中心だと不可)。
ただし「静的で足りる要件」なら、CDN/静的ホスティングに置けて配布が簡単です。
ここまでで、Windows上で「作る → devで動かす → build → startで本番確認 → 配布」を一通り回せます。

4. フォルダ構成(迷子にならない置き方)

重要な考え方
Next.js(App Router)では、app/ の中のフォルダ構造が、そのまま URLの構造 になります。

4-1. Next.js プロジェクトフォルダー構成(App Router 前提の代表例)

app/
  layout.tsx        // 共通レイアウト(全ページ共通)
  page.tsx          // トップページ(/)
  users/
    page.tsx        // /users
    [id]/
      page.tsx      // /users/:id
      edit/
        page.tsx    // /users/:id/edit
  api/
    users/
      route.ts      // /api/users (Route Handler)

Next.js の「HTML」はどこに書くのか(ファイルと場所)

Next.js では、HTMLファイル(.html)を直接作成しません。
代わりに、app フォルダの中にある .tsx ファイルに、 HTML相当の内容を書きます。

ページを書く場所(page.tsx)

画面(URLに対応するページ)は、app/○○/page.tsx に作成します。
app/
  page.tsx        // トップページ(/)
  users/
    page.tsx      // /users
page.tsx の中に書いた JSX(HTMLっぽい記法)が、 実際にブラウザへ返される HTML になります。

共通レイアウトを書く場所(layout.tsx)

ヘッダーやフッターなど、全ページ共通の枠は app/layout.tsx に書きます。
app/
  layout.tsx      // 全ページ共通レイアウト
layout.tsx には <html><body> を書くことができ、
{children} の位置に各ページ(page.tsx)の内容が差し込まれます。

では「TSX」とは何か

.tsx は TypeScript の中に、 HTMLのような記法(JSX)を書けるファイル形式です。
見た目はHTMLに似ていますが、実体はプログラムです。

4-2-1. page.tsx は “画面”

page.tsx の中で <div> などを返すと、それが画面として表示されます。
「サーバーでHTMLを組み立てる」のではなく、コンポーネントを組み立てる と思うと理解が早いです。

4-2-2. layout.tsx は “枠”

画面遷移が多い業務では「左メニューは共通、右側だけ切替」が基本です。
Next.jsでは layout.tsx がその枠です(子ページが {children} に入ります)。

5. ルーティング基礎(page / layout / Link)

Next.js(App Router)では、URL設計 = フォルダ構成です。
このセクションでは「URLがどのように決まり、どうやって画面遷移するのか」を、 静的ページ → 遷移 → 動的ルートの順で説明します。

5-1. 静的ページを追加する(URLとフォルダの対応)

App Router では、app フォルダ配下の構成が、そのまま URL になります。
新しいページを追加したい場合は、フォルダと page.tsx を作成します。
app/
  page.tsx        // /
  foo/
    page.tsx      // /foo
  bar/
    page.tsx      // /bar
例えば app/foo/page.tsx を作成すると、
ブラウザで /foo にアクセスしたときにそのページが表示されます。
ここでは ルーティング定義を書くコードは一切ありません
フォルダを作るだけで URL が決まるのが、Next.js の大きな特徴です。

5-2. ページ間を移動する(Link による遷移)

ページから別のページへ移動する場合、Next.js では <a> タグではなく next/link を使います。
import Link from "next/link";

export default function Page() {
  return (
    <div>
      <Link href="/users">ユーザー一覧へ</Link>
    </div>
  );
}
Link を使うと、ページ全体を再読み込みせずに遷移します。
そのため、操作感が速く、業務アプリの画面遷移に向いています。
<a href="..."> を使うと、毎回ページ全体が再読み込みされます。
Next.js の画面遷移では、原則 Link を使ってください。

5-3. 動的ルート(URLの一部を変数として扱う)

一覧 → 詳細 → 編集 のような画面構成では、 URL の一部を 可変(変数) にしたくなります。
Next.js では、そのために [名前] というフォルダ名を使います。
app/
  users/
    page.tsx        // /users
    [id]/
      page.tsx      // /users/123
      edit/
        page.tsx    // /users/123/edit
この構成にすると、次のような URL を受け取れます。
  • /users/1
  • /users/abc
  • /users/999/edit

動的ルートの値を page.tsx で受け取る

export default function UserPage(
  { params }: { params: { id: string } }
) {
  return <div>ユーザーID: {params.id}</div>;
}
URL /users/123 にアクセスした場合、
params.id には "123" が入ります。
この仕組みは、Laravel の /users/{id} や Spring の /users/{id} と同じ役割です。
まとめ:
  • URL は app フォルダ構成で決まる
  • 画面遷移は Link を使う
  • [id] フォルダで URL の変数を受け取る

6. レイアウト/共通UI(layout.tsxの考え方)

業務システムは「画面が増える」ほど、共通部分(ヘッダー/メニュー/パンくず/権限表示/ログイン者表示)が重要になります。
Next.js(App Router)では、この共通部分を layout.tsx に集約できます。

6-1. layout.tsx は何をするファイル?(結論)

layout.tsx は「全ページ共通の枠」です。
各ページ(page.tsx)の内容は {children} の位置に差し込まれます。

6-2. 最小構成:app/layout.tsx(共通枠の基本形)

下は “最小限だけ” の layout 例です。
ポイントは 必ず {children} を置くこと(置かないとページが表示されません)。
// app/layout.tsx
import "./globals.css";

export default function RootLayout(
  { children }: { children: React.ReactNode }
) {
  return (
    <html lang="ja">
      <body>
        {children}
      </body>
    </html>
  );
}

6-3. 業務っぽい共通UI例:ヘッダー+左メニュー+メイン領域

業務システムで多いレイアウトは「上部ヘッダー」「左メニュー」「右側が画面本体」です。
この “枠” を layout に入れると、ページが増えても共通UIを1箇所で管理できます。
// app/layout.tsx(例)
import "./globals.css";
import Link from "next/link";

export default function RootLayout(
  { children }: { children: React.ReactNode }
) {
  return (
    <html lang="ja">
      <body>
        <header style={{ padding: "12px 16px", borderBottom: "1px solid #ddd" }}>
          <b>業務システム</b>
          <span style={{ marginLeft: 12, color: "#666" }}>ログイン者:山田</span>
        </header>

        <div style={{ display: "flex", minHeight: "calc(100vh - 50px)" }}>
          <nav style={{ width: 240, borderRight: "1px solid #ddd", padding: 12 }}>
            <div style={{ fontWeight: 600, marginBottom: 8 }}>メニュー</div>
            <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 8 }}>
              <li><Link href="/users">ユーザー管理</Link></li>
              <li><Link href="/sales">売上管理</Link></li>
              <li><Link href="/stock">在庫管理</Link></li>
              <li><Link href="/shipments">出荷ステータス</Link></li>
            </ul>
          </nav>

          <main style={{ flex: 1, padding: 16 }}>
            {children}
          </main>
        </div>
      </body>
    </html>
  );
}

6-4. 「page.tsx には何を書く?」(役割分担)

layout に共通枠を置いたら、各ページ(page.tsx)には “その画面の中身” だけを書きます。
例:ユーザー一覧画面なら、一覧テーブル・検索条件・ページネーションなど。
// app/users/page.tsx(例:中身だけ)
export default function UsersPage() {
  return (
    <div>
      <h1>ユーザー一覧</h1>
      {/* ここに検索条件や一覧テーブルが入る */}
    </div>
  );
}

6-5. layout.tsx が「業務の画面遷移」と相性が良い理由

業務システムでは画面が増えても、
  • メニューは常に左にある
  • ヘッダー(会社名/ログイン者/ログアウト)は常に上にある
  • 変わるのは “右側の中身” だけ
という構造が多いです。
Next.js の layout.tsx は、この「共通枠+差し替え領域」を標準機能として持っています。

6-6. よくある失敗(初心者がハマる)

  • {children} を置き忘れる → ページが表示されない
  • 共通UIを各 page.tsx にコピペする → 修正が地獄になる
  • メニューの Link を <a> で書く → 画面遷移が遅くなることがある
まとめ:
共通UIは layout.tsx に集約し、各 page.tsx は画面の中身だけにする。
これが「画面遷移の多い業務システム」を破綻させない基本形です。

7. Server / Client 境界(Next.js 特有の最大の難所)

Next.js(App Router)では、画面を構成するコンポーネントが「サーバー側で動くもの」と 「ブラウザ側で動くもの」に分かれています
これを理解しないまま書くと、エラーが頻発します。

7-1. まず結論(初心者はここだけ覚える)

7-1. Server Component になる条件(明確な定義)

Next.js(App Router)では、app フォルダ配下の .tsx ファイルは、 ファイルの先頭に "use client" が書かれていない場合、 自動的に Server Component として扱われます。
つまり、次の条件をすべて満たすと Server Component になります。
  • app/ 配下にある .tsx ファイルである
  • ファイルの先頭に "use client" が書かれていない
app/page.tsx や app/users/page.tsx が Server Component になるのは、 「page.tsx という名前だから」ではありません。 app フォルダ配下の .tsx ファイルで、 ファイル先頭に "use client" が書かれていない場合、 そのファイルは Server Component として扱われます。

7-2. Server Component とは何か

Server Component は、サーバー側で実行され、
「完成した HTML をブラウザに返す」ためのコンポーネントです。
Server Component でできること:
  • データベースアクセス
  • ファイル読み込み
  • API 呼び出し
  • 画面の生成
Server Component では、React(TSX)で画面構造を書き、 それをサーバー側で実行して 完成したHTMLを生成します。
これは「画面を表示するための準備」をサーバーで行うという意味で、 一覧・詳細・初期表示など、 表示時点でユーザー操作を必要としない部分に向いています。
Server Component("use client" を書いていない app 配下の .tsx)では、 ブラウザ上で実行される処理は書けません。
  • useState / useEffect (ブラウザ上の状態管理)
  • onClick などのイベント処理 (ユーザー操作への反応)
  • window, document (ブラウザ専用オブジェクト)

補足:useState / useEffect とは何か(Reactの基礎)

useStateuseEffect は、
React で「画面を動かす」ための仕組みです。
これらは ブラウザ上で実行されることを前提 としています。

useState とは何か

useState は、
画面の中で変化する値(状態)を持つための仕組みです。
例えば、次のような「ボタンを押すたびに数が増える」画面では、
数値をどこかに覚えておく必要があります。
その「覚えておく場所」を作るのが useState です。
"use client";
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}
この例では:
  • count … 現在の数値
  • setCount … 数値を変更するための関数
useState が用意しています。

useEffect とは何か

useEffect は、
画面が表示された後や、状態が変わった後に処理を実行するための仕組みです。
例えば次のような処理です。
  • 画面を開いたときに API を呼ぶ
  • 入力内容が変わったら再計算する
"use client";
import { useEffect } from "react";

export default function Sample() {
  useEffect(() => {
    console.log("画面が表示されました");
  }, []);

  return <div>サンプル</div>;
}

なぜ Server Component では使えないのか

useStateuseEffect は、 「画面が表示された後に、ブラウザで動き続ける処理」です。
Server Component はサーバー側で一度だけ実行され、
HTMLを返した時点で役目が終わるため、これらを使うことができません。
まとめ:
  • useState … 画面の中で変わる値を持つ
  • useEffect … 表示後・変更後に処理をする
  • どちらも ブラウザで動く仕組み
  • そのため Client Component 専用

7-3. Client Component とは何か

Client Component は、ブラウザ上で実行されるコンポーネントです。
ユーザー操作(クリック・入力)に反応する画面は、必ずこちらになります。
Client Component でできること:
  • ボタン操作
  • 入力フォーム
  • 状態管理(useState)
  • 画面の動的更新

7-4. Client Component の最小例

"use client" をファイルの先頭に書くことで、 「このファイルはブラウザで動く」と Next.js に伝えます。
"use client";
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

7-5. Server と Client はどう組み合わせるのか

実際の業務画面では、
  • 外側:Server Component(データ取得・画面構造)
  • 内側:Client Component(操作部分)
という構成になります。
// Server Component(デフォルト)
import Counter from "./Counter";

export default async function Page() {
  // ここでDBやAPIからデータ取得できる
  return (
    <div>
      <h1>ユーザー詳細</h1>
      <Counter /> {/* 操作部分だけ Client */}
    </div>
  );
}

7-6. よくあるエラーと原因

  • useState が使えない
    → ファイルに "use client" が書かれていない
  • window is not defined
    → Server Component でブラウザ専用APIを使っている
  • イベントが反応しない
    → Server Component に onClick を書いている

7-7. 業務システムでの実践ルール

初心者は、次のルールを守ると事故りません。
  • 一覧・詳細画面は Server Component
  • フォーム・ボタン・モーダルは Client Component
  • "use client" は 必要最小限 にする
まとめ:
「動かない画面=Server」「動く画面=Client」
まずはこの感覚を持つことが、Next.js 最大の地雷を避ける近道です。
多くの業務画面には <input><button> が含まれます。
しかし、それらを含む 画面全体 を Client Component にする必要はありません。
Next.js では、
表示・データ取得・判定は Server Component
入力・クリックなど操作部分だけを Client Component
と分けて実装するのが基本です。
React(Next.js)は、画面を「コンポーネント(部品)」の集合として作る仕組みです。
ボタン、入力欄、一覧行、フォームなどは、それぞれ独立したコンポーネントとして分割できます。
Next.js(App Router)では、
その「コンポーネント単位」で
Server Component か Client Component かを決めます。
つまり、
  • ボタンや入力欄など、ユーザー操作を扱う小さな部品は Client Component
  • それらの部品を組み合わせて作られる、画面全体(ページ)は Server Component
という分け方ができます。
Server Component は、
Client Component を 「部品として読み込んで配置する側」 になれます。
そのため、画面全体を Client にしなくても、必要な操作だけを Client にできます。
例として、ユーザー一覧画面では:
  • 一覧データの取得・表示 → Server Component
  • 検索入力欄・登録ボタン → Client Component
という役割分担になります。
まとめると、
「画面 = Server Component」
「操作する部品 = Client Component」
という考え方で実装すると、Next.js の設計と自然に噛み合います。

8. データ取得とキャッシュ(fetch の挙動を理解する)

前章では、Server Component が「サーバー側でデータを取得し、画面(HTML)を生成する」 という仕組みを説明しました。
この章では、その データ取得に使う fetch の挙動 について説明します。

8-1. Next.js の fetch は「ただの fetch」ではない

ブラウザや Node.js の通常の fetch は、 呼び出すたびに毎回データを取得します。
しかし、Next.js(Server Component)で使われる fetch には、 取得結果をキャッシュする仕組み が最初から組み込まれています。

8-2. 初心者が混乱しやすい現象

次のような現象が起きることがあります。
  • データを更新したのに、画面の表示が変わらない
  • APIは呼ばれているのに、古い内容が表示される
  • 再読み込みすると直ることがある
これはバグではなく、fetch のキャッシュが効いていることが原因です。

8-3. なぜ Next.js はキャッシュするのか

Next.js は、次のことを自動で最適化しようとします。
  • 同じデータを何度も取りに行かない
  • サーバー負荷を下げる
  • 画面表示を速くする
そのため、「同じ fetch なら前の結果を再利用する」という判断を行います。

8-4. 業務システムで重要になる理由

業務システムでは、
  • 登録・更新後は必ず最新データを表示したい
  • 「反映されない」は致命的な不具合に見える
という要件が多くあります。
そのため、Next.js では 「この fetch はキャッシュしていいか?」明示的に指定する 必要があります。
次の節では、
  • キャッシュする fetch
  • 毎回最新を取りに行く fetch
  • 一定時間で再取得する fetch
の書き分け方を具体的に説明します。

9. Loading / Error / NotFound(ユーザーに優しいUI)

業務システムでは、
「待たされているのか」「失敗したのか」「存在しないのか」
ユーザーに正しく伝えることが重要です。
Next.js(App Router)では、そのための仕組みが最初から用意されています。

9-1. loading.tsx(データ取得中の表示)

loading.tsx は、
Server Component でのデータ取得が完了するまでの間に表示される画面です。
app/
  users/
    loading.tsx
    page.tsx
上記の構成では、/users にアクセスしたとき、 page.tsx の処理(DB取得など)が終わるまで loading.tsx が自動的に表示されます。
// app/users/loading.tsx
export default function Loading() {
  return <div>読み込み中です...</div>;
}
業務画面では、
  • 一覧取得に時間がかかる
  • ネットワークが遅い
ことがあるため、「固まった」と誤解されないようにする目的で重要です。

9-2. error.tsx(例外発生時の画面)

error.tsx は、
Server Component の処理中に例外(エラー)が発生した場合に表示される画面です。
app/
  users/
    error.tsx
    page.tsx

9-x. error.tsx(例外発生時の画面)を「登録ボタン」で具体的に理解する

ここでは、ユーザーが「登録」ボタンを押して、サーバー側の処理が動き、
その途中で想定外の例外が起きたときに error.tsx が表示される流れを、 コード(Client / Server)とフォルダ構成で具体的に説明します。

1) まず配置場所(どこに置くか)

error.tsx は、エラーを捕まえたい範囲(フォルダ)に置きます。
例:/users 配下で起きるエラーをまとめて扱いたいなら、app/users/error.tsx に置きます。
app/
  users/
    error.tsx        // /users 配下のエラーを受ける(例:/users/new も含む)
    new/
      page.tsx       // 登録画面(Server)
      UserForm.tsx   // 入力+登録ボタン(Client)
      actions.ts     // 登録処理(Server)

2) なぜ error.tsx は Client Component なのか(ボタンのためではない)

error.tsx が Client Component 必須なのは「ボタンを置きたいから」ではありません。
理由は、サーバー側の画面生成が途中で失敗したあとに、ブラウザ側で代替画面を表示する役目error.tsx が担うからです。

サーバー側(Server Component)は、処理が成功したときにだけ HTML を最後まで生成できます。
しかし例外が発生すると、そのページの HTML は完成しないため、サーバーは「正常な画面」を返せません。

そこで、クライアント側の Next.js(ブラウザ上で動いているJavaScript)
「この画面は生成に失敗した」と判断し、代わりに error.tsx を表示します。

つまり、error.tsxブラウザ上で描画される必要があるため、
ファイル先頭に "use client" を書いて Client Component にする必要があります。

3) 登録ボタンを押したときの流れ(全体像)

登録処理は次の 3 つの層に分かれます。
  • 画面全体page.tsx(Server)…枠と表示を作る
  • 操作部品UserForm.tsx(Client)…入力とボタン、クリック処理
  • 登録処理actions.ts(Server)…DB更新・業務ルール・例外
重要:業務では「エラー」を2種類に分けます。
  • 業務エラー(入力ミス・重複など)→ 画面内にメッセージ表示(error.tsx にしない)
  • 想定外エラー(DB障害・例外)→ error.tsx(代替画面)

4) 画面側コード(Server):登録画面の枠

page.tsx は「画面全体(枠)」です。操作する部品は Client に任せます。
// app/users/new/page.tsx (Server Component)
import UserForm from "./UserForm";

export default function NewUserPage() {
  return (
    <div>
      <h1>ユーザー登録</h1>
      <p>名前とメールを入力して登録します。</p>
      <UserForm />  {/* 操作部品だけ Client */}
    </div>
  );
}

5) 画面側コード(Client):入力と「登録」ボタン

UserForm.tsx はブラウザ上で動きます。
入力値を持ったり、ボタンのクリックに反応するため、"use client" が必要です。
// app/users/new/UserForm.tsx (Client Component)
"use client";

import { useState } from "react";
import { createUserAction } from "./actions";

export default function UserForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState(null);

  async function onSubmit() {
    setMessage(null);

    // ここでサーバー側処理(Server Action)を呼ぶ
    const result = await createUserAction({ name, email });

    // 業務エラーは「画面内に出す」
    if (!result.ok) {
      setMessage(result.message);
      return;
    }

    setMessage("登録しました!");
    setName("");
    setEmail("");
  }

  return (
    <div style={{ display: "grid", gap: 8, maxWidth: 420 }}>
      <label>名前
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </label>

      <label>メール
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
      </label>

      <button type="button" onClick={onSubmit}>登録</button>

      {message && <div>{message}</div>}
    </div>
  );
}

6) サーバー側コード(Server):登録処理(業務エラーと想定外エラーの分け方)

actions.ts はサーバー側で実行されます(Server Action)。
業務エラーは 戻り値で返す、想定外エラーは 例外(throw) にします。
// app/users/new/actions.ts (Server Action)
"use server";

type Input = { name: string; email: string };
type Result = { ok: true } | { ok: false; message: string };

const fakeDb = {
  users: new Map<string, { name: string; email: string }>(),
};

export async function createUserAction(input: Input): Promise<Result> {
  // 1) 業務エラー(入力チェック)は戻り値で返す(error.tsxにしない)
  if (!input.name.trim()) return { ok: false, message: "名前は必須です" };
  if (!input.email.trim()) return { ok: false, message: "メールは必須です" };
  if (!input.email.includes("@")) return { ok: false, message: "メール形式が不正です" };

  // 2) 業務ルール(重複禁止)
  if (fakeDb.users.has(input.email)) {
    return { ok: false, message: "そのメールは既に登録されています" };
  }

  // 3) 想定外エラー例(DB障害などを想定してthrow)
  if (input.email.endsWith("@fail.example")) {
    throw new Error("DB接続エラー(想定外)");
  }

  // 4) 登録(本来はDBにINSERT)
  fakeDb.users.set(input.email, { name: input.name, email: input.email });

  return { ok: true };
}

7) error.tsx(Client必須)の業務向け例

次は /users 配下のエラーを受ける app/users/error.tsx の例です。
これは「業務エラー」ではなく「想定外エラー(例外)」用の画面です。
// app/users/error.tsx (Client Component)
"use client";

import { useEffect } from "react";

export default function UsersError(
  { error, reset }: { error: Error; reset: () => void }
) {
  useEffect(() => {
    // 実運用では Sentry 等に送る
    console.error("[users] error:", error);
  }, [error]);

  return (
    <div>
      <h2>ユーザー機能でエラーが発生しました</h2>
      <p>サーバー処理が失敗しました。再試行してください。</p>

      <button onClick={() => reset()}>再試行</button>

      <details>
        <summary>詳細(開発用)</summary>
        <pre>{error.message}</pre>
      </details>
    </div>
  );
}

8) 「登録」を押して error.tsx が出るまで(実際の流れ)

ここが重要です。error.tsx はサーバーが返しているのではありません
失敗の流れは次の通りです。
  1. ユーザーがブラウザで「登録」ボタンを押す(UserForm.tsx が動く)
  2. UserForm.tsxcreateUserAction() を呼ぶ(サーバー側処理を依頼)
  3. サーバー側(actions.ts)で処理を実行中に throw が発生する(想定外エラー)
  4. サーバーは「正常な結果」を返せず、エラー状態(500相当)になる
  5. クライアント側の Next.js が「このルートの描画に失敗した」と判断する
  6. app/users/error.tsxブラウザ上で描画される
まとめ:
業務エラー(入力ミス等)は「画面内に表示」し、
想定外エラー(例外・障害)は error.tsx で「代替画面」を出します。
そして error.tsx は「失敗後にブラウザで表示される」ため Client Component 必須です。

// app/users/error.tsx
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";

export default function Error(
  { error, reset }: { error: Error; reset: () => void }
) {
  const router = useRouter();

  useEffect(() => {
    // 実運用ではここでログ送信(Sentry等)
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>ユーザー情報を表示できませんでした</h2>

      <p>
        データの取得中に問題が発生しました。<br />
        一時的な通信エラーの可能性があります。
      </p>

      <div style={{ display: "flex", gap: 8 }}>
        <button onClick={() => reset()}>
          再試行
        </button>
        <button onClick={() => router.push("/users")}>
          一覧へ戻る
        </button>
      </div>
    </div>
  );
}

業務システムでは、
  • DB接続エラー
  • 権限エラー
  • 想定外のデータ
が必ず発生します。
真っ白な画面にしないために error.tsx は必須です。

9-3. not-found.tsx(存在しないデータの扱い)

not-found.tsx は、画面から直接 import して呼び出すものではありません。
代わりに、notFound() を呼ぶことで「この処理は 404 扱いにする」という合図を Next.js に送ります。
notFound() が呼ばれると、Next.js は同じフォルダ(セグメント)に not-found.tsx があるか探し、あればそれを表示します。
無ければ親フォルダへ遡って探します。
not-found.tsx は、
指定された URL や ID が存在しない場合に表示される画面です。
app/
  users/
    [id]/
      not-found.tsx
      page.tsx
例えば /users/9999 のように、 存在しないユーザーIDが指定された場合に使います。
// app/users/[id]/page.tsx
import { notFound } from "next/navigation";

export default async function UserPage(
  { params }: { params: { id: string } }
) {
  const user = await getUser(params.id);

  if (!user) {
    notFound();
  }

  return <div>{user.name}</div>;
}
// app/users/[id]/not-found.tsx
export default function NotFound() {
  return <div>指定されたユーザーは存在しません。</div>;
}
これは HTTP の 404 に相当しますが、
業務用に分かりやすいメッセージを出せるのが特徴です。
まとめ:
  • loading.tsx:待ち時間を可視化する
  • error.tsx:例外時に真っ白にしない
  • not-found.tsx:存在しないデータを正しく伝える
これらは 「業務で必須のユーザー配慮」です。

補足:Next.js(App Router)の標準関数一覧(画面制御系)

Next.js(App Router)では、
画面の状態(404 / リダイレクト / 再描画など)を制御するための標準関数 が用意されています。
これらは自分で実装するものではなく、Next.js が解釈します。

1. notFound()

404(存在しない)として扱うための関数です。
対応する not-found.tsx が自動的に表示されます。
import { notFound } from "next/navigation";

if (!data) {
  notFound();
}
  • 想定内の「存在しない」状態
  • 業務的には「該当データなし」

2. redirect()

別のURLへ強制的に遷移させる関数です。
import { redirect } from "next/navigation";

redirect("/login");
  • 未ログイン時にログイン画面へ
  • 登録完了後に一覧へ戻す

3. permanentRedirect()

恒久的リダイレクト(301)を行います。
SEOやURL変更時に使用します。
import { permanentRedirect } from "next/navigation";

permanentRedirect("/new-path");

4. useRouter()

Client Component から 画面遷移を制御するためのフックです。
"use client";
import { useRouter } from "next/navigation";

const router = useRouter();
router.push("/users");
  • ボタンクリックで遷移
  • 入力完了後の画面遷移

5. usePathname()

現在のURLパスを取得します。
import { usePathname } from "next/navigation";

const pathname = usePathname();

6. useSearchParams()

URLクエリパラメータを取得します(読み取り専用)。
import { useSearchParams } from "next/navigation";

const params = useSearchParams();
const page = params.get("page");
  • ページネーション
  • 検索条件

7. useParams()

動的ルートのパラメータを取得します。
import { useParams } from "next/navigation";

const { id } = useParams();

8. headers()

リクエストヘッダを取得します(Server Component 用)。
import { headers } from "next/headers";

const headersList = headers();
const userAgent = headersList.get("user-agent");

9. cookies()

Cookie の取得・設定を行います(Server Component 用)。
import { cookies } from "next/headers";

const cookieStore = cookies();
const token = cookieStore.get("token");
まとめ:
  • notFound():存在しない(404)
  • redirect():一時的な遷移
  • permanentRedirect():恒久的遷移
  • useRouter():Client側の画面遷移
  • useParams():URLパラメータ
  • useSearchParams():クエリ文字列
  • headers() / cookies():リクエスト情報

10. Server Actions(フォーム送信を素直にする)

Server Actions は、 フォーム送信(POST)を直接サーバー側の関数に接続できる Next.js の仕組みです。
これにより、
  • API Route を別途作らなくてよい
  • fetch / JSON / status code を意識しなくてよい
  • 業務フォームの流れがシンプルになる
という利点があります。

10-1. 従来のやり方(Server Actions なし)

これまでの Next.js / React では、 フォーム送信のたびに API を経由する必要がありました。
// クライアント側
await fetch("/api/users", {
  method: "POST",
  body: JSON.stringify(form),
});
// サーバー側(API)
export async function POST(req) {
  const body = await req.json();
  // 登録処理
}
業務画面では、
  • API が増えすぎる
  • エラー処理が分散する
  • フォームが複雑になる
という問題が起きやすくなります。

10-2. Server Actions を使ったやり方

Server Actions を使うと、 フォームの action 属性に「サーバー関数」を直接指定できます。
// app/users/actions.ts
"use server";

export async function createUser(formData: FormData) {
  const name = formData.get("name");
  // DB登録などのサーバー処理
}
// app/users/page.tsx
import { createUser } from "./actions";

export default function Page() {
  return (
    <form action={createUser}>
      <input name="name" />
      <button>登録</button>
    </form>
  );
}
特徴:
  • API Route が不要
  • fetch を書かない
  • HTML の form と同じ感覚

10-3. なぜ「フォームを素直にする」のか

Server Actions は、 HTML本来のフォーム送信モデルに近い形で React / Next.js を使えるようにする仕組みです。
そのため、
  • 登録画面
  • 更新画面
  • 検索フォーム
といった 業務システム定番の画面と非常に相性が良いです。

10-4. 何でも Server Actions にすべきか?

すべてを Server Actions にする必要はありません。
  • 単純な登録・更新 → Server Actions 向き
  • 複雑な API / 外部連携 → API Route 向き
  • SPA的な即時反応 → Client + fetch
Server Actions は 「API を置き換える万能機能」ではなく、 業務フォームを簡潔に書くための選択肢です。
Server Actions の return は、 画面(HTML)を返すためのものではありません。
return の使い道は次の通りです:
  • 何も返さない → 処理だけ行う
  • オブジェクトを返す → 業務エラーや結果を画面に渡す
  • redirect() → 別画面へ遷移
  • notFound() → 404画面へ
  • throw Error → error.tsx を表示

10-x. Server Actions の return は「画面(HTML)を返す」のではない

Server Actions の return が省略されていると分かりにくい理由は、
Server Actions の return は「画面(HTML)を返す return」ではないからです。
ここでは return のパターンを、業務で使う形に寄せて説明します。
重要:Server Actions の return は主に次の 5 パターンです。
  • (A) 何も返さない(処理だけする)
  • (B) 結果オブジェクトを返す(成功/業務エラー)
  • (C) redirect() で遷移する
  • (D) notFound() で 404 にする
  • (E) throw Error で想定外エラー(error.tsx)

(A) 何も返さない(処理だけする)

これは「登録処理はするが、画面遷移もメッセージも出さない」形です。
学習には最小ですが、業務では通常 redirect() や結果表示を入れます。
// app/users/actions.ts
"use server";

export async function createUser(formData: FormData) {
  const name = String(formData.get("name") ?? "");
  // DB登録などのサーバー処理
  // return を書かない = 何も返さない(void)
}

(A) 何も返さない Server Action が必要になる具体状況

(A)「何も返さない」は、ユーザーに成功/失敗を表示する必要がない処理で使います。
逆に、登録・更新のように ユーザーが結果を知る必要がある処理では不向きです。

状況1:入力中の「自動保存(下書き保存)」

例:見積書作成・日報・申請書などで、ユーザーが入力している途中に
定期的にサーバーへ保存したいケースです。
この場合、毎回「保存しました」と出すと邪魔なので、画面表示は変えずに裏で保存します。
目的:ユーザーの入力を失わせない(ブラウザクラッシュ対策)

状況2:アクセスログ/操作ログを記録する(監査ログ)

業務では「誰が」「いつ」「何を見た/押した」を記録することがあります(監査)。
例:顧客情報閲覧、出荷ステータス変更、在庫調整など。
ログを残すこと自体が目的なので、ユーザーへの表示は不要です。
目的:監査・追跡・不正防止

状況3:メール送信/通知キュー登録(非同期処理)

例:登録完了メール、Slack通知、バッチ処理依頼など。
ユーザーの画面としては「登録完了」として進みたいが、
通知は裏で処理してよい、というケースがあります。
注意:この場合でも「登録本体」は (B) や redirect() にし、
通知だけを (A) にするのが典型です。

状況4:ボタン1つで終わる設定変更(成功表示が不要な微小操作)

例:表示順の保存、列幅の保存、フィルタ条件の保存など。
ユーザーは画面を見れば「反映されたか」が分かるため、
成功メッセージを出さなくても困らないことがあります。

状況5:運用者向けの「裏コマンド」や「整合性修復」

例:キャッシュ削除、再計算、ステータス再同期など。
操作結果を画面表示ではなく、ログで確認する運用をすることがあります。

まとめ:
(A) が向いているのは「ユーザーに結果を見せない/見せなくてよい処理」です。
  • 自動保存
  • 監査ログ
  • 通知・キュー登録(補助)
  • 微小な設定保存
  • 運用向け処理
逆に、登録・更新・削除のようにユーザーが結果を必要とする処理は、
(A) ではなく (B)(結果表示)や redirect()(画面遷移)を使うのが基本です。

(B) 結果オブジェクトを return する(業務エラーを画面内表示)

業務システムでは、入力ミスや業務ルール違反(重複、上限など)は
error.tsx に飛ばさず、同じ画面にメッセージとして表示するのが一般的です。
そのために Server Action は「結果」を return します。
// app/users/actions.ts
"use server";

export type CreateUserResult =
  | { ok: true }
  | { ok: false; message: string };

export async function createUser(formData: FormData): Promise<CreateUserResult> {
  const name = String(formData.get("name") ?? "");
  const email = String(formData.get("email") ?? "");

  // バリデーション(業務エラー)は return で返す(throwしない)
  if (!name.trim()) return { ok: false, message: "名前は必須です" };
  if (!email.trim()) return { ok: false, message: "メールは必須です" };
  if (!email.includes("@")) return { ok: false, message: "メール形式が不正です" };

  // ここでDB登録(省略)
  // await insertUser({name, email});

  return { ok: true };
}
受け取る側(Client)は、return を見て画面内に表示します。
// app/users/UserForm.tsx
"use client";

import { useState } from "react";
import { createUser, type CreateUserResult } from "./actions";

export default function UserForm() {
  const [message, setMessage] = useState<string | null>(null);

  async function action(formData: FormData) {
    setMessage(null);
    const result: CreateUserResult = await createUser(formData);

    if (!result.ok) {
      setMessage(result.message);   // 業務エラーは画面内
      return;
    }

    setMessage("登録しました!");
  }

  return (
    <form action={action}>
      <div>名前:<input name="name" /></div>
      <div>メール:<input name="email" /></div>
      <button>登録</button>
      {message && <p>{message}</p>}
    </form>
  );
}

(C) redirect() で遷移する(登録後に一覧へ戻す)

業務でよくある「登録完了 → 一覧へ戻る」を素直に書けます。
この場合 return は書かず、最後に redirect() を呼びます。
// app/users/actions.ts
"use server";

import { redirect } from "next/navigation";

export async function createUserAndGoList(formData: FormData) {
  const name = String(formData.get("name") ?? "");
  const email = String(formData.get("email") ?? "");

  if (!name.trim()) {
    // ここでは例として単純化(実務は(B)のように返す)
    redirect("/users/new?error=NAME_REQUIRED");
  }

  // DB登録(省略)
  // await insertUser({name, email});

  redirect("/users"); // ここで一覧へ
}

(D) notFound()(存在しない=404相当)

これは「想定内の存在しない」状態を、Next.js に 404 として扱わせる合図です。
対応する not-found.tsx が自動で表示されます。
// app/users/actions.ts
"use server";

import { notFound } from "next/navigation";

export async function getUserOr404(id: string) {
  // const user = await selectUserById(id);
  const user = null; // 例:見つからない

  if (!user) {
    notFound(); // not-found.tsx を探して表示(明示的にimportして呼ばない)
  }

  return user;
}

(E) throw Error(想定外エラー:error.tsx)

DB障害やバグなど「想定外」は throw します。
これが起きると、Next.js の仕組みにより error.tsx が表示されます。
// app/users/actions.ts
"use server";

export async function createUserMayFail(formData: FormData) {
  const email = String(formData.get("email") ?? "");

  // 例:DB接続障害を想定した throw
  if (email.endsWith("@fail.example")) {
    throw new Error("DB接続エラー(想定外)");
  }

  // DB登録(省略)
}
まとめ:
Server Actions の return は「画面(HTML)を返す」のではなく、
成功/業務エラーの結果を返す、あるいは redirect/notFound/throw で 次の挙動を決めるためのものです。

Server Actions の return パターン詳細説明

(A) 何も返さない(処理だけする)

このパターンでは、Server Action は サーバー側の処理だけを行い、画面には何も返しません
フォーム送信後に起きることは:
  • ① フォームが送信される
  • ② サーバー側で処理が実行される
  • ③ 画面は 送信前と同じ状態のまま
これは HTML のフォームで action 先が何も返さない場合と同じ挙動です。
// app/users/actions.ts
"use server";

export async function createUser(formData: FormData) {
  const name = String(formData.get("name") ?? "");
  // DB登録などの処理だけ行う
  // return は書かない
}
この方式では:
  • 成功したのか失敗したのか分からない
  • メッセージも表示されない
  • 画面遷移もしない
そのため業務システムでは:
  • バッチ処理
  • ログ記録
  • 副作用だけが目的の処理
など、画面フィードバックが不要な場合に限定して使います。

(B) 結果オブジェクトを返す(成功 / 業務エラー)

このパターンでは、Server Action が 処理結果をオブジェクトとして returnします。
return された値は、 クライアント側のフォーム処理関数が受け取り、 画面内の表示に利用します。
// app/users/actions.ts
"use server";

export type CreateUserResult =
  | { ok: true }
  | { ok: false; message: string };

export async function createUser(
  formData: FormData
): Promise<CreateUserResult> {

  const name = String(formData.get("name") ?? "");

  if (!name.trim()) {
    // 業務エラーは throw しない
    return { ok: false, message: "名前は必須です" };
  }

  // DB登録処理(省略)
  return { ok: true };
}
受け取る側(Client Component)は、 return 値を見て画面を制御します。
// app/users/UserForm.tsx
"use client";

import { useState } from "react";
import { createUser } from "./actions";

export default function UserForm() {
  const [message, setMessage] = useState<string | null>(null);

  async function action(formData: FormData) {
    const result = await createUser(formData);

    if (!result.ok) {
      // 業務エラーは同じ画面で表示
      setMessage(result.message);
      return;
    }

    setMessage("登録しました");
  }

  return (
    <form action={action}>
      <input name="name" />
      <button>登録</button>
      {message && <p>{message}</p>}
    </form>
  );
}
この方式のポイント:
  • 業務エラーで画面遷移しない
  • 入力内容が消えない
  • ユーザーに優しい
注意:
業務エラーで throw Error を使うと error.tsx に飛んでしまいます。
これは 障害扱い になるため、通常は誤りです。
まとめ:
  • (A) は「処理だけ・画面無反応」
  • (B) は「結果を返して画面内で制御」
業務フォームでは、ほぼ必ず (B) を使います。

11. Route Handlers(外部公開API・Webhook)

Route Handlers は、画面ではなく「API(サーバー処理)」を作るための仕組みです。
ブラウザに表示されるページではなく、JSON を返す/データを保存する/外部から呼ばれる用途に使います。

11-1. Route Handlers とは何か(結論)

app/api/**/route.ts にファイルを置くと、
自動的に API の URL が作られます
ルーティング定義を書く必要はありません。

11-2. フォルダ構成と URL の対応

フォルダ構成が、そのまま API の URL になります。
app/
  api/
    users/
      route.ts

11-3. page.tsx との違い(重要)

項目 page.tsx route.ts
役割 画面(HTML) API(サーバー処理)
ブラウザ表示 される されない
返すもの HTML JSON / ステータス
用途 一覧・詳細・入力画面 取得・登録・更新・Webhook

11-4. 最小の Route Handler 例(GET)

ユーザー一覧を JSON で返す、最小構成の API 例です。
// app/api/users/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json([
    { id: 1, name: "山田" },
    { id: 2, name: "佐藤" }
  ]);
}
ブラウザや Postman で /api/users にアクセスすると、
JSON データが返ります。

11-5. データを受け取る(POST / Webhook)

フォーム送信や外部サービスからの Webhook は、
POST メソッドで受け取ります。
// app/api/users/route.ts
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.json();

  // body を使って保存・処理する
  console.log(body);

  return NextResponse.json({ ok: true });
}
Route Handler は サーバー側でのみ実行されます。
windowdocument など、ブラウザ専用 API は使えません。

11-6. 業務システムでの典型的な使い方

  • 一覧画面 → GET /api/xxx
  • 登録フォーム → POST /api/xxx
  • 更新処理 → PUT /api/xxx
  • 外部サービス通知 → Webhook(POST)

11-7. route.ts の名前は固定なのか?

はい、ファイル名は必ず route.ts(または route.js)で固定です。
Next.js(App Router)では、
「このフォルダは API 用だ」と認識させる合図として
route.ts という名前が使われます。
users.tsapi.ts など、
別の名前では Route Handler として認識されません。
まとめると:
  • フォルダ名 → URL を決める
  • route.ts → 「API ですよ」という宣言

11-8. なぜ GET / POST しか書いていないのか?

先ほどの例で GETPOST しか出てこなかったのは、
「例として最小限を見せているだけ」です。
実際には HTTP メソッドはすべて使えます。

使えるメソッド一覧

// app/api/users/route.ts
export async function GET() {}
export async function POST() {}
export async function PUT() {}
export async function DELETE() {}
export async function PATCH() {}
呼ばれた HTTP メソッドに対応する関数が、
自動的に実行されます。

11-9. なぜ「関数名=HTTPメソッド」なのか

Next.js の Route Handler は、
「HTTP メソッドごとに処理を分ける」設計になっています。
HTTPメソッド 意味(業務的) 典型用途
GET 取得 一覧・詳細取得
POST 新規作成 登録・Webhook受信
PUT 全体更新 編集保存
PATCH 部分更新 ステータス変更
DELETE 削除 削除処理

11-10. では「/api/users/add」などは作れないのか?

作れます。
URL を分けたい場合は、フォルダを分けます。
app/
  api/
    users/
      route.ts        // /api/users
      add/
        route.ts      // /api/users/add
      [id]/
        route.ts      // /api/users/123
この場合:
  • POST /api/users → 一覧に追加
  • POST /api/users/add → 明示的な追加API
  • DELETE /api/users/123 → ID指定削除

11-11. 業務システム的な理解(超重要)

Route Handler は、
  • URL → フォルダ構成
  • 処理分岐 → HTTPメソッド
で整理します。
これは Spring MVC や ASP.NET MVC でいう:
  • @GetMapping
  • @PostMapping
  • [HttpGet]
を、ファイル構成と関数名で表現しているだけです。
まとめ:
  • route.ts は名前固定
  • GET / POST しか「できない」わけではない
  • HTTPメソッド分の関数を書けば全部使える

11-X. route.ts で GET / POST / PUT / DELETE をすべて扱う実務例

この例は「ユーザー管理 API」を想定しています。
一覧取得・新規登録・更新・削除を、
同じ /api/users URL で HTTP メソッドにより分岐します。

想定するリクエスト

メソッド URL 用途
GET /api/users ユーザー一覧取得
POST /api/users ユーザー新規登録
PUT /api/users ユーザー情報更新
DELETE /api/users?id=123 ユーザー削除

app/api/users/route.ts

// app/api/users/route.ts
import { NextResponse } from "next/server";

/**
 * GET /api/users
 * ユーザー一覧取得
 */
export async function GET() {
  // 本来は DB 取得(例:SELECT * FROM users)
  const users = [
    { id: 1, name: "山田", email: "yamada@example.com" },
    { id: 2, name: "佐藤", email: "sato@example.com" }
  ];

  return NextResponse.json(users, { status: 200 });
}

/**
 * POST /api/users
 * ユーザー新規登録
 */
export async function POST(req: Request) {
  const body = await req.json();

  if (!body.name || !body.email) {
    return NextResponse.json(
      { message: "name と email は必須です" },
      { status: 400 }
    );
  }

  // DB 登録処理(例:INSERT)
  const newUser = {
    id: 123, // 本来は DB が採番
    name: body.name,
    email: body.email
  };

  return NextResponse.json(newUser, { status: 201 });
}

/**
 * PUT /api/users
 * ユーザー更新
 */
export async function PUT(req: Request) {
  const body = await req.json();

  if (!body.id) {
    return NextResponse.json(
      { message: "id は必須です" },
      { status: 400 }
    );
  }

  // DB 更新処理(例:UPDATE users SET ... WHERE id = ?)
  const updatedUser = {
    id: body.id,
    name: body.name,
    email: body.email
  };

  return NextResponse.json(updatedUser, { status: 200 });
}

/**
 * DELETE /api/users?id=123
 * ユーザー削除
 */
export async function DELETE(req: Request) {
  const { searchParams } = new URL(req.url);
  const id = searchParams.get("id");

  if (!id) {
    return NextResponse.json(
      { message: "id が指定されていません" },
      { status: 400 }
    );
  }

  // DB 削除処理(例:DELETE FROM users WHERE id = ?)
  return NextResponse.json(
    { message: `ユーザー ${id} を削除しました` },
    { status: 200 }
  );
}
この route.ts は、
  • URL:/api/users
  • 分岐:HTTP メソッド(GET / POST / PUT / DELETE)
という構造で、業務 CRUD API の基本形になっています。

設計上のポイント(重要)

  • URL は増やさず、意味は HTTP メソッドで分ける
  • 入力チェックは API 側で必ず行う
  • ステータスコードを正しく返す
  • 「何をした API か」がコメントだけで分かる
本番では:
  • DB 処理を try/catch で囲む
  • 認証・認可チェックを追加
  • ログ出力を行う
などを必ず追加してください。
まとめ:
route.ts 1ファイルで、実務 CRUD API は十分に書ける
URL はフォルダ、処理は HTTP メソッド

12. Middleware / Edge(リクエストの入口で判定する)

Middleware は、画面や API に入る「前」に実行される処理です。
ページや route.ts が動く前に、
通していいか/書き換えるか/別の場所へ送るかを判断します。

12-1. Middleware は何をするための仕組みか

Middleware の役割は、
「このリクエストを、先に進ませてよいか?」を入口で決めることです。

12-2. 典型的な用途(業務でよくある)

  • ログインしていないユーザーを /login にリダイレクト
  • 管理者以外を /admin に入れない
  • API への不正アクセスを事前に遮断
  • リクエストに共通ヘッダーを付与

12-3. Middleware が動く「位置」(重要)

Middleware は、
  • page.tsx より前
  • route.ts より前
に実行されます。
つまり、画面も API も「入口」でまとめて制御できます。

12-4. 最小構成の Middleware 例

プロジェクト直下に middleware.ts を作成します。
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const isLoggedIn = false; // 本来は Cookie や Token を確認

  if (!isLoggedIn && request.nextUrl.pathname.startsWith("/admin")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return NextResponse.next();
}
この例では:
  • /admin にアクセス
  • ログインしていなければ
  • /login に強制移動
という処理を、画面が表示される前に行っています。

12-5. Edge とは何か(混同しやすい)

Edge とは、
Middleware が「どこで実行されるか」を表す言葉です。
Middleware は、
  • 通常の Node.js サーバー
  • または Edge(CDNに近い場所)
で実行されます。
Edge では:
  • DB 直接アクセス不可
  • Node 専用 API が使えない
  • 処理は軽量である必要がある

12-6. route.ts / page.tsx との役割分担

仕組み 役割 実行タイミング
Middleware 入口判定・遮断 最初
route.ts 業務 API 処理 Middleware の後
page.tsx 画面表示 Middleware の後
まとめ:
  • Middleware は「入口での関所」
  • 画面・API 共通で効く
  • 重い処理は書かない
my-app/
  app/
    page.tsx
    api/
      users/
        route.ts
  middleware.ts   // ← ここ(プロジェクト直下)
  package.json
app/ の中に置いても Middleware としては動きません。
必ずプロジェクト直下に置きます。

13. 環境変数と秘密情報(どこで定義し、どこで使うか)

Next.js の環境変数は OS の環境変数として扱われます。
実務では .env ファイルに定義し、Next.js が起動時・ビルド時に読み込みます。

13-1. 環境変数の定義(.env.local)

# プロジェクト直下の .env.local
DB_PASSWORD=secret_password
INTERNAL_API_KEY=internal_key_123

NEXT_PUBLIC_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_APP_NAME=User Management System
  • NEXT_PUBLIC_ 付き → ブラウザで使える
  • 付いていないもの → サーバー専用(秘密情報)

13-2. route.ts での読み込み(サーバー専用)

// app/api/users/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const apiKey = process.env.INTERNAL_API_KEY;
  const dbPassword = process.env.DB_PASSWORD;

  // ここで DB 接続や内部 API を呼ぶ
  console.log("API KEY:", apiKey);

  return NextResponse.json({ status: "ok" });
}
route.ts では NEXT_PUBLIC_ なしの秘密情報が読めます。
これらはブラウザに送られません。

13-3. middleware.ts での読み込み(入口制御)

// middleware.ts(プロジェクト直下)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const internalKey = process.env.INTERNAL_API_KEY;

  if (!internalKey) {
    return new NextResponse("Server misconfiguration", { status: 500 });
  }

  return NextResponse.next();
}
Middleware では DB 接続や重い処理は不可ですが、
秘密情報の参照は可能です。

13-4. Server Component での読み込み

// app/users/page.tsx(Server Component)
export default function UsersPage() {
  const dbPassword = process.env.DB_PASSWORD;

  return (
    <div>
      <h1>ユーザー一覧</h1>
      <p>(DB接続はサーバー側で実行)</p>
    </div>
  );
}
Server Component は サーバー上でのみ実行されるため、
秘密情報を直接参照できます。

13-5. Client Component での読み込み(公開情報のみ)

"use client";

// app/components/AppInfo.tsx
export default function AppInfo() {
  const appName = process.env.NEXT_PUBLIC_APP_NAME;
  const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;

  return (
    <div>
      <p>App: {appName}</p>
      <p>API URL: {apiBaseUrl}</p>
    </div>
  );
}
Client Component では NEXT_PUBLIC_ が付いた環境変数しか使えません。
秘密情報を参照しようとすると ビルドエラーになります。

13-6. 実務ルールまとめ(コード基準)

場所 コード上の参照 使える環境変数
middleware.ts process.env.X 秘密情報
route.ts process.env.X 秘密情報
Server Component process.env.X 秘密情報
Client Component process.env.NEXT_PUBLIC_X 公開情報のみ
まとめ:
  • 環境変数は OS 環境変数
  • .env.local に定義するのが実務の基本
  • 読むのは常に process.env
  • NEXT_PUBLIC_ は「ブラウザ公開宣言」

14. バリデーション(どこで落とすのが正解か)

業務アプリは 入力ミスが起きる前提です。
ただし「入口で落とす」と言っても、Middleware ではなく
route.ts(API)で必ず検証して落とすのが基本です。
画面(Client)はユーザー体験のために先に警告しますが、最終判断は API 側で行います。

14-1. 置き場所の結論(重要)

場所 目的 バリデーションの扱い
Client Component(画面) 入力しやすさ 早めに警告(必須/形式)※信用しない
route.ts(API) 正しさの保証 必ず検証して落とす(最終防衛線)
Middleware 通す/弾く 入力項目の検証はしない(認証/認可向け)

14-2. 実務で必要なバリデーションの種類

  • 必須:未入力は 400
  • 形式:メール形式、日付形式、数字のみ 等
  • 範囲:1〜100、文字数 1〜50 等
  • 整合性:存在しないID、権限がない更新、重複禁止 等(必要ならDB確認)

14-3. route.ts で「入口で落とす」具体例(POST)

例:ユーザー登録 API(POST /api/users)で、
必須・形式・範囲をチェックして、NGなら 400 を返します。
// app/api/users/route.ts
import { NextResponse } from "next/server";

function isValidEmail(email: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export async function POST(req: Request) {
  const body = await req.json();
  const errors: string[] = [];

  // 必須
  if (!body.name) errors.push("name は必須です");
  if (!body.email) errors.push("email は必須です");

  // 形式
  if (body.email && !isValidEmail(body.email)) {
    errors.push("email の形式が不正です");
  }

  // 範囲(例:名前 1〜50 文字)
  if (body.name && (body.name.length < 1 || body.name.length > 50)) {
    errors.push("name は 1〜50 文字で入力してください");
  }

  // NGなら入口で落とす(業務では必須)
  if (errors.length > 0) {
    return NextResponse.json(
      { message: "validation error", errors },
      { status: 400 }
    );
  }

  // ここから先は「正しい入力」だけが来る前提で処理できる
  // (例:DB insert)
  const created = { id: 123, name: body.name, email: body.email };

  return NextResponse.json(created, { status: 201 });
}
このように API 側で落とすと、
外部から直接叩かれても画面のチェックを回避されても
必ず守れます(業務ではここが重要です)。

14-4. Client 側は「先に気付かせる」(ただし最終判断ではない)

画面側のチェックは「入力しやすさ」のために行います。
ただし Client のチェックは必ず回避できるため、
API 側のバリデーションが本体です。
まとめ:
  • 入力チェックは「起きる前提」で設計する
  • 最終的に守るのは route.ts(API)
  • Client はユーザー体験改善のために先に警告する
  • Middleware に入力項目バリデーションは入れない

14-Y. React クライアントから JSON を送る例(route.ts 連携)

route.ts 側で await req.json() を使う場合、
クライアントは JSON 形式でリクエストを送信します。
以下は 実務でそのまま使える React(Client Component)の例です。

14-Y-1. 想定する API

POST /api/users
Content-Type: application/json

14-Y-2. React クライアント(Client Component)

"use client";

import { useState } from "react";

export default function UserCreateForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
  const [success, setSuccess] = useState("");

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setSuccess("");

    const res = await fetch("/api/users", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name,
        email,
      }),
    });

    const data = await res.json();

    if (!res.ok) {
      setError(data.message || "登録に失敗しました");
      return;
    }

    setSuccess("ユーザーを登録しました");
    setName("");
    setEmail("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <h2>ユーザー登録</h2>

      <div>
        <label>名前</label><br />
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
      </div>

      <div>
        <label>メールアドレス</label><br />
        <input
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>

      <button type="submit">登録</button>

      {error && <p style={{ color: "red" }}>{error}</p>}
      {success && <p style={{ color: "green" }}>{success}</p>}
    </form>
  );
}

14-Y-3. このコードでやっていること

  • fetch()/api/users に POST
  • Content-Type: application/json を指定
  • JSON.stringify() で body を送信
  • API 側で await req.json() が受け取る

14-Y-4. route.ts 側との対応関係

// route.ts 側
export async function POST(req: Request) {
  const body = await req.json();
  // body.name
  // body.email
}
fetch → JSON.stringify → req.json()
この 3 点は必ずセットで理解します。

14-Y-5. なぜ form 送信(application/x-www-form-urlencoded)ではないのか

業務 API では:
  • 構造化データ
  • 配列・ネスト
  • 将来の拡張
を考慮し、JSON を標準にするのが一般的です。
まとめ:
  • req.json() は「JSON で送られてくる前提」
  • Client は fetch + JSON.stringify を使う
  • Content-Type を必ず指定する
  • この形が Next.js 業務アプリの基本

14-Z. エラーは「各項目の下」に出すのは一般的か?

はい、各項目の下にエラーを1つずつ表示するのは一般的です。
ただし実務では、フォーム全体のエラー(上部)も併用することが多いです。

14-Z-1. 実務での基本ルール(表示位置の使い分け)

エラーの種類 表示位置
項目に紐づくエラー 必須 / 形式 / 範囲 各項目の下
項目に紐づかないエラー DB重複 / 権限 / サーバー障害 フォーム上部(または下部)

14-Z-2. なぜ「各項目の下」が一般的なのか

入力フォームでは、ユーザーが直すべき場所がすぐ分かることが重要です。
そのため 「どの項目が悪いか」を、項目のすぐ近くで伝える(=各項目の下)がよく使われます。

14-Z-3. React 実装の典型(項目別 + 全体)

実務では 「項目別エラー」「フォーム全体エラー」の2段構えが多いです。
例:必須や形式は項目の下、DB重複はフォーム上部。
"use client";

import { useState } from "react";

export default function UserCreateForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");

  // フォーム全体エラー(例:DB重複、権限、サーバー障害)
  const [formError, setFormError] = useState("");

  // 項目別エラー(例:必須、形式、範囲)
  const [fieldErrors, setFieldErrors] = useState({ name: "", email: "" });

  async function handleSubmit(e) {
    e.preventDefault();
    setFormError("");
    setFieldErrors({ name: "", email: "" });

    const res = await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, email }),
    });

    const data = await res.json();

    if (!res.ok) {
      // API が「項目別エラー」を返してきた場合
      if (data.errors) {
        setFieldErrors({
          name: data.errors.name || "",
          email: data.errors.email || "",
        });
      } else {
        // DB重複など、項目に紐づかない場合
        setFormError(data.message || "登録に失敗しました");
      }
      return;
    }

    alert("登録しました");
    setName("");
    setEmail("");
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* フォーム全体エラー */}
      {formError && <p style={{ color: "red" }}>{formError}</p>}

      <h2>ユーザー登録</h2>

      <div>
        <label>名前</label><br />
        <input value={name} onChange={(e) => setName(e.target.value)} />
        {/* 項目別エラー(名前の下) */}
        {fieldErrors.name && <p style={{ color: "red" }}>{fieldErrors.name}</p>}
      </div>

      <div>
        <label>メールアドレス</label><br />
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
        {/* 項目別エラー(メールの下) */}
        {fieldErrors.email && <p style={{ color: "red" }}>{fieldErrors.email}</p>}
      </div>

      <button type="submit">登録</button>
    </form>
  );
}

14-Z-4. API(route.ts)が返す形(項目別エラーの例)

項目別に出したい場合、API は次のように errors を返すと扱いやすいです。
{
  "message": "validation error",
  "errors": {
    "name": "name は必須です",
    "email": "email の形式が不正です"
  }
}
まとめ:
  • 各項目の下にエラーを出すのは一般的
  • 実務では フォーム全体エラーも併用する
  • API は errors を返すと項目別表示が簡単

14-X. DB の duplicate(重複)も route.ts(API)で処理するのか?

はい。重複(duplicate)は route.ts(API)で処理します。
さらに実務では、DB の UNIQUE 制約 + API 側のエラーハンドリングの両方で守ります。

14-X-1. なぜ route.ts でやるのか

  • Client 側のチェックは回避できる(信用できない)
  • Middleware は入口制御向けで、入力の意味を判定しない
  • DB と直接向き合う最後の層が route.ts
したがって「重複禁止」のような業務ルールは API(route.ts)が最終責任を持ちます。

14-X-2. 正しい守り方(実務の結論)

実務での正解はこの2段構えです:
  1. DB に UNIQUE 制約(最重要・最終防衛線)
  2. route.ts で UNIQUE 違反を捕まえて 409 を返す

14-X-3. DB 側(UNIQUE 制約の例)

-- 例:users テーブルの email は重複禁止
CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL
);
UNIQUE 制約がないと、並行アクセスで必ず重複が発生します。

14-X-4. route.ts 側(duplicate を 409 Conflict で返す例)

例:POST /api/users で新規登録するとき、
email が既に存在する場合は 409 Conflict を返します。
// app/api/users/route.ts
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.json();

  // 入口バリデーション(必須/形式など)
  if (!body.email || !body.name) {
    return NextResponse.json(
      { message: "email と name は必須です" },
      { status: 400 }
    );
  }

  try {
    // ここで DB に INSERT(例)
    // await db.insertUser({ email: body.email, name: body.name });

    return NextResponse.json(
      { message: "created" },
      { status: 201 }
    );

  } catch (err: any) {
    // DB の UNIQUE 制約違反を捕まえる(DB/ドライバによりコードは異なる)
    // 例:SQLite なら SQLITE_CONSTRAINT、PostgreSQL なら 23505 など
    if (err?.code === "SQLITE_CONSTRAINT" || err?.code === "23505") {
      return NextResponse.json(
        { message: "この email は既に登録されています" },
        { status: 409 }
      );
    }

    // それ以外はサーバーエラー
    return NextResponse.json(
      { message: "server error" },
      { status: 500 }
    );
  }
}

14-X-5. 「事前チェックだけ」ではダメな理由

次のように SELECT で先に確認するだけだと、並行アクセスで破綻します。
// ❌ よくある失敗例(レースコンディションで破綻)
const exists = await db.findByEmail(email);
if (exists) return 400;
await db.insert(email); // ← この間に別リクエストが INSERT できてしまう
まとめ:
  • 重複禁止は「業務の正しさ」なので route.ts が責任を持つ
  • DB の UNIQUE 制約が最終防衛線
  • API は重複時に 409 Conflict を返す

15. DB/ORM(server層に閉じ込める:生SQL版 / ORM版)

DBアクセスは 画面(Client Component)から直接書きません
理由は「秘密情報(接続情報)が漏れる」「不正呼び出しを防げない」「設計が破綻しやすい」ためです。
Next.js では Route Handler(route.ts)/ Server Actions / Server Component の中に DB処理を寄せ、
さらに server/ 配下などの“server層”にDBコードを閉じ込めると安全です。

15-1. 推奨ディレクトリ構成(共通)

my-app/
  app/
    api/
      users/
        route.ts          // API(ここから server 層を呼ぶ)
  server/
    db/                  // DB/ORM はここに閉じ込める
      usersRepo.ts
  .env.local             // DB接続情報など(秘密)
Client Component から server/db を import しない(事故防止)。
「DBは server 層だけが知る」ルールにします。

15-2. 生SQL版(SQLite例:実務で使える最小CRUD)

ORMを使わず 生SQLで書く版です。
例として SQLite + better-sqlite3 を使います(小規模・配布しやすい用途でよく採用されます)。

インストール

npm i better-sqlite3

.env.local(DBファイルの場所は秘密情報として server 側だけで使う)

DB_PATH=./data/app.db

server/db/usersRepo.ts(生SQL:Repository)

// server/db/usersRepo.ts
import Database from "better-sqlite3";

// DB接続は server 側だけ
const dbPath = process.env.DB_PATH || "./data/app.db";
const db = new Database(dbPath);

// 起動時にテーブル作成(サンプルとして実務でも普通にやる形)
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  );
`);

export type User = { id: number; email: string; name: string; created_at: string };

export function listUsers(): User[] {
  return db.prepare("SELECT id, email, name, created_at FROM users ORDER BY id DESC").all() as User[];
}

export function createUser(input: { email: string; name: string }): User {
  const stmt = db.prepare("INSERT INTO users (email, name) VALUES (?, ?)");
  const result = stmt.run(input.email, input.name);
  return db
    .prepare("SELECT id, email, name, created_at FROM users WHERE id = ?")
    .get(result.lastInsertRowid) as User;
}

export function updateUser(input: { id: number; email?: string; name?: string }): User | null {
  const current = db.prepare("SELECT id, email, name, created_at FROM users WHERE id = ?").get(input.id) as User | undefined;
  if (!current) return null;

  const nextEmail = input.email ?? current.email;
  const nextName = input.name ?? current.name;

  db.prepare("UPDATE users SET email = ?, name = ? WHERE id = ?").run(nextEmail, nextName, input.id);
  return db.prepare("SELECT id, email, name, created_at FROM users WHERE id = ?").get(input.id) as User;
}

export function deleteUser(id: number): boolean {
  const result = db.prepare("DELETE FROM users WHERE id = ?").run(id);
  return result.changes > 0;
}

app/api/users/route.ts(Route Handler:生SQL Repo を呼ぶ)

// app/api/users/route.ts
import { NextResponse } from "next/server";
import { listUsers, createUser, updateUser, deleteUser } from "@/server/db/usersRepo";

function isValidEmail(email: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export async function GET() {
  const users = listUsers();
  return NextResponse.json(users, { status: 200 });
}

export async function POST(req: Request) {
  const body = await req.json();
  const errors: Record<string, string> = {};

  if (!body.name) errors.name = "name は必須です";
  if (!body.email) errors.email = "email は必須です";
  if (body.email && !isValidEmail(body.email)) errors.email = "email の形式が不正です";

  if (Object.keys(errors).length) {
    return NextResponse.json({ message: "validation error", errors }, { status: 400 });
  }

  try {
    const created = createUser({ name: body.name, email: body.email });
    return NextResponse.json(created, { status: 201 });
  } catch (err: any) {
    // UNIQUE 制約違反(duplicate)
    if (String(err?.message || "").includes("UNIQUE")) {
      return NextResponse.json({ message: "この email は既に登録されています" }, { status: 409 });
    }
    return NextResponse.json({ message: "server error" }, { status: 500 });
  }
}

export async function PUT(req: Request) {
  const body = await req.json();
  if (!body.id) return NextResponse.json({ message: "id は必須です" }, { status: 400 });

  try {
    const updated = updateUser({ id: Number(body.id), name: body.name, email: body.email });
    if (!updated) return NextResponse.json({ message: "not found" }, { status: 404 });
    return NextResponse.json(updated, { status: 200 });
  } catch (err: any) {
    if (String(err?.message || "").includes("UNIQUE")) {
      return NextResponse.json({ message: "この email は既に登録されています" }, { status: 409 });
    }
    return NextResponse.json({ message: "server error" }, { status: 500 });
  }
}

export async function DELETE(req: Request) {
  const { searchParams } = new URL(req.url);
  const id = Number(searchParams.get("id"));
  if (!id) return NextResponse.json({ message: "id が必要です" }, { status: 400 });

  const ok = deleteUser(id);
  if (!ok) return NextResponse.json({ message: "not found" }, { status: 404 });
  return NextResponse.json({ message: "deleted" }, { status: 200 });
}
生SQL版のポイント:
  • SQLは server/db に閉じ込める(画面に持ち込まない)
  • Route Handler は「入力検証」「HTTPの返し方」を担当
  • duplicate は DB の UNIQUE + API 側の 409 で守る

15-3. ORM版(Prisma例:型安全・中規模以上で定番)

ORM を使う版です。ここでは Next.js で採用が多い Prisma を例にします。
「SQLを直書きせず、モデル操作でCRUDを書く」スタイルになります。

インストール & 初期化

npm i prisma @prisma/client
npx prisma init --datasource-provider sqlite

.env.local(Prisma の接続文字列)

DATABASE_URL="file:./data/app.db"

prisma/schema.prisma(モデル定義:UNIQUEもここで宣言)

// prisma/schema.prisma
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  createdAt DateTime @default(now())
}

マイグレーション

npx prisma migrate dev --name init

server/db/prisma.ts(Prisma Client:server層に閉じ込める)

// server/db/prisma.ts
import { PrismaClient } from "@prisma/client";

declare global {
  // 開発中のホットリロードで多重生成されないようにする定番パターン
  // eslint-disable-next-line no-var
  var prisma: PrismaClient | undefined;
}

export const prisma =
  globalThis.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalThis.prisma = prisma;
}

app/api/users/route.ts(ORMでCRUD:実務的な返し方)

// app/api/users/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/server/db/prisma";

function isValidEmail(email: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export async function GET() {
  const users = await prisma.user.findMany({ orderBy: { id: "desc" } });
  return NextResponse.json(users, { status: 200 });
}

export async function POST(req: Request) {
  const body = await req.json();
  const errors: Record<string, string> = {};

  if (!body.name) errors.name = "name は必須です";
  if (!body.email) errors.email = "email は必須です";
  if (body.email && !isValidEmail(body.email)) errors.email = "email の形式が不正です";

  if (Object.keys(errors).length) {
    return NextResponse.json({ message: "validation error", errors }, { status: 400 });
  }

  try {
    const created = await prisma.user.create({
      data: { name: body.name, email: body.email },
    });
    return NextResponse.json(created, { status: 201 });
  } catch (err: any) {
    // Prisma のユニーク制約違反コード(代表:P2002)
    if (err?.code === "P2002") {
      return NextResponse.json({ message: "この email は既に登録されています" }, { status: 409 });
    }
    return NextResponse.json({ message: "server error" }, { status: 500 });
  }
}

export async function PUT(req: Request) {
  const body = await req.json();
  if (!body.id) return NextResponse.json({ message: "id は必須です" }, { status: 400 });

  try {
    const updated = await prisma.user.update({
      where: { id: Number(body.id) },
      data: {
        name: body.name,
        email: body.email,
      },
    });
    return NextResponse.json(updated, { status: 200 });
  } catch (err: any) {
    if (err?.code === "P2002") {
      return NextResponse.json({ message: "この email は既に登録されています" }, { status: 409 });
    }
    // 存在しないIDなど(Prismaは例外になる)
    return NextResponse.json({ message: "not found or server error" }, { status: 404 });
  }
}

export async function DELETE(req: Request) {
  const { searchParams } = new URL(req.url);
  const id = Number(searchParams.get("id"));
  if (!id) return NextResponse.json({ message: "id が必要です" }, { status: 400 });

  try {
    await prisma.user.delete({ where: { id } });
    return NextResponse.json({ message: "deleted" }, { status: 200 });
  } catch {
    return NextResponse.json({ message: "not found" }, { status: 404 });
  }
}
ORM版のポイント:
  • モデル(schema)で UNIQUE / 型を宣言できる
  • CRUDは SQL ではなく prisma.user.create() のように書ける
  • それでも duplicate は DB制約 + 409 返却で守る(考え方は同じ)

15-4. 生SQL と ORM の使い分け(実務の目安)

観点 生SQL ORM
小規模・単純CRUD ◎(速い/軽い)
中〜大規模・型安全 △(管理が大変) ◎(保守が楽)
複雑なSQL最適化 ○(必要なら生SQL併用)
まとめ:
  • DB/ORM は server 層(server/db)に閉じ込める
  • route.ts は入力検証とHTTP応答、DB層はCRUD実装に集中
  • duplicate は DB制約 + APIの 409 で守る(生SQLでもORMでも同じ)

15-X. ORM + 生SQLの混合(現実的な“よくある”構成)

実務では「基本は ORM(保守性・型安全)」「一部だけ生SQL(複雑集計・最適化・バルク処理)」という 混合がよくあります。
Next.js では DBアクセスは server層に閉じ込め、Route Handler(route.ts)は 入力検証・HTTP応答に集中させます。

15-X-1. 構成(混合の置き場所)

my-app/
  app/
    api/
      users/
        route.ts                // API(ここから server層を呼ぶ)
  prisma/
    schema.prisma               // ORM(Prisma)モデル
  server/
    db/
      prisma.ts                 // Prisma Client(ORM)
      usersRepo.ts              // ORM中心のRepo
      usersSql.ts               // 生SQL(集計/特殊処理)だけを切り出す
Client Component から server/db を import しない(事故防止)。
DBは server層だけに閉じ込めます。

15-X-2. Prisma(ORM)側:基本CRUD

prisma/schema.prisma(例)

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  createdAt DateTime @default(now())
}

server/db/prisma.ts(Prisma Client)

import { PrismaClient } from "@prisma/client";

declare global {
  // eslint-disable-next-line no-var
  var prisma: PrismaClient | undefined;
}

export const prisma = globalThis.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalThis.prisma = prisma;
}

server/db/usersRepo.ts(ORM中心のCRUD)

import { prisma } from "@/server/db/prisma";

export async function listUsers() {
  return prisma.user.findMany({ orderBy: { id: "desc" } });
}

export async function createUser(input: { name: string; email: string }) {
  return prisma.user.create({ data: input });
}

export async function updateUser(input: { id: number; name?: string; email?: string }) {
  return prisma.user.update({
    where: { id: input.id },
    data: { name: input.name, email: input.email },
  });
}

export async function deleteUser(id: number) {
  return prisma.user.delete({ where: { id } });
}

15-X-3. 生SQL側:ORMでやりにくい処理だけを書く(集計/検索/バルク)

例:ユーザーを「ドメイン別」に集計するなど、複雑な集計は生SQLが読みやすいことがあります。
Prisma は SQLite では $queryRaw を使って生SQLを実行できます。
(DBがPostgreSQL/MySQLでも同じ考え方です)

server/db/usersSql.ts(生SQL:集計クエリ)

import { prisma } from "@/server/db/prisma";

/**
 * ユーザーの email ドメイン別件数を集計する(例:業務でよくある管理画面向け)
 * 例: "example.com" が 10人、"corp.local" が 3人…のような集計
 */
export async function countUsersByEmailDomain() {
  // SQLite の場合:substr/instr を使って "@" 以降を取り出す
  const rows = await prisma.$queryRaw<
    Array<{ domain: string; count: number }>
  >`
    SELECT
      substr(email, instr(email, '@') + 1) AS domain,
      COUNT(*) AS count
    FROM User
    GROUP BY domain
    ORDER BY count DESC;
  `;

  return rows;
}

/**
 * バルク削除(例:古いテストデータだけ一括削除)
 * ORMでも可能だが、条件が複雑だったり最適化したい場合に生SQLを使うことがある。
 */
export async function deleteTestUsersByEmailSuffix(suffix: string) {
  const result = await prisma.$executeRaw`
    DELETE FROM User
    WHERE email LIKE ${"%" + suffix};
  `;
  return result; // 変更行数
}
生SQLは「必要なところだけ」に限定します。
乱用すると、ORM採用メリット(保守性・一貫性)が消えます。

15-X-4. Route Handler:ORM CRUD + 生SQL 集計を同じAPIに混在させる例

例:GET /api/users は一覧(ORM)、
GET /api/users?summary=domain は集計(生SQL)
のように “現実的な管理API”としてまとめます。
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { listUsers, createUser, updateUser, deleteUser } from "@/server/db/usersRepo";
import { countUsersByEmailDomain } from "@/server/db/usersSql";

function isValidEmail(email: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const summary = searchParams.get("summary");

  // 集計系(生SQL)
  if (summary === "domain") {
    const data = await countUsersByEmailDomain();
    return NextResponse.json({ type: "domain_summary", data }, { status: 200 });
  }

  // 通常一覧(ORM)
  const users = await listUsers();
  return NextResponse.json(users, { status: 200 });
}

export async function POST(req: Request) {
  const body = await req.json();
  const errors: Record<string, string> = {};

  if (!body.name) errors.name = "name は必須です";
  if (!body.email) errors.email = "email は必須です";
  if (body.email && !isValidEmail(body.email)) errors.email = "email の形式が不正です";

  if (Object.keys(errors).length) {
    return NextResponse.json({ message: "validation error", errors }, { status: 400 });
  }

  try {
    const created = await createUser({ name: body.name, email: body.email });
    return NextResponse.json(created, { status: 201 });
  } catch (err: any) {
    // Prisma unique violation
    if (err?.code === "P2002") {
      return NextResponse.json({ message: "この email は既に登録されています" }, { status: 409 });
    }
    return NextResponse.json({ message: "server error" }, { status: 500 });
  }
}

export async function PUT(req: Request) {
  const body = await req.json();
  if (!body.id) return NextResponse.json({ message: "id は必須です" }, { status: 400 });

  try {
    const updated = await updateUser({
      id: Number(body.id),
      name: body.name,
      email: body.email,
    });
    return NextResponse.json(updated, { status: 200 });
  } catch (err: any) {
    if (err?.code === "P2002") {
      return NextResponse.json({ message: "この email は既に登録されています" }, { status: 409 });
    }
    return NextResponse.json({ message: "not found or server error" }, { status: 404 });
  }
}

export async function DELETE(req: Request) {
  const { searchParams } = new URL(req.url);
  const id = Number(searchParams.get("id"));
  if (!id) return NextResponse.json({ message: "id が必要です" }, { status: 400 });

  try {
    await deleteUser(id);
    return NextResponse.json({ message: "deleted" }, { status: 200 });
  } catch {
    return NextResponse.json({ message: "not found" }, { status: 404 });
  }
}

15-X-5. 混合の“実務ルール”(事故らないための線引き)

  • 基本CRUDは ORM(一覧・作成・更新・削除)
  • 生SQLは限定(複雑集計・最適化・バルク・ORMで読みにくい処理)
  • SQL文字列は server/db/usersSql.ts に隔離
  • Route Handler は 入力検証・HTTPの返却に集中
生SQLを使う場合は、必ずパラメータ化(SQLインジェクション対策)します。
Prisma の $queryRaw/$executeRaw はテンプレートリテラル形式で書くと安全側に寄せられます。
まとめ:
ORMを主軸にして、生SQLは必要箇所だけ“隔離して併用”すると、保守性と性能を両立できます。

16. 認証 / 認可(最初に決める“最小セット”)

認証 / 認可は 後から足すと必ず破綻するため、
業務アプリでは 最初に「どこまでやるか」を最小セットで決めることが重要です。
ここでは 作りすぎない・事故らないための現実的な整理を示します。

16-1. 認証と認可の違い(混同しやすい)

用語 意味
認証(Authentication) 誰かを確認する ログインしているか
認可(Authorization) 何をしてよいかを決める 管理画面を見てよいか
順序が重要:
① 認証(誰か分かる) → ② 認可(何を許すか)

16-2. 最初に決めるべき最小セット

多くの業務アプリでは、最初から複雑な権限設計は不要です。
まずは次の 最小セットを決めます。
  • ログインしている / していない
  • 一般ユーザー / 管理者
「部署」「ロール」「細かい権限」を最初から作りすぎると、
実装も運用も破綻しやすくなります。

16-3. どこで何を判定するか(役割分担)

場所 役割 やること
Middleware 入口制御 未ログインを弾く / 強制リダイレクト
route.ts(API) 最終判断 権限チェック / 403 を返す
Server Component 表示制御 見せる / 見せないの分岐

16-4. Middleware での最小認証チェック例

例:ログインしていないユーザーは /login に飛ばす。
(Cookie / セッションの有無だけを見る)
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(req: NextRequest) {
  const session = req.cookies.get("session")?.value;

  if (!session && req.nextUrl.pathname.startsWith("/admin")) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  return NextResponse.next();
}

16-5. route.ts での認可チェック例(最終防衛線)

API は 直接叩かれる前提で作ります。
Middleware をすり抜けても、ここで必ず権限を確認します。
// app/api/admin/route.ts
import { NextResponse } from "next/server";

export async function GET(req: Request) {
  const isAdmin = false; // 本来はセッション/トークンから判定

  if (!isAdmin) {
    return NextResponse.json(
      { message: "forbidden" },
      { status: 403 }
    );
  }

  return NextResponse.json({ secret: "admin data" });
}
Middleware だけに頼らないことが重要です。
API は常に単体で安全である必要があります。

16-6. Server Component での表示制御

表示上の制御(ボタンを出す / 出さない)は Server Component で行います。
ただし これは見た目の制御であり、セキュリティではありません。
// app/admin/page.tsx(Server Component)
export default function AdminPage() {
  const isAdmin = false; // 本来はサーバーで取得

  if (!isAdmin) {
    return <p>権限がありません</p>;
  }

  return <h1>管理画面</h1>;
}

16-7. 実務でよくある失敗

  • 画面だけで認可して API を素通しにする
  • Middleware に全部書こうとする
  • 最初から権限を作りすぎる
まとめ:
  • 認証=誰か、認可=何をしてよいか
  • 最初は「ログイン有無」「管理者か」だけで十分
  • 入口(Middleware)+ API(route.ts)の二段構え
  • 表示制御は Server Component、最終判断は API

16. 認証/認可:React画面 → Middleware → API → DB → React画面(完全な一貫コード)

あなたが見たいのは「説明」ではなく、実際に動くコードの一本の流れです。
ここでは、React画面(Client)から呼び出し → Middlewareで入口判定route.tsで認可SQLiteでSELECT/INSERTJSONで戻してReactで表示、を 同じ設計思想で見せます。
(※DBは “生SQL” で実装。ORM版は前章のPrisma例を参照し、差し替え可能です)

16-1. ディレクトリ構成(この通りに置けば流れが追える)

my-app/
  app/
    admin/
      page.tsx                 // React画面(Client): 一覧取得 + 登録
    login/
      page.tsx                 // React画面(Client): ログインしてCookieを持つ
    api/
      login/
        route.ts               // ログインAPI:session cookie をセット
      users/
        route.ts               // 認可付きAPI:SELECT/INSERT(DB)
  server/
    db/
      sqlite.ts                // SQLite接続 + テーブル作成(生SQL)
      auth.ts                  // sessionの検証(DB)
      usersRepo.ts             // usersのSELECT/INSERT(DB)
  middleware.ts                 // 入口判定(/admin を未ログインなら /login へ)
  .env.local                    // DBパス(秘密情報)

16-2. DB(SQLite): テーブル作成と接続(server/db/sqlite.ts)

「ログイン状態」をサンプルで誤魔化さず、sessionをDBで管理します。
これにより、Middleware / API の両方が同じ根拠で判定できます。
// server/db/sqlite.ts
import Database from "better-sqlite3";

const dbPath = process.env.DB_PATH || "./data/app.db";
export const db = new Database(dbPath);

// 起動時に必要テーブルを作成(実務でも小規模なら普通にやる)
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL
  );

  CREATE TABLE IF NOT EXISTS sessions (
    token TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL,
    role TEXT NOT NULL,               -- "admin" or "user"
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY(user_id) REFERENCES users(id)
  );
`);

// 初期データ(最低限の動作確認用:管理者ユーザー1件)
// 既に存在すれば何もしない
const exists = db.prepare("SELECT id FROM users WHERE email = ?").get("admin@example.com");
if (!exists) {
  const r = db.prepare("INSERT INTO users (email, name) VALUES (?, ?)").run("admin@example.com", "管理者");
  // 初回だけ admin セッションを1つ作る(token固定ではなく、ログインAPIで作るのが本来)
}
インストール:npm i better-sqlite3
DBファイル:./data/app.db(フォルダがなければ作成してください)

.env.local

DB_PATH=./data/app.db

16-3. 認証/認可の根拠:sessionをDBで確認(server/db/auth.ts)

// server/db/auth.ts
import { db } from "@/server/db/sqlite";

export type Session = { token: string; user_id: number; role: "admin" | "user" };

export function getSessionByToken(token: string | undefined): Session | null {
  if (!token) return null;

  const row = db.prepare(
    "SELECT token, user_id, role FROM sessions WHERE token = ?"
  ).get(token) as Session | undefined;

  return row ?? null;
}

16-4. DB操作(SELECT/INSERT):usersRepo(server/db/usersRepo.ts)

// server/db/usersRepo.ts
import { db } from "@/server/db/sqlite";

export type User = { id: number; email: string; name: string };

export function listUsers(): User[] {
  return db.prepare("SELECT id, email, name FROM users ORDER BY id DESC").all() as User[];
}

export function createUser(input: { email: string; name: string }): User {
  const stmt = db.prepare("INSERT INTO users (email, name) VALUES (?, ?)");
  const result = stmt.run(input.email, input.name);

  return db.prepare("SELECT id, email, name FROM users WHERE id = ?")
    .get(result.lastInsertRowid) as User;
}

16-5. Middleware(入口):/admin を未ログインなら /login にリダイレクト(middleware.ts)

Middleware は「入口で弾く」担当です。
ここでは session cookie の有無だけ見て、未ログインなら /login に送ります。
(※最終防衛線は API 側でも必ずやる)
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname;

  // 管理画面だけ保護(必要に応じて /api も対象にする)
  if (path.startsWith("/admin")) {
    const token = req.cookies.get("session")?.value;
    if (!token) {
      return NextResponse.redirect(new URL("/login", req.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/admin/:path*"],
};

16-6. ログインAPI:session cookie を発行(app/api/login/route.ts)

「ログインしてCookieを持つ」までコードで繋げます。
ここでは簡略化として email を送るだけでログインし、session を作ります。
(実務ではパスワード/SSO等に差し替え)
// app/api/login/route.ts
import { NextResponse } from "next/server";
import { db } from "@/server/db/sqlite";

// Node環境のcrypto(Route Handlerはサーバー側)
import crypto from "crypto";

export async function POST(req: Request) {
  const body = await req.json();
  const email = String(body.email || "").trim();

  if (!email) {
    return NextResponse.json({ message: "email は必須です" }, { status: 400 });
  }

  // ユーザー取得(なければ作成:社内ツールなどではこういう運用もある)
  let user = db.prepare("SELECT id, email, name FROM users WHERE email = ?").get(email) as any;
  if (!user) {
    const r = db.prepare("INSERT INTO users (email, name) VALUES (?, ?)").run(email, email.split("@")[0]);
    user = db.prepare("SELECT id, email, name FROM users WHERE id = ?").get(r.lastInsertRowid);
  }

  // roleは例として admin@example.com だけ管理者扱い
  const role = (email === "admin@example.com") ? "admin" : "user";

  // session token 発行
  const token = crypto.randomBytes(24).toString("hex");
  db.prepare("INSERT INTO sessions (token, user_id, role) VALUES (?, ?, ?)").run(token, user.id, role);

  // cookie セット
  const res = NextResponse.json({ message: "logged in", role }, { status: 200 });
  res.cookies.set({
    name: "session",
    value: token,
    httpOnly: true,
    sameSite: "lax",
    path: "/",
  });

  return res;
}

16-7. 認可付きAPI:SELECT/INSERT(app/api/users/route.ts)

ここが「React → API → DB → React」の中心です。
APIは直接叩かれる前提なので、Middlewareを抜けても必ずここで認証/認可します。
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSessionByToken } from "@/server/db/auth";
import { listUsers, createUser } from "@/server/db/usersRepo";

function isValidEmail(email: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

function requireAdmin() {
  const token = cookies().get("session")?.value;
  const session = getSessionByToken(token);

  if (!session) {
    return { ok: false as const, res: NextResponse.json({ message: "unauthorized" }, { status: 401 }) };
  }
  if (session.role !== "admin") {
    return { ok: false as const, res: NextResponse.json({ message: "forbidden(管理者のみ)" }, { status: 403 }) };
  }
  return { ok: true as const, session };
}

export async function GET() {
  const auth = requireAdmin();
  if (!auth.ok) return auth.res;

  // DB SELECT
  const users = listUsers();
  return NextResponse.json(users, { status: 200 });
}

export async function POST(req: Request) {
  const auth = requireAdmin();
  if (!auth.ok) return auth.res;

  const body = await req.json();
  const errors: Record<string, string> = {};

  if (!body.name) errors.name = "name は必須です";
  if (!body.email) errors.email = "email は必須です";
  if (body.email && !isValidEmail(body.email)) errors.email = "email の形式が不正です";

  if (Object.keys(errors).length) {
    return NextResponse.json({ message: "validation error", errors }, { status: 400 });
  }

  try {
    // DB INSERT
    const created = createUser({ name: body.name, email: body.email });
    return NextResponse.json(created, { status: 201 });
  } catch (err: any) {
    // duplicate
    if (String(err?.message || "").includes("UNIQUE")) {
      return NextResponse.json({ message: "この email は既に登録されています" }, { status: 409 });
    }
    return NextResponse.json({ message: "server error" }, { status: 500 });
  }
}

16-8. React画面:ログイン(Cookieを持つ)→ 管理画面へ(app/login/page.tsx)

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export default function LoginPage() {
  const [email, setEmail] = useState("admin@example.com");
  const [error, setError] = useState("");
  const router = useRouter();

  async function login(e: React.FormEvent) {
    e.preventDefault();
    setError("");

    const res = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email }),
    });

    const data = await res.json();
    if (!res.ok) {
      setError(data.message || "ログイン失敗");
      return;
    }

    // Cookie は httpOnly なのでJSから読めないが、ブラウザには保存される
    router.push("/admin");
  }

  return (
    <div>
      <h1>ログイン</h1>

      <form onSubmit={login}>
        <label>email</label><br />
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
        <br />
        <button type="submit">ログイン</button>
      </form>

      {error && <p style={{ color: "red" }}>{error}</p>}
      <p>※ admin@example.com でログインすると管理者になります</p>
    </div>
  );
}

16-9. React画面:管理画面(SELECT/INSERT → 画面に戻る)app/admin/page.tsx

ここがあなたの要求の「往復」です。
一覧取得(SELECT)新規登録(INSERT)の両方を、同じAPIに対して行い、結果を画面に反映します。
"use client";

import { useEffect, useState } from "react";

type User = { id: number; email: string; name: string };

export default function AdminPage() {
  const [users, setUsers] = useState<User[]>([]);
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");

  const [formError, setFormError] = useState("");
  const [fieldErrors, setFieldErrors] = useState({ name: "", email: "" });

  async function loadUsers() {
    setFormError("");
    const res = await fetch("/api/users");

    if (!res.ok) {
      const data = await res.json();
      setFormError(data.message || "取得できませんでした");
      setUsers([]);
      return;
    }

    const data = await res.json();
    setUsers(data);
  }

  async function create(e: React.FormEvent) {
    e.preventDefault();
    setFormError("");
    setFieldErrors({ name: "", email: "" });

    const res = await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, email }),
    });

    const data = await res.json();

    if (!res.ok) {
      // 400: 項目別
      if (data.errors) {
        setFieldErrors({
          name: data.errors.name || "",
          email: data.errors.email || "",
        });
        return;
      }
      // 401/403/409/500など
      setFormError(data.message || "登録に失敗しました");
      return;
    }

    // 追加成功 → 一覧再取得(実務でよくある:整合性が崩れにくい)
    setName("");
    setEmail("");
    await loadUsers();
  }

  useEffect(() => {
    // 画面表示時にSELECT(一覧取得)
    loadUsers();
  }, []);

  return (
    <div>
      <h1>管理画面(Users)</h1>

      {/* フォーム全体エラー(401/403/409など) */}
      {formError && <p style={{ color: "red" }}>{formError}</p>}

      <h2>新規登録(INSERT)</h2>
      <form onSubmit={create}>
        <div>
          <label>名前</label><br />
          <input value={name} onChange={(e) => setName(e.target.value)} />
          {fieldErrors.name && <p style={{ color: "red" }}>{fieldErrors.name}</p>}
        </div>

        <div>
          <label>メール</label><br />
          <input value={email} onChange={(e) => setEmail(e.target.value)} />
          {fieldErrors.email && <p style={{ color: "red" }}>{fieldErrors.email}</p>}
        </div>

        <button type="submit">登録</button>
      </form>

      <h2>一覧(SELECT)</h2>
      <button onClick={loadUsers}>再読み込み</button>

      <ul>
        {users.map((u) => (
          <li key={u.id}>
            {u.id} / {u.name} / {u.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

16-10. これで「往復」がどう成立しているか(1行で)

React(fetch) → Middleware(入口) → route.ts(認可) → DB(SELECT/INSERT) → JSON → React(表示更新)
重要:Middleware は画面遷移の入口には効きますが、
APIは直叩きされる前提なので route.ts でも必ず認可します(ここが一貫性の核)。

17. ファイルアップロード / ダウンロード(React画面 → API → 保存 → 取得)

「(A)フォーム→Route Handler」「(B)外部ストレージ直送」と言われても、コードが無いと何も選べません
ここではまず React画面からのアップロード/ダウンロードがどう流れるのかを説明し、
次に そのまま動くコードで示します。
なお、業務で最初に作るなら (A)Route Handler で受けるが最小で分かりやすいです。
(B)は大容量・帯域・コスト最適化のために後から導入する選択肢です。

17-1. まずは全体の流れ(React → API → 保存 → React)

【アップロード】
React画面でファイル選択
  ↓ (multipart/form-data で送信)
Route Handler(/api/files/upload)が受け取る
  ↓
server 側の保存先にファイルを書き込む(例:/uploads)
  ↓
保存したファイルのID/URLをJSONで返す
  ↓
React画面で「アップロード完了」「ダウンロードリンク」を表示

【ダウンロード】
React画面のリンクをクリック
  ↓
Route Handler(/api/files/download?id=xxx)がファイルを返す
  ↓
ブラウザがダウンロードする(または新規タブで表示)
重要:ファイルは JSON では送れません。
ファイル送信は通常 multipart/form-data(FormData) を使います。

17-2. (A)フォーム→Route Handler で受ける(最小構成)

この方式は「社内業務ツール」「小~中サイズの添付」「最初のプロトタイプ」でよく使います。
Next.js では Route Handler が multipart/form-data を受け取れます。
ただし Edge ではなく Node 実行で扱うのが安全です(ファイル書き込みがあるため)。

17-3. ディレクトリ構成

my-app/
  app/
    files/
      page.tsx                      // React画面:アップロード/一覧/ダウンロード
    api/
      files/
        upload/
          route.ts                  // アップロード API
        download/
          route.ts                  // ダウンロード API
  server/
    files/
      store.ts                      // 保存・取得の共通処理
  uploads/                          // 保存先(git管理しない)
uploads/ は .gitignore 推奨(運用データなので)。

17-4. server 側:保存と取得を “server層” に閉じ込める(server/files/store.ts)

// server/files/store.ts
import path from "path";
import fs from "fs/promises";
import crypto from "crypto";

export type StoredFile = {
  id: string;
  originalName: string;
  savedName: string;
  mimeType: string;
  size: number;
};

const UPLOAD_DIR = path.join(process.cwd(), "uploads");
const META_PATH = path.join(UPLOAD_DIR, "meta.json");

// メタ情報を JSON で保持(最小構成)
// ※実務ではDBテーブルにすることが多い
async function loadMeta(): Promise<Record<string, StoredFile>> {
  try {
    const txt = await fs.readFile(META_PATH, "utf-8");
    return JSON.parse(txt);
  } catch {
    return {};
  }
}
async function saveMeta(meta: Record<string, StoredFile>) {
  await fs.mkdir(UPLOAD_DIR, { recursive: true });
  await fs.writeFile(META_PATH, JSON.stringify(meta, null, 2), "utf-8");
}

export async function saveUploadedFile(input: {
  originalName: string;
  mimeType: string;
  bytes: Uint8Array;
}): Promise<StoredFile> {
  await fs.mkdir(UPLOAD_DIR, { recursive: true });

  const id = crypto.randomBytes(12).toString("hex");
  const ext = path.extname(input.originalName) || "";
  const savedName = `${id}${ext}`;
  const fullPath = path.join(UPLOAD_DIR, savedName);

  await fs.writeFile(fullPath, input.bytes);

  const meta = await loadMeta();
  const info: StoredFile = {
    id,
    originalName: input.originalName,
    savedName,
    mimeType: input.mimeType || "application/octet-stream",
    size: input.bytes.byteLength,
  };
  meta[id] = info;
  await saveMeta(meta);

  return info;
}

export async function getFileMeta(id: string): Promise<StoredFile | null> {
  const meta = await loadMeta();
  return meta[id] ?? null;
}

export async function readFileBytes(savedName: string): Promise<Buffer> {
  const fullPath = path.join(UPLOAD_DIR, savedName);
  return fs.readFile(fullPath);
}

export async function listFiles(): Promise<StoredFile[]> {
  const meta = await loadMeta();
  return Object.values(meta).sort((a, b) => a.originalName.localeCompare(b.originalName));
}

17-5. アップロード API(app/api/files/upload/route.ts)

React から FormData で送られた multipart を受け取り、
server層(store.ts)に保存を委譲します。
// app/api/files/upload/route.ts
import { NextResponse } from "next/server";
import { saveUploadedFile } from "@/server/files/store";

// ファイル書き込みがあるため Node 実行を明示(環境により不要な場合もある)
export const runtime = "nodejs";

export async function POST(req: Request) {
  const form = await req.formData();
  const file = form.get("file");

  if (!file || !(file instanceof File)) {
    return NextResponse.json({ message: "file が送られていません" }, { status: 400 });
  }

  // サイズ制限例(10MB)
  if (file.size > 10 * 1024 * 1024) {
    return NextResponse.json({ message: "ファイルが大きすぎます(最大10MB)" }, { status: 413 });
  }

  const arrayBuf = await file.arrayBuffer();
  const saved = await saveUploadedFile({
    originalName: file.name,
    mimeType: file.type,
    bytes: new Uint8Array(arrayBuf),
  });

  // ダウンロード用URLを返す
  return NextResponse.json({
    message: "uploaded",
    file: saved,
    downloadUrl: `/api/files/download?id=${saved.id}`,
  }, { status: 201 });
}

17-6. ダウンロード API(app/api/files/download/route.ts)

id からメタ情報を引き、実ファイルを読み込んでレスポンスします。
Content-Disposition: attachment を付けると「ダウンロード扱い」になります。
// app/api/files/download/route.ts
import { NextResponse } from "next/server";
import { getFileMeta, readFileBytes } from "@/server/files/store";

export const runtime = "nodejs";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const id = searchParams.get("id");
  if (!id) {
    return NextResponse.json({ message: "id が必要です" }, { status: 400 });
  }

  const meta = await getFileMeta(id);
  if (!meta) {
    return NextResponse.json({ message: "not found" }, { status: 404 });
  }

  const bytes = await readFileBytes(meta.savedName);

  return new NextResponse(bytes, {
    status: 200,
    headers: {
      "Content-Type": meta.mimeType,
      "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(meta.originalName)}`,
    },
  });
}

17-7. React画面(アップロード + 一覧 + ダウンロード)app/files/page.tsx

React 側は FormData を使って送ります。
成功したら API が返した downloadUrl を表示します。
"use client";

import { useEffect, useState } from "react";

type StoredFile = {
  id: string;
  originalName: string;
  mimeType: string;
  size: number;
};

export default function FilesPage() {
  const [picked, setPicked] = useState<File | null>(null);
  const [error, setError] = useState("");
  const [message, setMessage] = useState("");
  const [downloadUrl, setDownloadUrl] = useState("");

  // 簡易:アップロード結果の一覧表示用(実務では /api/files/list を作る)
  const [uploaded, setUploaded] = useState<StoredFile[]>([]);

  async function upload() {
    setError("");
    setMessage("");
    setDownloadUrl("");

    if (!picked) {
      setError("ファイルを選択してください");
      return;
    }

    const fd = new FormData();
    fd.append("file", picked);

    const res = await fetch("/api/files/upload", {
      method: "POST",
      body: fd, // Content-Type は自動で multipart になるので指定しない
    });

    const data = await res.json();

    if (!res.ok) {
      setError(data.message || "アップロードに失敗しました");
      return;
    }

    setMessage("アップロードしました");
    setDownloadUrl(data.downloadUrl);

    // 画面側に反映(簡易)
    setUploaded((prev) => [
      { id: data.file.id, originalName: data.file.originalName, mimeType: data.file.mimeType, size: data.file.size },
      ...prev,
    ]);

    setPicked(null);
  }

  return (
    <div>
      <h1>ファイル管理(アップロード / ダウンロード)</h1>

      <div style={{ border: "1px solid #ddd", padding: 12, borderRadius: 8 }}>
        <h2>アップロード</h2>

        <input
          type="file"
          onChange={(e) => setPicked(e.target.files?.[0] || null)}
        />

        <div style={{ marginTop: 8 }}>
          <button onClick={upload}>アップロード</button>
        </div>

        {error && <p style={{ color: "red" }}>{error}</p>}
        {message && <p style={{ color: "green" }}>{message}</p>}

        {downloadUrl && (
          <p>
            ダウンロード:<a href={downloadUrl}>ここから取得</a>
          </p>
        )}
      </div>

      <h2>アップロード済み(この画面内の履歴)</h2>
      {uploaded.length === 0 ? (
        <p>まだありません</p>
      ) : (
        <ul>
          {uploaded.map((f) => (
            <li key={f.id}>
              {f.originalName}({Math.round(f.size / 1024)}KB)
              {" "}
              <a href={`/api/files/download?id=${f.id}`}>ダウンロード</a>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
注意:
  • アップロード一覧を永続表示したい場合は /api/files/list を作り、DB/メタから取得します。
  • 本番ではウイルス対策/拡張子制限/権限チェック(誰がDLできるか)が必要です。

17-8. (B)外部ストレージ直送(署名URL)とは何か(何が違うのか)

大容量や高トラフィックでは、サーバーがファイル本体を中継すると負荷/コストが増えます。
そこで(B)では、APIは“署名URLを発行するだけ”にして、ファイル本体は S3 等へ ブラウザから直接アップロードします。
React画面
  ↓ (APIに「署名URLください」)
Route Handler(署名URL発行)
  ↓ (署名URL返す)
React画面
  ↓ (署名URLへ直接PUT)
外部ストレージ(S3等)
最初は(A)で十分。
画像・動画・大量添付・高負荷が見えてきたら(B)に移行、が現実的です。

18. 画像 / フォント / 静的アセット(“何をどう書けばよいか”)

この章は「Nextの機能紹介(カタログ)」ではなく、業務で迷わないための書き方マニュアルとして書きます。
つまり「どこに置く」「どう書く」「何を守る」を、コード付きで示します。

18-0. まず結論(迷ったらこれ)

  • アプリ内の画像(ロゴ等) → /public に置き、next/image で表示する
  • 外部画像(CDN等) → 許可ドメインを設定し、next/image で表示する
  • フォント → next/font を使い、layout.tsx で一括適用する

18-1. 静的アセットの置き場所(/public が基本)

/public に置いたファイルは、URLでそのまま参照できます。
例:public/logo.png → ブラウザでは /logo.png
my-app/
  public/
    logo.png
    images/
      hero.jpg
  app/
    page.tsx
// app/page.tsx から参照する場合のURL
/logo.png
/images/hero.jpg
src/assets などに置いて import で頑張らない。
業務では「どこにあるか」「URLは何か」が明確な /public が最小事故で済みます。

18-2. 画像表示:next/image の “正しい書き方”

この章の目的は「最適化が自動」ではなく、どう書けば崩れず、実務で安全かです。
next/image は width/height を明示してレイアウト崩れを防ぎます。

18-2-1. /public の画像を表示(最頻出)

// app/components/Logo.tsx
import Image from "next/image";

export default function Logo() {
  return (
    <Image
      src="/logo.png"
      alt="会社ロゴ"
      width={160}
      height={40}
      priority
    />
  );
}
  • width/height は必ず入れる(表示崩れ防止)
  • ファーストビューの重要画像は priority
  • alt は業務でも必須(アクセシビリティ)

18-2-2. ヒーロー画像:コンテナに合わせてトリミング(崩れない)

// app/components/Hero.tsx
import Image from "next/image";

export default function Hero() {
  return (
    <div style={{ position: "relative", width: "100%", height: 320 }}>
      <Image
        src="/images/hero.jpg"
        alt="サービスのイメージ"
        fill
        sizes="100vw"
        style={{ objectFit: "cover" }}
        priority
      />
    </div>
  );
}
fill を使う場合は親要素に高さが必要です。
高さが無いと「表示されない」「0pxになる」事故が起きます。

18-2-3. 外部画像を使う場合(許可設定が必要)

外部URLの画像を表示する場合、next.config.js で許可ドメインを指定します。
// next.config.js(例)
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "images.example.com" }
    ],
  },
};

module.exports = nextConfig;
// app/components/RemoteImage.tsx
import Image from "next/image";

export default function RemoteImage() {
  return (
    <Image
      src="https://images.example.com/banner.png"
      alt="バナー"
      width={800}
      height={200}
    />
  );
}

18-3. フォント:next/font の “業務で迷わない導入方法”

目的は「最適化」ではなく、フォント指定を散らかさず一括で適用し、表示ブレを減らすことです。
基本は layout.tsx で1回だけ設定します。

18-3-1. Google Fonts を使う(例:Noto Sans JP)

// app/layout.tsx
import "./globals.css";
import { Noto_Sans_JP } from "next/font/google";

const noto = Noto_Sans_JP({
  subsets: ["latin"],
  weight: ["400", "700"],
  display: "swap",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body className={noto.className}>
        {children}
      </body>
    </html>
  );
}
ポイント:
  • フォント指定を 1箇所に閉じ込める(散らからない)
  • display: "swap" を指定(表示の“真っ白”を避ける)

18-3-2. 自前フォント(/public/fonts)を使う

public/
  fonts/
    MyFont-Regular.woff2
    MyFont-Bold.woff2
// app/layout.tsx
import "./globals.css";
import localFont from "next/font/local";

const myFont = localFont({
  src: [
    { path: "../public/fonts/MyFont-Regular.woff2", weight: "400", style: "normal" },
    { path: "../public/fonts/MyFont-Bold.woff2", weight: "700", style: "normal" }
  ],
  display: "swap",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body className={myFont.className}>
        {children}
      </body>
    </html>
  );
}

18-4. 実務ルール(この章の結論)

  • 画像は /public に置き、URL参照で扱う(場所が明確)
  • 表示は next/image を使い、width/height(または fill+高さ)を必ず指定
  • フォントは next/font を使い、layout.tsx に一括適用(散らかさない)
  • 外部画像は next.config.js で許可しない限り使えない(事故防止)

19. CSSの取り扱い(Reactにどう適用するか:迷わない運用ルール)

この章は「CSSの種類紹介」ではなく、業務で事故らないための運用手順として書きます。
つまり どこに書く / どこで読み込む / どれを使う を決め、コードで固定します。

19-1. 結論(迷ったらこのルール)

  1. 全体共通(リセット・色・余白・タイポ) → app/globals.css
  2. 部品単位(ボタン・カード等) → CSS Modules*.module.css
  3. その場だけ(1回しか使わない) → style={{...}} は最小限
  4. クラス名衝突を避けたい → CSS Modules を優先
「適当に各コンポーネントでCSSをimport」を続けると、
どこが効いているか分からなくなり、修正コストが跳ねます。

19-2. CSSの置き場所(推奨構成)

my-app/
  app/
    layout.tsx               // globals.css を1回だけ読む
    globals.css              // 全体共通CSS(ここに集約)
    page.tsx
  app/components/
    Button.tsx
    Button.module.css        // 部品CSS(衝突しない)
    Card.tsx
    Card.module.css

19-3. 全体共通CSS:globals.css(1回だけ読み込む)

Next.js(App Router)では、グローバルCSSは layout.tsx から読み込むのが基本です。
ここに「全ページ共通」だけを書き、コンポーネント固有の見た目は入れすぎません。

app/layout.tsx

// app/layout.tsx
import "./globals.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>{children}</body>
    </html>
  );
}

app/globals.css(例)

/* app/globals.css */

/* 1) 最低限のベース */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
  margin: 0;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  color: #111;
  background: #fff;
}

/* 2) よく使う共通変数(必要なら) */
:root {
  --space-1: 8px;
  --space-2: 12px;
  --space-3: 16px;
  --radius: 10px;
  --border: #e5e7eb;
}

/* 3) 共通の“レイアウト枠” */
.container {
  max-width: 980px;
  margin: 0 auto;
  padding: 0 var(--space-3);
}

/* 4) 見出し・本文の最小整備 */
h1 { font-size: 24px; margin: 16px 0; }
h2 { font-size: 18px; margin: 16px 0 8px; }
p { margin: 8px 0; line-height: 1.6; }
globals.css に入れるのは 共通の土台だけ。
「特定コンポーネントの見た目」まで入れると、後で地獄になります。

19-4. 部品CSS:CSS Modules(コンポーネントに閉じ込める)

CSS Modules は クラス名が自動でユニーク化され、衝突しません。
業務ではこの「衝突しない」が強いので、部品は Modules が基本です。

app/components/Button.module.css

.button {
  padding: 10px 14px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: #111;
  color: #fff;
  cursor: pointer;
}

.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

app/components/Button.tsx

import styles from "./Button.module.css";

export default function Button(
  props: React.ButtonHTMLAttributes<HTMLButtonElement>
) {
  return (
    <button className={styles.button} {...props} />
  );
}
これで .button はグローバルに漏れず、他の .button と衝突しません。

19-5. “1ページだけ” のCSS(ページ専用 Modules)

ページ固有のレイアウトは「page専用のCSS Modules」に閉じ込めると整理できます。

app/admin/AdminPage.module.css

.panel {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--space-3);
  margin: var(--space-3) 0;
}

.row {
  display: grid;
  gap: 8px;
  margin-bottom: 10px;
}

app/admin/page.tsx(例:ClientでもServerでもOK)

import styles from "./AdminPage.module.css";

export default function AdminPage() {
  return (
    <div className="container">
      <h1>管理画面</h1>

      <div className={styles.panel}>
        <h2>検索</h2>
        <div className={styles.row}>
          <input placeholder="キーワード" />
          <button>検索</button>
        </div>
      </div>
    </div>
  );
}

19-6. style={{...}} を使う場面(使いすぎ禁止)

インライン style は便利ですが、乱用すると見た目が分散します。
「1回しか使わない」「動的に数値が変わる」などに限定します。
// OK例:動的に幅が変わるプログレス
<div style={{ width: progress + "%", height: 6 }} />

// NG例:見た目の基本を全部インラインで書く(保守不能)
<div style={{ padding: 12, borderRadius: 10, border: "1px solid #ddd", ... }} />

19-7. CSSの“現場ルール”(この章の最重要)

  • globals.css は土台だけ(色・余白・文字・containerなど)
  • 見た目は原則 Modules(Button/Card/Formなど)
  • ページ固有も Modules(AdminPage.module.css など)
  • インライン style は「動的」「一回だけ」に限定
  • クラス名が増えたら「部品化」する(Button等)

19. SEO / OGP / JSON-LD(metadata と JSON-LD を “各ページの<head>に反映”する)

この章では「何となくSEO」ではなく、Next.js(App Router)で各ページに確実に反映される方法を示します。
重要なのは次の2点です:
(1)metadata = Next.js公式の<head>生成ルート
(2)JSON-LD = <script type="application/ld+json"> を各ページで出す

19-1. metadata とは何か(タイトル/説明/OGPを<head>へ出す仕組み)

App Router では、各ページ(app/**/page.tsx)や各レイアウト(app/**/layout.tsx)で
export const metadata または export async function generateMetadata() を書くと、
Next.js が自動的に <head>(title, meta, OGP, Twitterカード等)を生成します。
つまり、手で <Head> を組み立てるのではなく、metadata で宣言するのが基本です。

19-1-1. 共通(サイト全体)metadata:app/layout.tsx

まずサイト共通のベースを Root Layout に置きます。
各ページは、ここに対して「上書き/追加」できます。
// app/layout.tsx
import type { Metadata } from "next";

const siteUrl = "https://example.com"; // ← 自分のドメインに置換
const siteName = "MAO Portfolio";
const ogImage = `${siteUrl}/ogp.png`;  // public/ogp.png を置く想定

export const metadata: Metadata = {
  metadataBase: new URL(siteUrl),
  title: {
    default: siteName,
    template: `%s | ${siteName}`,
  },
  description: "ポートフォリオ(Web/業務サンプル/ツール集)",
  openGraph: {
    type: "website",
    url: siteUrl,
    siteName,
    title: siteName,
    description: "ポートフォリオ(Web/業務サンプル/ツール集)",
    images: [{ url: ogImage, width: 1200, height: 630, alt: `${siteName} OGP` }],
  },
  twitter: {
    card: "summary_large_image",
    title: siteName,
    description: "ポートフォリオ(Web/業務サンプル/ツール集)",
    images: [ogImage],
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>{children}</body>
    </html>
  );
}
ポイント:
  • metadataBase を入れると OGP URL 等が安定する
  • OGP画像は public/ogp.png などに置く(URLは /ogp.png
  • title の template を入れておくと各ページのタイトルが統一される

19-1-2. ページ単位 metadata:app/about/page.tsx(例)

ページ固有のタイトル/説明/OGP を上書きします。
これが「各ページの<head>に適用する」最も確実な方法です。
// app/about/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "私について",
  description: "MAO の経歴・得意分野・実績をまとめたページです。",
  openGraph: {
    title: "私について",
    description: "MAO の経歴・得意分野・実績をまとめたページです。",
    url: "/about",
    images: [{ url: "/ogp-about.png", width: 1200, height: 630, alt: "私について OGP" }],
  },
  twitter: {
    card: "summary_large_image",
    title: "私について",
    description: "MAO の経歴・得意分野・実績をまとめたページです。",
    images: ["/ogp-about.png"],
  },
};

export default function AboutPage() {
  return <main><h1>私について</h1></main>;
}
ページごとに OGP を変えないなら、openGraph.images は layout.tsx の共通だけでもOKです。

19-2. JSON-LD とは何か(検索エンジンに“構造”で伝える)

JSON-LD は、ページ内容を schema.org の形式で機械可読にするためのデータです。
Next.js では metadata とは別で、<script type="application/ld+json"> を出力します。
ポートフォリオなら、よく使うのは:
  • Person(自分)
  • Organization(屋号/チームがあれば)
  • WebSite(サイト全体)
  • CreativeWork / SoftwareApplication(制作物・ツール)

19-2-1. JSON-LD を各ページの<head>に出す(例:app/about/page.tsx)

App Router では page.tsx の JSX 内で <script> を書けば、
Next.js が適切に <head> へ配置します(Server Component でOK)。
// app/about/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "私について",
  description: "MAO の経歴・得意分野・実績をまとめたページです。",
};

export default function AboutPage() {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Person",
    "@id": "https://example.com/#person",
    "name": "MAO",
    "jobTitle": "ITエンジニア",
    "url": "https://example.com/",
    "sameAs": [
      "https://github.com/yourname"
    ]
  };

  return (
    <main>
      <h1>私について</h1>

      {/* JSON-LD(<head>へ出力される) */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />

      <p>...</p>
    </main>
  );
}
Reactでは script の中にオブジェクトを直接書けないため、
dangerouslySetInnerHTML で JSON文字列を埋め込みます。これはJSON-LDでは定番です。

19-3. “各ページ共通” の JSON-LD を layout.tsx に入れる例(WebSite)

サイト共通(WebSiteなど)は Root Layout に置くと管理が楽です。
ページ固有(Person / SoftwareApplicationなど)は各ページに置きます。
// app/layout.tsx(抜粋)
import type { Metadata } from "next";

const siteUrl = "https://example.com";
const siteName = "MAO Portfolio";

export const metadata: Metadata = {
  metadataBase: new URL(siteUrl),
  title: { default: siteName, template: `%s | ${siteName}` },
  description: "ポートフォリオ(Web/業務サンプル/ツール集)",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "WebSite",
    "@id": `${siteUrl}/#website`,
    "name": siteName,
    "url": siteUrl
  };

  return (
    <html lang="ja">
      <body>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
        />
        {children}
      </body>
    </html>
  );
}

19-4. まとめ(この章の運用ルール)

  • metadata:タイトル/説明/OGP/Twitter を 宣言して <head> を自動生成
  • 各ページexport const metadata でページ固有値にする
  • JSON-LD<script type="application/ld+json"> を出す(layout=共通、page=個別)
  • JSON-LD は dangerouslySetInnerHTML で埋め込むのが定番

補足:${siteUrl} とは何か(どこで定義し、何に使うのか)

${siteUrl}Next.js が自動で用意する変数ではありません
開発者が自分で定義する「サイトの基準URL」です。
OGP / canonical / JSON-LD など、必ず絶対URLが必要な場面で使います。

1. なぜ siteUrl を自分で定義するのか

metadata や JSON-LD は サーバー側・ビルド時に生成されます。
そのため、次のような書き方はできません。
// ❌ 使えない例
window.location.origin
そこで、「このサイトの正規URLはこれ」という値を、
変数 siteUrl として明示的に定義します。

2. 一番シンプルな定義方法(layout.tsx に直書き)

ポートフォリオ・個人サイトでは、この方法が一番分かりやすく安全です。
// app/layout.tsx
import type { Metadata } from "next";

const siteUrl = "https://portfolio-ijp.pages.dev"; // ← 自分のサイトURL

export const metadata: Metadata = {
  metadataBase: new URL(siteUrl),
  title: {
    default: "MAO Portfolio",
    template: "%s | MAO Portfolio",
  },
  openGraph: {
    images: [
      {
        url: `${siteUrl}/ogp.png`,
        width: 1200,
        height: 630,
        alt: "MAO Portfolio OGP",
      },
    ],
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>{children}</body>
    </html>
  );
}

3. ogp.png はどこに置くのか

OGP画像の実体ファイルは /public 配下に置きます。
my-app/
  public/
    ogp.png        ← このファイル
  app/
    layout.tsx
対応関係は次の通りです。
実体ファイル ブラウザから見えるURL
public/ogp.png https://portfolio-ijp.pages.dev/ogp.png

4. 環境変数で定義する方法(補足)

本番・検証・開発でURLを切り替えたい場合は、環境変数を使います。
# .env.local
NEXT_PUBLIC_SITE_URL=https://portfolio-ijp.pages.dev
// app/layout.tsx
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!;
OGP用URLはブラウザに公開されても問題ないため、
NEXT_PUBLIC_ を付けています。

5. まとめ(重要)

  • siteUrl自分で定義する変数
  • Next.js の予約語・自動変数ではない
  • OGP / canonical / JSON-LD 用の 基準URL
  • OGP画像の実体は public/ogp.png
  • metadata では 絶対URLを使う

20. ロギング / 監視(最初から“業務用”で入れる)

「console.log で十分」は 学習用までです。
業務では 最初から「コンソール+ファイルに出る logger」を入れておくと、
障害対応・調査・引き継ぎが圧倒的に楽になります。
ここでは log4j 的な使い方ができる最小構成を示します。

20-1. 結論:Next.js での現実解

  • Node.js 実行環境(Route Handler / server層)で使う
  • console も file も両方に出す
  • ログレベル(INFO / WARN / ERROR)を分ける
  • logger は 1ファイルに集約して import する
Java の log4j に近い感覚で使える代表例は:
  • winston(最も定番)
  • pino(高速・JSONログ向き)
ここでは 構造が分かりやすい winston を使います。

20-2. 導入(winston を入れる)

npm install winston

20-3. logger を1箇所に定義する(log4j的な中心)

console.log を直接使わず、
必ずこの logger を通すのが運用ルールです。
// lib/logger.ts
import winston from "winston";
import path from "path";
import fs from "fs";

// logs ディレクトリを作成
const logDir = path.join(process.cwd(), "logs");
if (!fs.existsSync(logDir)) {
  fs.mkdirSync(logDir);
}

export const logger = winston.createLogger({
  level: "info",
  format: winston.format.combine(
    winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
    winston.format.printf(({ timestamp, level, message, ...meta }) => {
      const metaStr = Object.keys(meta).length
        ? JSON.stringify(meta)
        : "";
      return `[${timestamp}] ${level.toUpperCase()} ${message} ${metaStr}`;
    })
  ),
  transports: [
    // コンソール出力
    new winston.transports.Console(),

    // ファイル出力(INFO以上)
    new winston.transports.File({
      filename: path.join(logDir, "app.log"),
    }),

    // エラー専用ファイル
    new winston.transports.File({
      level: "error",
      filename: path.join(logDir, "error.log"),
    }),
  ],
});
これで:
  • コンソールに即時表示
  • logs/app.log に全ログ
  • logs/error.log にエラーのみ
が同時に出力されます。

20-4. Route Handler での使い方(最重要)

API は「入口」なので、必ず logger を使います
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { logger } from "@/lib/logger";
import { createUser } from "@/server/users/repository";

export async function POST(req: Request) {
  logger.info("POST /api/users start");

  try {
    const body = await req.json();
    logger.info("POST /api/users request", body);

    const user = await createUser(body);

    logger.info("POST /api/users success", { userId: user.id });

    return NextResponse.json(user, { status: 201 });
  } catch (err) {
    logger.error("POST /api/users failed", { error: err });

    return NextResponse.json(
      { message: "内部エラーが発生しました" },
      { status: 500 }
    );
  }
}

20-5. DB層での使い方(例外だけ)

DB層は 成功ログを書きません
「失敗したときだけ」出すのが業務向きです。
// server/users/repository.ts
import { logger } from "@/lib/logger";
import { db } from "./db";

export async function createUser(input: { name: string; email: string }) {
  try {
    return await db.user.create({ data: input });
  } catch (err) {
    logger.error("DB createUser error", { input, error: err });
    throw err;
  }
}

20-6. React(画面)側はどうするか

ブラウザ側では ファイル出力はできません
そのため React 側は:
  • ユーザー向けメッセージ表示
  • 必要なら API に「操作ログ」を送る
に留めます。
// app/users/page.tsx
async function handleSubmit() {
  try {
    await fetch("/api/users", {
      method: "POST",
      body: JSON.stringify(form),
    });
  } catch {
    // UI 表示用(logger は使わない)
    setError("通信に失敗しました");
  }
}

20-7. log4j との対応関係

log4j winston
Logger createLogger
Appender Transport
Level level
PatternLayout format.printf

20-8. この章の結論(運用視点)

  • console.log は使わない
  • logger は 1箇所に集約
  • API 入口で必ずログを書く
  • ファイル+コンソールの両方に出す
  • log4j の感覚で運用できる

21. テスト(壊れにくくするための“最小セット”)

この章は「テスト技法のカタログ」ではありません。
React → API → 業務ロジックの流れの中で、
どこを・どの粒度で・何からテストするかを、コードで固定します。

21-1. 結論(最初はここだけで十分)

  • 画面(React)の見た目は 最初はテストしない
  • 入力バリデーション業務ロジックだけをテストする
  • DBや外部APIは モック or 切り離し
  • 「落ちてはいけない処理」だけを守る
最初から E2E や画面テストをやると、
テストが壊れやすくなり、結局回らなくなります。

21-2. テスト対象の切り分け(実務目線)

テストするか 理由
React UI △(後回し) 変更が多く壊れやすい
Route Handler 入力と戻り値が安定
業務ロジック 最重要・壊れると事故
DBアクセス 最初はモックで代替

21-3. テスト環境の準備(vitest)

Next.js + TypeScript では vitest が最小構成で扱いやすいです。
npm install -D vitest
// package.json(抜粋)
{
  "scripts": {
    "test": "vitest"
  }
}

21-4. 入力バリデーションのテスト(例)

「変な入力を弾けるか」は、
UI よりも server 側でテストします。
// server/users/validation.ts
export function validateUser(input: { name?: string; email?: string }) {
  if (!input.name || input.name.trim() === "") {
    return "名前は必須です";
  }
  if (!input.email || !input.email.includes("@")) {
    return "メールアドレスが不正です";
  }
  return null;
}
// server/users/validation.test.ts
import { describe, it, expect } from "vitest";
import { validateUser } from "./validation";

describe("validateUser", () => {
  it("正常な入力は null を返す", () => {
    expect(validateUser({ name: "太郎", email: "a@test.com" })).toBeNull();
  });

  it("名前が空ならエラー", () => {
    expect(validateUser({ name: "", email: "a@test.com" }))
      .toBe("名前は必須です");
  });

  it("メールが不正ならエラー", () => {
    expect(validateUser({ name: "太郎", email: "xxx" }))
      .toBe("メールアドレスが不正です");
  });
});
ポイント:
  • UI を介さず 関数単体でテスト
  • 分岐がそのままテストケースになる

21-5. 業務ロジックのテスト(DBを切り離す)

業務ロジックは DBを直接触らせない形にしておくと、
テストが簡単になります。
// server/users/service.ts
export async function registerUser(
  input: { name: string; email: string },
  repo: { existsByEmail: (email: string) => Promise<boolean> }
) {
  if (await repo.existsByEmail(input.email)) {
    throw new Error("duplicate email");
  }
  return { id: 1, ...input };
}
// server/users/service.test.ts
import { describe, it, expect } from "vitest";
import { registerUser } from "./service";

describe("registerUser", () => {
  it("重複メールは例外", async () => {
    const repo = {
      existsByEmail: async () => true,
    };
    await expect(
      registerUser({ name: "太郎", email: "a@test.com" }, repo)
    ).rejects.toThrow("duplicate email");
  });

  it("正常ならユーザーを返す", async () => {
    const repo = {
      existsByEmail: async () => false,
    };
    const user = await registerUser(
      { name: "太郎", email: "a@test.com" },
      repo
    );
    expect(user.name).toBe("太郎");
  });
});
DB を使わずに 業務ルールだけを守れるのが重要です。

21-6. Route Handler を軽くテストする考え方

Route Handler 自体は、
  • 入力を読む
  • サービスを呼ぶ
  • レスポンスを返す
だけにしておくと、テスト不要 or 最小になります。
// app/api/users/route.ts(イメージ)
POST:
  1. JSONを読む
  2. validate
  3. serviceを呼ぶ
  4. 結果を返す
実務では「service がテストされていれば安心」という判断をよくします。

21-7. この章の結論(実務ルール)

  • 最初は バリデーション + 業務ロジックだけテスト
  • UIテストは後回しでよい
  • DBは切り離してテスト
  • 「壊れると困る所」だけ守る

補足:Next.js では「どうやってモックに入れ替えるのか」

Next.js に「専用のモック機構」があるわけではありません。
テスト時に依存を差し替えるという、Node.js / TypeScript の基本手法を使います。
ここでは 実務で破綻しない 3 つの方法を、難易度順に説明します。

結論(先に全体像)

  1. 依存性注入(DI):関数引数で差し替える(最推奨)
  2. モジュールモック:import を丸ごと置き換える
  3. 環境変数で分岐:本番/テストで実装を切り替える
「Next.js だから特別な書き方がある」と考えると混乱します。
server 側は普通の Node.jsとして扱うのが正解です。

① 依存性注入(DI)で入れ替える(最も安全・実務向き)

「使うものを引数でもらう」形にすると、テストで簡単に差し替えられます。
DB・外部API・メール送信など、副作用のあるものは必ず DIにします。

Next.jsでのDI(依存性注入)入門:0から「なぜ必要で、どう書くか」

あなたが今つまずいているのは「DIという言葉」ではなく、
Next.jsのコードをどう切ると、テストや運用が楽になるのかという設計の話です。
Next.jsだから特別なDI機構があるわけではなく、サーバー側は普通のNode.jsとして考えます。

1. DIとは何か(1文)

DI(依存性注入)=「中で new しない。外から渡す」
何を渡すのか? → DBや外部APIなどの「依存(Dependency)」です。
それを関数の引数・コンストラクタ引数として 外から注入(Inject)します。

2. なぜDIが必要か(Next.jsで起きる“実害”)

DIを知らないと、Next.jsでありがちな「壊れやすい」書き方になります。
典型例を見てください。

2-1. DIしない例(中で直接DBを触る)

// server/users/service.ts(悪い例:依存が固定)
import { db } from "@/server/db/sqlite";

export async function registerUser(input: { name: string; email: string }) {
  // ここで直接DBを触っている
  const exists = db.prepare("SELECT 1 FROM users WHERE email = ?").get(input.email);
  if (exists) throw new Error("duplicate");

  db.prepare("INSERT INTO users (name, email) VALUES (?, ?)").run(input.name, input.email);
  return { ok: true };
}
この書き方の問題:
  • テストするときに 本物DBが必要になる
  • テストのたびにDB状態を作る必要がある(遅い・面倒)
  • CI環境で壊れやすい
  • 例外パターン(重複など)を作るのに準備が大変

3. DIの考え方(役割分担を固定する)

Next.jsの実務では、次の3つに分けるとDIが自然に入ります。
① Route Handler(app/api/**/route.ts)
   - HTTPの入口
   - reqを読む / statusを返す
   - serviceを呼ぶだけ

② Service(server/**/service.ts)
   - 業務ルールの本体(ここが一番大事)
   - DBや外部APIは “直接触らない”
   - 必要なもの(repo等)を引数でもらう = DI

③ Repository(server/**/repo.ts)
   - DBアクセスの実装だけ
   - SQL/ORMはここに閉じる
DIは「テクニック」ではなく、役割分担の結果として自然に入ります。

4. DIの最小形(関数引数で渡す:Next.jsで一番よく使う)

Next.jsではクラスDIコンテナ(Springみたいなもの)を無理に入れる必要はありません。
まずは 関数に repo を渡すだけで十分です。

4-1. 型(契約)を作る:repoは「できること」だけ定義

// server/users/types.ts
export type UserRepo = {
  existsByEmail(email: string): Promise<boolean>;
  create(input: { name: string; email: string }): Promise<{ id: number; name: string; email: string }>;
};

4-2. serviceはrepoを受け取る(DI)

// server/users/service.ts
import type { UserRepo } from "./types";

export async function registerUser(
  input: { name: string; email: string },
  repo: UserRepo
) {
  // ここでは「業務ルール」だけを書く(DBの具体は知らない)
  if (await repo.existsByEmail(input.email)) {
    throw new Error("duplicate email");
  }
  return repo.create(input);
}

4-3. repoの「本物実装」(DBアクセスはここだけ)

// server/users/repository.ts
import { db } from "@/server/db/sqlite";
import type { UserRepo } from "./types";

export const userRepository: UserRepo = {
  async existsByEmail(email) {
    const row = db.prepare("SELECT 1 FROM users WHERE email = ?").get(email);
    return !!row;
  },
  async create(input) {
    const r = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)").run(input.name, input.email);
    return db.prepare("SELECT id, name, email FROM users WHERE id = ?").get(r.lastInsertRowid) as any;
  },
};

4-4. Route Handlerは「本物repo」を渡すだけ

// app/api/users/route.ts
import { NextResponse } from "next/server";
import { registerUser } from "@/server/users/service";
import { userRepository } from "@/server/users/repository";

export async function POST(req: Request) {
  const body = await req.json();

  try {
    const user = await registerUser(
      { name: body.name, email: body.email },
      userRepository // ← DI:本物を注入
    );
    return NextResponse.json(user, { status: 201 });
  } catch (e: any) {
    if (String(e.message).includes("duplicate")) {
      return NextResponse.json({ message: "duplicate" }, { status: 409 });
    }
    return NextResponse.json({ message: "server error" }, { status: 500 });
  }
}
これで service は DB を知らないので、テストが一気に楽になります。

5. 「差し替え」が何を意味するか(モックの正体)

モックとは「偽物のrepo」です。
本物DBの代わりに、同じ型(契約)を満たす別実装を渡します。

5-1. テスト用のモックrepo(DBなし)

// server/users/service.test.ts
import { describe, it, expect } from "vitest";
import { registerUser } from "./service";
import type { UserRepo } from "./types";

describe("registerUser", () => {
  it("重複メールなら例外", async () => {
    const mockRepo: UserRepo = {
      existsByEmail: async () => true,
      create: async () => { throw new Error("should not call"); },
    };

    await expect(
      registerUser({ name: "A", email: "a@test.com" }, mockRepo)
    ).rejects.toThrow("duplicate email");
  });

  it("重複しないなら作成される", async () => {
    const mockRepo: UserRepo = {
      existsByEmail: async () => false,
      create: async (input) => ({ id: 1, ...input }),
    };

    const user = await registerUser({ name: "A", email: "a@test.com" }, mockRepo);
    expect(user.id).toBe(1);
  });
});
テスト時:
  • DB不要
  • 速い
  • 重複ケースなどを簡単に再現できる

6. Next.jsでDIが効く“具体例”(副作用=外に逃がす)

DIの対象は「副作用」です。副作用とは:
  • DB
  • 外部API(REST)
  • メール送信
  • ファイル書き込み
  • 時刻(now)
  • 乱数(token生成)
これらを service の中で直接使うとテスト不能になります。

例:時刻をDIする(テストが安定する)

// server/time/types.ts
export type Clock = { now: () => Date };

// server/time/clock.ts(本物)
export const systemClock: Clock = { now: () => new Date() };

// server/users/service.ts(DI)
export async function registerUserWithTime(input: any, deps: { clock: Clock; repo: any }) {
  const createdAt = deps.clock.now(); // ここが差し替え可能
  return deps.repo.create({ ...input, createdAt });
}
テストでは clock を固定できます(毎回同じDateにできる)。

7. まとめ:Next.jsでDIを“底から”理解する

  • DIはNext.jsの機能ではなく、設計手法
  • DIの核心は 「中でnewしない。外から渡す」
  • Route Handler は入口、service は業務本体、repo はDB実装
  • service は DB/外部API を直接触らない(副作用を外へ)
  • テストでは repo をモックに差し替えるだけで動く

業務ロジック(本体)

// server/users/service.ts
type UserRepo = {
  existsByEmail(email: string): Promise<boolean>;
  create(input: { name: string; email: string }): Promise<any>;
};

export async function registerUser(
  input: { name: string; email: string },
  repo: UserRepo
) {
  if (await repo.existsByEmail(input.email)) {
    throw new Error("duplicate email");
  }
  return repo.create(input);
}

本番で使う実装

// server/users/repository.ts
export const userRepository = {
  async existsByEmail(email: string) {
    // DBアクセス
    return false;
  },
  async create(input: { name: string; email: string }) {
    return { id: 1, ...input };
  },
};

Route Handler(本番)

// app/api/users/route.ts
import { registerUser } from "@/server/users/service";
import { userRepository } from "@/server/users/repository";

export async function POST(req: Request) {
  const body = await req.json();
  const user = await registerUser(body, userRepository);
  return Response.json(user, { status: 201 });
}

テストではモックを渡すだけ

// server/users/service.test.ts
import { describe, it, expect } from "vitest";
import { registerUser } from "./service";

describe("registerUser", () => {
  it("重複メールは例外", async () => {
    const mockRepo = {
      existsByEmail: async () => true,
      create: async () => { throw new Error("should not call"); },
    };

    await expect(
      registerUser({ name: "A", email: "a@test.com" }, mockRepo)
    ).rejects.toThrow("duplicate email");
  });
});
これが Next.js 実務で一番壊れにくいやり方です。

② モジュールモック(import を丸ごと差し替える)

DI が難しい場合、vitest の module mockで差し替えます。
ただし構造が複雑になるため、乱用は非推奨です。
// server/users/service.ts
import { userRepository } from "./repository";

export async function registerUser(input: any) {
  if (await userRepository.existsByEmail(input.email)) {
    throw new Error("duplicate");
  }
  return userRepository.create(input);
}
// server/users/service.test.ts
import { describe, it, expect, vi } from "vitest";

// repository をモックに置き換える
vi.mock("./repository", () => ({
  userRepository: {
    existsByEmail: vi.fn(),
    create: vi.fn(),
  },
}));

import { userRepository } from "./repository";
import { registerUser } from "./service";

describe("registerUser with module mock", () => {
  it("duplicate email", async () => {
    (userRepository.existsByEmail as any).mockResolvedValue(true);

    await expect(
      registerUser({ email: "a@test.com" })
    ).rejects.toThrow();
  });
});
import 順・依存関係に引きずられやすく、
大規模になると破綻しやすいのが弱点です。

③ 環境変数で実装を切り替える(最終手段)

テスト専用の実装を使いたい場合に限り使います。
ロジック内で分岐しすぎると読めなくなるので注意。
// server/users/repository.ts
export const userRepository =
  process.env.NODE_ENV === "test"
    ? {
        existsByEmail: async () => false,
        create: async (i: any) => ({ id: 999, ...i }),
      }
    : {
        existsByEmail: async (email: string) => {
          // 本番DB
          return false;
        },
        create: async (i: any) => ({ id: 1, ...i }),
      };
この方法は テストが増えるほど地獄になります。
原則は DI。

まとめ(Next.js における正解)

  • Next.js でも 普通の Node.js のテスト手法を使う
  • 依存性注入(DI)が最優先
  • module mock は補助
  • 環境変数分岐は最終手段

22. パフォーマンス設計(RSCで「送るJSを最小化する」)

この章は「速くなります」という説明ではありません。
なぜ Next.js が速くなりやすいのかどう書くと遅くなるのかを、
React Server Components(RSC)を軸にして整理します。

22-1. まず結論(業務で守るべきルール)

  • 基本は Server Component(=ブラウザにJSを送らない)
  • 「操作が必要な所」だけ Client Component にする
  • "use client"最小範囲に閉じ込める
  • DB取得・集計・整形は 必ずServer側
「とりあえず全部 use client」は、
Next.js の強みを捨てているのと同じです。

22-2. RSC(React Server Components)とは何か

RSCとは、サーバーでだけ実行され、ブラウザにJSを送らないReactコンポーネントです。
Next.js(App Router)では、何も書かなければ Server Componentになります。
種類 どこで実行 ブラウザにJS
Server Component サーバー 送られない
Client Component ブラウザ 送られる
Server Component = HTMLを生成するための部品
Client Component = 画面操作のための部品

22-3. 何が「遅くなる原因」か

遅くなる最大の原因は 不要なClient Componentです。

悪い例:最初から Client Component

// app/users/page.tsx
"use client";

import { useEffect, useState } from "react";

export default function UsersPage() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("/api/users")
      .then(r => r.json())
      .then(setUsers);
  }, []);

  return <pre>{JSON.stringify(users)}</pre>;
}
問題点:
  • 初期表示が遅い(JSロード+fetch待ち)
  • ブラウザに無駄なJSを送っている
  • SEOにも不利

22-4. 正しい形:Server Componentで取得・描画

データ取得は Server Component に寄せます。

型は repository から export する(API / Service / Page で共通化する)

業務アプリでは 同じ「データの形」を、
API・Service・Page(React画面)で 共有できるかどうかが保守性を大きく左右します。

Next.js + TypeScript では、型を server 配下で定義し export するのが基本形です。

1. 型定義は「責務の近く」に置く

User の構造(id / name など)は、
「どこで使われるか」ではなく 「何を表しているか」で決めます。

ユーザーという業務概念なら、server/users 配下に型を置きます。
// server/users/types.ts
export type User = {
  id: number;
  name: string;
};
結論:
いいえ。「すべての export type を1ファイルに書く」わけではありません。
このファイルには 「users という業務ドメインに属する型だけ」を書きます。

まず大前提として、TypeScriptの type
「責務(意味)」で分けるのが実務での正解です。

型は「使う場面ごと」に分ける(DB用 / 入力用 / 表示用)

ここで言う 「責務で分ける」とは、難しい意味ではありません。

「その型は、どの場面で使うデータなのか」をはっきり分ける、という意味です。

たとえば同じ「ユーザー」でも、
  • DBに保存するとき
  • 画面から入力されて送られてくるとき
  • 画面に表示するとき
では、必要な項目も、持ってはいけない項目も違います

その違いをあいまいにせず、
「使う場面ごとに、別の型として定義する」
それをここでは「責務で分ける」と言っています。
ここで言う「型を分ける」とは、
同じ「ユーザー」でも、使う場面が違えばデータの形が違うという事実を、
そのままコードに反映することです。


1. この types.ts の役割

// server/users/types.ts
export type User = {
  id: number;
  name: string;
};
このファイルが表しているのは:
  • 「User とは何か」
  • ユーザーという業務データの形
つまり、
  • users ドメイン専用の型定義ファイル
  • 他のドメイン(orders / products など)は含めない

2. 「全部まとめる」書き方がダメな理由

仮に、こんな書き方をしたとします。
// ❌ 悪い例:巨大な共通 types.ts
export type User = { ... };
export type Order = { ... };
export type Product = { ... };
export type AuthUser = { ... };
export type ApiError = { ... };
export type Pagination = { ... };
この構成の問題点:
  • ファイルが肥大化する
  • どの型がどの機能用か分からない
  • 影響範囲が読めない
  • 変更が怖くなる
👉 結果、誰も触りたがらないファイルになります。

3. 正しい分け方(業務ドメイン単位)

型は 「業務ドメイン(機能)」ごとに分けます。
server/
  users/
    types.ts        ← User / UserRepo / UserInput など
    repository.ts
    service.ts

  orders/
    types.ts        ← Order / OrderItem など
    repository.ts
    service.ts
✔ users に関係する型は users/types.ts
✔ orders に関係する型は orders/types.ts
✔ ドメインをまたがない

4. 1ファイルに複数の type を書くのはOK?

OKです。ただし「同じ意味の塊」だけ。
// server/users/types.ts
export type User = {
  id: number;
  name: string;
};

export type CreateUserInput = {
  name: string;
  email: string;
};

export type UserRepo = {
  existsByEmail(email: string): Promise<boolean>;
  create(input: CreateUserInput): Promise<User>;
};
このファイルの共通点:
  • 全部「users」という業務に関係している
  • User を中心にした型
  • repo / input / output が一貫している

5. 実務での判断基準(これだけ覚えればOK)

  • 「この型は何の業務?」と聞いて即答できるか
  • 答えが同じなら同じ types.ts に置いてOK
  • 迷ったら ドメイン単位で分ける
  • 共通化は後からでいい(最初は分ける)

まとめ
  • 1ファイルに「全部の型」は書かない
  • users / orders など 業務単位で分ける
  • 同じ意味の型なら1ファイルに複数あってOK
  • types.ts は「仕様書」に近い存在

2. repository はその型を使ってデータを返す

repository は「DBから何が返るか」を保証する層です。
そのため、戻り値の型として User[] を明示します。
// server/users/repository.ts
import type { User } from "./types";

export async function getUsers(): Promise<User[]> {
  // DBアクセス(例)
  return [
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" }
  ];
}
ここで Promise<User[]> と書いているため、
呼び出し側では 自動的に型が伝播します。

3. Page(Server Component)では型を「知っている」状態になる

Page 側では getUsers() の戻り値から型推論されますが、
明示的に型を使いたい場合は 同じ型を import できます。
// app/users/page.tsx
import { getUsers } from "@/server/users/repository";
import type { User } from "@/server/users/types";

export default async function UsersPage() {
  const users: User[] = await getUsers();

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}
この時点で:
  • usersUser[]
  • u.idnumber
  • u.namestring
型として保証されています。

4. なぜ「型を共有する」のが重要か

型を共有すると、次のことが起きます。
  • フィールド追加・変更時に 壊れる場所が即分かる
  • API / Service / Page の 認識ズレが起きない
  • コメントよりも 型が仕様書になる

5. 実務ルール(これだけ覚えればOK)

  • 業務データの型は server 配下で定義
  • repository / service / page で 同じ型を import
  • APIレスポンスと画面表示の ズレを型で防ぐ
  • 「DTOを別で作りすぎない」
👉 この構成にすると、
API・Service・Page が 1つの型定義を中心に回るため、
修正に強い Next.js アプリになります。
// app/users/page.tsx(Server Component)
import { getUsers } from "@/server/users/repository";

export default async function UsersPage() {
  const users = await getUsers(); // DB直接OK

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

@/ とは何か(ルート基準import:相対パスが壊れる例つき)

@/ は「プロジェクトのルート(baseUrl)からのパス」を表すエイリアスです。
目的は 1つ:ファイル移動や構成変更で import が壊れないようにすること。

※ OSのルート(/)ではありません。
@/tsconfig.json で定義した project root を指します。

1. どこで定義される?(tsconfig.json / jsconfig.json)

Next.js が自動で用意するのではなく、TypeScript の設定です。
典型的には次のように定義します。
// tsconfig.json(または jsconfig.json)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}
これにより、@/server/users/repository
./server/users/repository(= プロジェクト直下から見たパス)に解決されます。

2. 「今いる場所基準」= 相対パス(./ ../)

相対パスは 「このファイルの場所」 を基準にします。
つまり import は、ファイルの位置に依存します。
// 例:app/users/page.tsx から server/users/repository.ts を読む
import { getUsers } from "../../server/users/repository";

3. 「ルート基準」= @/(壊れない)

@/ は 「プロジェクトルート基準」なので、ファイルの位置に依存しません。
import の意味が 常に一定になります。
// 例:どの場所からでも同じ
import { getUsers } from "@/server/users/repository";

4. 相対パスが壊れる“具体例”(ここが重要)

例として、次のように ページファイルを1階層深い場所へ移動したとします。
これが実務でよく起きる「構成整理」「グルーピング」「ルート設計変更」です。
(移動前)
my-app/
  app/
    users/
      page.tsx
  server/
    users/
      repository.ts

(移動後:1階層深くした)
my-app/
  app/
    (admin)/
      users/
        page.tsx
  server/
    users/
      repository.ts

4-1. 相対パスで書いていた場合(壊れる)

移動前は正しかった相対パスが、移動後は 階層がズレて壊れます
// 移動前:OK
// app/users/page.tsx から見て server は ../../
import { getUsers } from "../../server/users/repository";

// 移動後:NG(同じ記述のままだと壊れる)
// app/(admin)/users/page.tsx から見て server は ../../../ になる
import { getUsers } from "../../server/users/repository"; // ❌ 参照先がズレる
import { getUsers } from "../../../server/users/repository"; // ✅ 正しい相対パス

移動後に直すなら、こう変えないといけません:
// 移動後:修正版
import { getUsers } from "../../../server/users/repository"; // ✅

4-2. @/で書いていた場合(壊れない)

@/ はルート基準なので、ファイルをどこに動かしても import が変わりません
// 移動前も移動後も同じ(修正不要)
import { getUsers } from "@/server/users/repository"; // ✅

5. 実務ルール(迷ったらこれ)

  • 層をまたぐ import(server / lib / shared)@/ を使う
  • 同じフォルダ内の部品./UserForm, ./page.module.css) → 相対パスでOK
  • ../../.. が出てきたら、@/ へ寄せる
これで:
  • 初回表示が速い
  • JSは送られない
  • HTMLがそのまま返る

22-5. Client Componentは「必要な所だけ」

フォーム送信・ボタン操作など、
ユーザー操作がある所だけ Client Component にします。

Client Component(最小)

// app/users/UserForm.tsx
"use client";

import { useState } from "react";

export default function UserForm() {
  const [name, setName] = useState("");

  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button>送信</button>
    </form>
  );
}

Server Componentから組み込む

// app/users/page.tsx
import UserForm from "./UserForm";

export default async function UsersPage() {
  const users = await getUsers();

  return (
    <>
      <UserForm />
      <ul>...</ul>
    </>
  );
}
Client Component は 子として差し込むのが基本です。

22-6. RSCがもたらす「実務的な速さ」

観点 効果
初期表示 HTMLが即返る
JSサイズ 最小限
SEO HTMLに内容がある
保守性 責務分離が明確

22-7. この章の結論(設計ルール)

  • 何も書かなければ Server Component
  • "use client" は最後の手段
  • データ取得は Server 側
  • 操作UIだけ Client 側
  • RSCは「速度」と「構造」を同時に良くする

23. デプロイ判断(SSRが必要か?静的で足りるか?)

この章で決めたいことは、とてもシンプルです。

「この画面は、サーバーが無いと作れないか?」
それとも
「HTMLを置くだけで成立するか?」

Next.js はどちらも選べるため、
画面ごとに最適なデプロイ方法を選ぶことが重要になります。

1. 静的サイト(SSG / Export)で足りるケース

SSG(Static Site Generation)とは

ビルド時に、あらかじめHTMLを生成しておく方式です。
アクセスが来たときは、作成済みのHTMLファイルをそのまま返すだけなので高速です。

特徴:
  • ビルド時にHTMLが確定する
  • リクエスト時にサーバー処理は行われない
  • 誰が見ても同じ内容のページ向き
「ビルド時にHTMLを生成する」とは、何をどう書けばそうなるのか

Next.js(App Router)では、
特別な設定をしなくても、条件を満たせば自動で「ビルド時HTML生成」になります。

ポイントは次の3つです。
① page.tsx を Server Component のまま書く

"use client" を書かずに page.tsx を作ると、
そのページは Server Component になります。

Server Component は、
  • ビルド時に実行できる
  • HTMLをサーバー側で生成できる
という性質を持っています。
// app/about/page.tsx
export default function AboutPage() {
  return (
    <main>
      <h1>このサイトについて</h1>
      <p>これは静的に生成されるページです</p>
    </main>
  );
}
このように、
・DBアクセス
・ユーザー依存の処理
が無いページは、
ビルド時にHTMLが生成されます。

② データ取得が「固定」または「ビルド時に確定」している

次のような場合、Next.jsは
「このページは毎回同じ内容だ」と判断できます。
  • データを取得していない
  • ローカルの定数を使っている
  • ビルド時に取得可能なデータだけを使っている
// app/news/page.tsx
const NEWS = [
  { id: 1, title: "お知らせ1" },
  { id: 2, title: "お知らせ2" }
];

export default function NewsPage() {
  return (
    <ul>
      {NEWS.map(n => (
        <li key={n.id}>{n.title}</li>
      ))}
    </ul>
  );
}
この場合も、
HTMLはビルド時に1回だけ生成されます。

③ 「動的に変わる」と明示しない

Next.jsでは、次の指定があると
「このページは毎回サーバーで作る必要がある」と判断されます。
  • cookies() を使う
  • headers() を使う
  • ログイン情報に依存する
  • export const dynamic = "force-dynamic" を書く
逆に言えば、
これらを使わなければ、静的にできる可能性が高いということです。

④ ビルドすると何が起きるのか

npm run build を実行すると、Next.js は:
  1. page.tsx を解析する
  2. 「静的でいけるか?」を判断する
  3. 可能なら HTML をその場で生成する
その結果、
すでに完成したHTMLファイルが出力されます。

重要なまとめ(初心者向けに一言で)

  • 特別なAPIを呼ばなくてもよい
  • 普通に page.tsx を書くだけでよい
  • 「毎回変わらないページ」なら自動で静的になる
👉 Next.js が「これは静的で大丈夫」と判断すると、ビルド時にHTMLを作ってくれる
Export(Static Export)とは

Next.jsプロジェクトを「純粋な静的HTML/CSS/JSの集合」として書き出す方法です。
Node.jsサーバーを一切使わず、
普通の静的ホスティングにそのまま置ける形になります。

特徴:
  • SSR・API・DBアクセスは使えない
  • 生成物はHTMLファイルのみ
  • Cloudflare Pages / GitHub Pages 等に置ける
違いを一言で言うと

  • SSG:静的HTMLを「どう作るか」の話
  • Export:静的HTMLを「どう配布するか」の話
次の条件に当てはまる画面は、
ビルド時にHTMLを作ってしまえば十分です。
  • 誰が見ても内容が同じ
  • ログイン不要
  • 個人ごとのデータが無い
  • 更新頻度が低い
// 例:静的で成立するページ
- トップページ
- 会社紹介
- サービス説明
- ポートフォリオ
- FAQ
この場合は:
  • SSG(Static Site Generation)
  • next export
  • Cloudflare Pages / GitHub Pages
など、サーバー不要の構成が選べます。

2. サーバー(SSR / RSC)が必要なケース

次の条件が1つでもあれば、
サーバー処理が必要になります。
  • ログインしているユーザーごとに表示が変わる
  • URLは同じだが、中身は人によって違う
  • DBから最新データを読む必要がある
  • 権限チェックが必要
// 例:サーバーが必要な画面
- マイページ
- 管理画面
- 受注一覧
- 個人設定画面
この場合:
  • SSR(Server Side Rendering)
  • RSC(Server Component)
  • API / DB 接続
を使う前提になります。
結論から先に

「I/Oを一切行わなければ自動的に Export になる」わけではありません。
Export は Next.js に対して明示的に「静的書き出しをする」と指定します。

1. Export は「ビルド結果の形式」を選ぶ話

Next.js には、ビルド結果として次の2系統があります。
  • Node.js サーバーとして動かす(SSR / RSC を含む)
  • 静的ファイルだけを書き出す(Export)
Export は、
「このプロジェクトはサーバーを持たない」
ビルド時に宣言する方式です。

2. どうやって Export を指定するのか

App Router の場合、
next.config.js に次を設定します。
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export"
};

module.exports = nextConfig;
この設定があると:
  • npm run build 時に
  • サーバーを前提とした機能を禁止し
  • 純粋な静的ファイルだけを出力
という動作になります。

3. Export できる/できないの判断基準

Export では、
「I/Oをしていないか」ではなく
「実行時にサーバーが必要か」で判断されます。
Export できるもの
  • ビルド時に確定するページ(SSG)
  • 固定データのみを使うページ
  • Client Component だけで完結する画面
Export できないもの
  • SSR(リクエストごとにHTMLを作る)
  • Route Handler(app/api/**)
  • DBアクセス
  • cookies / headers / 認証

4. 「I/Oしていなければ Export できる」という誤解

たとえば、次のコードは I/O をしていませんが、
// app/page.tsx
export default function Page() {
  return <h1>Hello</h1>;
}
next.config.js に output: "export" を書かない限り
ビルド結果は 「Node.js サーバー前提」になります。
つまり:
  • コードの内容だけでは Export にはならない
  • ビルド設定で明示する必要がある

5. Export 時に Next.js が行うチェック

Export を指定すると、Next.js はビルド中に:
  1. 各ページを解析
  2. サーバー機能を使っていないか検査
  3. 使っていたら ビルドエラー
という厳しめのチェックを行います。

6. まとめ(質問への直接回答)

  • ❌「I/Oをしなければ自動で Export」ではない
  • output: "export"明示的に指定する
  • Export は「静的ファイルとして配る」という宣言
  • サーバーが必要な機能は最初から使えない
👉 Export は「コードの書き方」ではなく「ビルドの選択」です。

3. Next.js の強み:混在できる

Next.js の最大の強みは、
同じプロジェクト内で「静的」と「サーバー」を混在できる点です。
// 同一プロジェクト内の例
/app/page.tsx          → 静的(SSG)
/app/about/page.tsx    → 静的(SSG)
/app/login/page.tsx    → サーバー(SSR)
/app/users/page.tsx    → サーバー(RSC + DB)
「全部SSR」「全部静的」ではなく、
画面ごとに最適解を選ぶのが実務です。

4. 判断基準(これだけ見ればOK)

新しい画面を作るときは、次の質問をします。
  • この画面は、誰が見ても同じ内容か?
  • ログインしていない人にも見せるか?
  • DBの最新状態が必要か?
すべて YES → 静的で作る
1つでも NO → サーバーが必要

5. 業務アプリでの現実的な結論

業務アプリでは、
  • ログイン
  • 個人データ
  • 権限
がほぼ必ずあるため、
完全な静的構成だけで完結することは少ないです。
ただし、
  • ログイン前の説明ページ
  • ヘルプ
  • 利用規約
は静的にすることで、
高速・低コスト・シンプルに保てます。
👉 結論:
「サーバーが必要な画面だけ SSR / RSC」
「それ以外は静的」
これが Next.js での最も現実的なデプロイ判断です。

24. Vercelデプロイ(Next.jsを一番そのまま動かす方法)

この章の結論はとても単純です。

「Next.js を、設定で悩まず・壊さず・最短で動かしたいなら Vercel」

Vercel は Next.js の開発元が提供しているため、
SSR / RSC / API / 画像最適化などを“何も考えず”使えます。

1. Vercel とは何か(何をしてくれるのか)

Vercel は、Next.js プロジェクトを GitHub に置くだけで、
ビルド・デプロイ・サーバー起動まで自動でやってくれるサービスです。
公式サイト: https://vercel.com/
自分でやらなくてよいこと:
  • Node.js サーバーの用意
  • SSR / RSC の設定
  • API ルートの公開
  • HTTPS 証明書
  • ビルド設定の調整

2. デプロイまでの最短手順

手順は本当にこれだけです。
  1. Next.js プロジェクトを GitHub に push
  2. Vercel にログイン
  3. 「Import Project」でリポジトリを選ぶ
  4. Deploy ボタンを押す
特別な設定をしなくても、
Next.js プロジェクトだと自動認識されます。

3. Vercel で「そのまま使える」機能

Vercel を選ぶ最大の理由は、
Next.js の機能制限を気にしなくてよい点です。
そのまま使える代表例:
  • SSR(Server Side Rendering)
  • RSC(Server Components)
  • Route Handler(app/api/**)
  • next/image
  • next/font
  • middleware
  • 環境変数(管理画面から設定)

4. 静的サイトとの違い

Vercel は「静的サイト専用」ではありません。
サーバーが必要な画面と、静的な画面を混在させられます。
// 同じプロジェクト内
/app/page.tsx           → 静的
/app/about/page.tsx     → 静的
/app/login/page.tsx     → SSR
/app/users/page.tsx     → RSC + DB
/app/api/users/route.ts → API
「まず全部 Vercel に載せる」→「必要なら後で分ける」
という進め方ができます。

5. 注意点(ここだけ知っておく)

便利な反面、次の点は理解しておく必要があります。
  • 無料枠には実行時間・回数の制限がある
  • DB は別サービス(RDS / Supabase 等)が必要
  • ローカルファイル永続保存はできない

6. どういう場合に Vercel を選ぶか

次に当てはまるなら、まず Vercel で問題ありません。
  • Next.js を学習・検証したい
  • ポートフォリオを作りたい
  • SSR / API をすぐ使いたい
  • インフラ設定に時間をかけたくない
👉 結論:
Vercel は「Next.js を正しく・速く・安全に動かすための基準環境」。
迷ったら、まずここに置くのが最短ルートです。
2026年時点で確認できる Vercel(Hobby/無料枠)の制限の概要は次の通りです。

📌 1. 実行時間(サーバーレス関数/Function Duration)

無料プランの Serverless Functions(API など)のデフォルト最大実行時間は 10秒。

これは 1 リクエストでの処理時間の上限 で、10 秒を超えると関数が終了(タイムアウト)します。

Free でも Fluid Compute を使えば最大 60 秒まで伸ばせる場合がある、という文言も公式で案内されていますが、これは設定により変わる可能性がある点として説明されています。

📌 2. 実行量(関数呼び出し・CPU時間など)
Serverless Functions(API / Edge Functions)

慣例として 月 1,000,000 回まで関数の呼び出しが無料枠内というデータあり。

実行時間総量(CPU/メモリ)

無料枠では(Fluid Compute 指標表として)
👉 Active CPU の使用量が 4 CPU-hours / 月 まで含まれる。
(Active CPU =関数が実際に CPU を使っている時間)

統合実行量

無料枠で 100 GB-hours の関数実行総量まで(旧仕様/ Fluid Compute 以前の基準)。

📌 3. 他の無料枠の制限(参考)

これらも実行量に関係する制限です:

項目 無料枠 (Hobby)
デプロイ(ビルド)の最大時間 45 分 / 回(Build Step)
ログの保存 1 時間(Runtime logs)
月間デプロイ数 約 100 回 / 日目安(サービス提供者仕様)
帯域 約 100GB / 月目安(転送量)
関数呼び出し数 約 1,000,000 / 月目安(非公式まとめ)

※ これらは厳密に公開値でなく「仕様まとめ・リスト情報」をもとにした数字です。公式は日々更新されるため、Vercel 管理画面の Usage / Limits ページで最新値を確認するのが安全です。

25. Node / Docker デプロイ(自前サーバーで動かす)

この章は「クラウドに任せず、自分のサーバーで Next.js を動かす場合の話です。
社内サーバー、VPS、オンプレ環境などで運用するケースを想定します。

25-1. Node.js での基本的なビルドと起動

Next.js は、ビルド → Node.js プロセスとして起動という流れで動きます。
まずは一番シンプルな形を理解します。
# ビルド(HTML生成・最適化)
npm run build

# 本番起動(Node.js サーバーとして起動)
npm run start
この方式の特徴:
  • SSR / RSC / API / middleware がすべて使える
  • Node.js が常駐プロセスとして動く
  • PM2 / systemd などでプロセス管理するのが一般的

25-2. 自前サーバー運用で必要になるもの

Vercel と違い、自前サーバーでは次を 自分で用意・管理します。
  • Node.js のバージョン管理
  • プロセスの自動再起動
  • HTTPS(Nginx / Apache / 証明書)
  • ログの保存・ローテーション
  • OSアップデート対応
👉 「自由度は高いが、運用コストも増える」のが自前サーバーです。

25-3. Docker を使う理由(なぜ業務で強いか)

Docker を使う最大の理由は、
「このアプリは、この環境で動く」と丸ごと固定できることです。
Docker を使わない場合に起きがちな問題:
  • Node.js のバージョン差で動かない
  • ローカルでは動くが、本番で動かない
  • 人によって環境が違う
Docker を使うと:
  • Node.js バージョンを固定できる
  • 依存関係をすべてイメージに含められる
  • 本番・検証・ローカルの差が消える

25-4. Docker での基本的な考え方

Docker では、Next.js アプリを 1つのコンテナとして扱います。
【Dockerの役割】
- OS の差を吸収
- Node.js のバージョン固定
- npm install / build / start を再現可能にする
実務では:
  • アプリ用コンテナ(Next.js)
  • DB コンテナ(PostgreSQL など)
  • リバースプロキシ(Nginx)
を分ける構成が一般的です。

25-5. どういう場合に自前サーバーを選ぶか

次に当てはまる場合、自前サーバー+Docker が現実的です。
  • 社内ネットワーク内だけで使う
  • 外部クラウドを使えない制約がある
  • 長時間処理・常駐処理が必要
  • インフラを自分で管理できる
👉 自由度・制御性を取るなら自前
👉 手軽さ・速さを取るなら Vercel
という住み分けになります。
node:20-slim を使う Next.js(本番起動)Dockerfile 例
(SSR / RSC / Route Handler を使う “Node.js サーバー起動” 前提)

ポイント:
  • multi-stage(ビルド用と実行用を分けて、最終イメージを小さくする)
  • npm ci(lockfile どおりに高速・再現性高く入れる)
  • 実行は node_modules を含めた状態next start
# syntax=docker/dockerfile:1

############################
# 1) deps: 依存関係のインストール
############################
FROM node:20-slim AS deps
WORKDIR /app

# package-lock.json がある前提(npm)
COPY package.json package-lock.json ./
RUN npm ci


############################
# 2) builder: ビルド(.next を作る)
############################
FROM node:20-slim AS builder
WORKDIR /app

# node_modules を引き継ぐ
COPY --from=deps /app/node_modules ./node_modules

# アプリのソースをコピー
COPY . .

# 本番ビルド
ENV NODE_ENV=production
RUN npm run build


############################
# 3) runner: 本番実行(next start)
############################
FROM node:20-slim AS runner
WORKDIR /app

ENV NODE_ENV=production

# セキュリティ的に root で動かさない
RUN useradd -m -u 1001 nextjs
USER nextjs

# 実行に必要なものだけコピー
COPY --from=builder --chown=nextjs:nextjs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nextjs /app/.next ./.next
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
COPY --from=builder --chown=nextjs:nextjs /app/node_modules ./node_modules

# Next.js のデフォルトは 3000
EXPOSE 3000

# 起動(package.json の start: "next start" を想定)
CMD ["npm", "run", "start"]

使い方(例)
# イメージ作成
docker build -t my-next-app .

# 起動(ホスト:3000 → コンテナ:3000)
docker run --rm -p 3000:3000 my-next-app
Windows 11 + Docker Desktop を使った立ち上げ方法

前提:
  • Windows 11 に Docker Desktop がインストールされている
  • Docker Desktop は WSL2 backend を使用
  • Linux コンテナモードになっている
① Docker Desktop を起動する

スタートメニューから Docker Desktop を起動し、
画面左下が 「Docker Desktop is running」 になるのを待ちます。
② PowerShell(または Windows Terminal)を開く

管理者権限は不要です。
通常の PowerShell / Windows Terminal で問題ありません。
③ Next.js プロジェクトのルートへ移動

cd C:\work\my-next-app
このフォルダに次が存在していることを確認します:
  • Dockerfile
  • package.json
  • app / public フォルダ
④ Docker イメージをビルド

docker build -t my-next-app .
初回は npm install / build が走るため、
数分かかる場合があります(正常です)。
⑤ コンテナを起動

docker run --rm -p 3000:3000 my-next-app
このコマンドで:
  • コンテナ内の 3000 番ポートが
  • Windows 側の 3000 番ポートに公開されます
⑥ ブラウザで確認

ブラウザを開き、次にアクセスします:
http://localhost:3000
Next.js の画面が表示されれば成功です。
補足:よくあるトラブル

  • ポート 3000 が他のアプリで使用中 → 別ポートに変更する
  • Docker Desktop が起動していない → コマンドが失敗する
  • WSL2 が無効 → Docker Desktop が起動しない
👉 まとめ
Windows 11 + Docker Desktop では、
「Docker Desktop 起動 → PowerShell → docker build / docker run」
これだけで Next.js を立ち上げられます。

補足(よくある落とし穴)
  • Export(output: "export")構成の場合は next start は不要です(静的ホスティングに置く)
  • 環境変数は docker run -e KEY=VALUE か compose で渡します
  • DB 接続をするなら、コンテナ内に DB を同梱せず、別コンテナ/外部DBにします
結論

この Dockerfile では、
Next.js プロジェクトのルート配下が、コンテナ内の作業ディレクトリ
になるように明示的に設定されています。

決定打になっている行

WORKDIR /app
この 1 行で:
  • コンテナ内の作業ディレクトリが /app に設定される
  • 以降の COPY / RUN / CMD はすべて /app 基準

ホストとコンテナの対応関係

例:
ホスト(Windows 11)
C:\work\my-next-app\   ← プロジェクトルート
  ├─ app\
  ├─ public\
  ├─ package.json
  ├─ Dockerfile

コンテナ内
/app                  ← WORKDIR
  ├─ app/
  ├─ public/
  ├─ package.json
COPY . . が意味するのは:
「ホストのプロジェクトルート配下を、
コンテナ内の /app に丸ごとコピーする」

なぜプロジェクトルートを WORKDIR にするのか

理由は実務的です。
  • npm / next のコマンドはプロジェクトルート前提
  • package.json が常に見える
  • パスがブレない
👉 「ローカルでの作業感覚」と「Docker 内」を一致させるため

もし別の場所にしたらどうなるか

例えば:
WORKDIR /srv/app
にすると、
プロジェクトは /srv/app 配下に展開されます。
どこでも動きますが、
慣例として /app が多いだけです。

まとめ

  • WORKDIR が「プロジェクトルートの置き場所」を決める
  • この Dockerfile では /app をルートにしている
  • ホスト側の構成と感覚を揃えるための設計

26. Cloudflare での公開(Next.js は「そのまま」は動かない)

この章で一番伝えたい結論はこれです。

Cloudflare は「何でも動く場所」ではなく、
Next.js の使い方によって可・不可がはっきり分かれる
という点です。

26-1. Cloudflare は何を提供しているのか

Cloudflare で Web アプリを公開する場合、主に次の選択肢があります。
  • Cloudflare Pages(静的サイト + Edge Functions)
  • Cloudflare Workers(Edge 実行環境)
どちらも Node.js サーバーを常駐させる仕組みではありません

26-2. そのまま使えるケース(問題なし)

次のような構成なら、Cloudflare Pages で問題なく公開できます。
  • SSG(静的生成)のみ
  • output: "export" を使った静的書き出し
  • React SPA としての利用
  • 外部 API(別サーバー)を呼ぶだけ
👉 ポートフォリオ・LP・ツール系はこの構成が最適です。

26-3. 制限が出るケース(要注意)

次の機能は、Cloudflare Pages ではそのまま使えません
  • Node.js 常駐サーバー前提の SSR
  • Route Handler(app/api/**)
  • DB に直接つなぐ処理
  • fs(ファイルシステム)アクセス
理由は単純で、
Cloudflare は Edge 実行(短時間・軽量)を前提にしているためです。

26-4. 「Cloudflare で SSR したい」場合

Cloudflare でも SSR 的なことは可能ですが、
通常の Next.js SSR とは別物と考えてください。
選択肢:
  • Workers 用にビルドされた Next.js(制約あり)
  • API / DB は別サーバーに逃がす
  • Cloudflare はフロント配信専用に割り切る
👉 実務では「Cloudflare + 別バックエンド」の分離構成が多いです。

26-5. 実務向けの判断基準

やりたいこと おすすめ
ポートフォリオ / 静的LP Cloudflare Pages
SPA + 外部API Cloudflare Pages
SSR / DB / 認証 Vercel / Nodeサーバー

まとめ

  • Cloudflare は「高速配信」に特化
  • Next.js の全機能は前提にしていない
  • 静的で割り切れるなら最強
  • 業務系・SSR中心なら別の選択肢を取る
👉 Cloudflare は万能ではない。用途を選べば非常に強い

27. よくある詰まり集(復帰チェック)

27-1. 画面が真っ白

まず DevTools(コンソール)でエラーを見ます。

27-2. useState が使えない

"use client" が無い可能性。

27-3. fetch が更新されない

キャッシュが効いている可能性。

27-4. 環境変数が読めない

NEXT_PUBLIC の有無/再起動の有無を確認。

28. 付録:最小テンプレ(よく使う雛形)

28-1. データ取得ページ(Server Component)

export default async function Page(){
  const res = await fetch("https://example.com/api");
  const data = await res.json();
  return <pre>{JSON.stringify(data,null,2)}</pre>
}

28-2. API(Route Handler)

export async function GET(){
  return Response.json({ ok:true });
}

29. 付録:実務チェックリスト(ここだけで最低限いける)

  • URL設計(一覧/詳細/編集)
  • layout.tsx で共通UI
  • Server/Client境界を守る("use client")
  • 入力検証(zod等)
  • 業務エラーを分類(NotFound/Conflict)
  • ログを入れる

30. 実務サンプル(業務に必要な画面・イメージ・Reactコード)

ここからは「業務でよくある画面」を、画面イメージ(ワイヤーフレーム)Reactコード で説明します。
Next.jsに移植する場合でも、まずは UIの形(一覧/登録/編集/詳細)エラーの扱い を理解すると速いです。

30-1. 画面遷移(URLでページを切り替える)

React Router の例ですが、Next.jsでも「URLで画面を切替える」思想は同じです。
import { NavLink, Outlet } from "react-router-dom";

/**
 * Outlet:
 *   ルーティングで「ここにページ本体を差し込む」穴。
 * NavLink:
 *   現在のURLに応じて active 表示できるリンク。
 */
export default function AppLayout() {
  return (
    <div className="app">
      <aside className="sidebar">
        <div className="brand">業務サンプル</div>

        <nav className="nav">
          <NavLink to="/" end className={({ isActive }) => isActive ? "navItem active" : "navItem"}>ダッシュボード</NavLink>
          <NavLink to="/users" className={({ isActive }) => isActive ? "navItem active" : "navItem"}>ユーザー管理</NavLink>
          <NavLink to="/sales" className={({ isActive }) => isActive ? "navItem active" : "navItem"}>売上管理</NavLink>
          <NavLink to="/inventory" className={({ isActive }) => isActive ? "navItem active" : "navItem"}>在庫管理</NavLink>
          <NavLink to="/shipping" className={({ isActive }) => isActive ? "navItem active" : "navItem"}>出荷ステータス</NavLink>
        </nav>

        <div className="sidebarHint">
          <div className="pill">React Router</div>
          <div className="muted">URLで画面を切り替えます</div>
        </div>
      </aside>

      <main className="main">
        <header className="topbar">
          <div className="title">画面遷移の多い業務システム例</div>
          <div className="muted">※ データはモック(擬似)です</div>
        </header>

        <div className="content">
          <Outlet />
        </div>
      </main>
    </div>
  );
}
import { Navigate, Route, Routes } from "react-router-dom";
import AppLayout from "./layouts/AppLayout";
import Dashboard from "./pages/Dashboard";

import UsersListPage from "./pages/users/UsersListPage";
import UserNewPage from "./pages/users/UserNewPage";
import UserEditPage from "./pages/users/UserEditPage";

import SalesListPage from "./pages/sales/SalesListPage";
import SalesNewPage from "./pages/sales/SalesNewPage";
import SalesEditPage from "./pages/sales/SalesEditPage";

import InventoryListPage from "./pages/inventory/InventoryListPage";
import InventoryAdjustPage from "./pages/inventory/InventoryAdjustPage";

import ShippingListPage from "./pages/shipping/ShippingListPage";
import ShippingDetailPage from "./pages/shipping/ShippingDetailPage";

export default function App() {
  return (
    <Routes>
      {/* 業務システムは「共通レイアウト + 中身だけ差し替え」が基本 */}
      <Route element={<AppLayout />}>
        <Route path="/" element={<Dashboard />} />

        {/* ユーザー管理(ページネーション/登録/編集) */}
        <Route path="/users" element={<UsersListPage />} />
        <Route path="/users/new" element={<UserNewPage />} />
        <Route path="/users/:id/edit" element={<UserEditPage />} />

        {/* 売上管理 */}
        <Route path="/sales" element={<SalesListPage />} />
        <Route path="/sales/new" element={<SalesNewPage />} />
        <Route path="/sales/:id/edit" element={<SalesEditPage />} />

        {/* 在庫管理 */}
        <Route path="/inventory" element={<InventoryListPage />} />
        <Route path="/inventory/adjust" element={<InventoryAdjustPage />} />

        {/* 出荷ステータス */}
        <Route path="/shipping" element={<ShippingListPage />} />
        <Route path="/shipping/:id" element={<ShippingDetailPage />} />

        {/* それ以外はダッシュボードへ */}
        <Route path="*" element={<Navigate to="/" replace />} />
      </Route>
    </Routes>
  );
}

30-2. ユーザー一覧(ページネーション)/ 登録 / 編集(バリデーション・業務エラー)

30-2-1. 画面イメージ

左メニュー(共通) ユーザー一覧 ページ: 1 / 6
ダッシュボード
ユーザー管理
売上管理
在庫管理
出荷ステータス
ID氏名メール権限操作
u_xxxxUser 1user1@example.comstaff
u_yyyyUser 2user2@example.comadmin

30-2-2. Reactコード(一覧:ページ番号をURLへ)

ページ番号を URL に入れると「戻る/進む/共有」が簡単になります。
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import DataTable from "../../components/DataTable";
import Pagination from "../../components/Pagination";
import { apiListUsers } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import type { User } from "../../domain/types";

const PAGE_SIZE = 10;

export default function UsersListPage() {
  const [sp, setSp] = useSearchParams();
  const page = Math.max(1, Number(sp.get("page") ?? "1"));

  const [rows, setRows] = useState<User[]>([]);
  const [total, setTotal] = useState(0);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    let mounted = true;
    (async () => {
      try {
        setErr(null);
        const data = await apiListUsers(page, PAGE_SIZE);
        if (!mounted) return;
        setRows(data.rows);
        setTotal(data.total);
      } catch (e) {
        const ae = toAppError(e);
        if (mounted) setErr(ae.message);
      }
    })();
    return () => { mounted = false; };
  }, [page]);

  return (
    <div className="card">
      <div className="h">ユーザー一覧(ページネーション)</div>
      <p className="sub">ページ番号は URL(searchParams)に入れると、戻る/進む/共有が楽です。</p>

      <div className="actions">
        <Link className="btn primary" to="/users/new">新規登録</Link>
      </div>

      {err && <div className="bannerErr">{err}</div>}

      <DataTable
        keyOf={(r) => r.id}
        rows={rows}
        columns={[
          { header: "ID", render: (r) => r.id, width: "160px" },
          { header: "氏名", render: (r) => r.name },
          { header: "メール", render: (r) => r.email },
          { header: "権限", render: (r) => r.role, width: "90px" },
          { header: "操作", render: (r) => <Link className="btn" to={`/users/${r.id}/edit`}>編集</Link>, width: "120px" },
        ]}
      />

      <Pagination
        page={page}
        pageSize={PAGE_SIZE}
        total={total}
        onPageChange={(p) => setSp({ page: String(p) })}
      />
    </div>
  );
}

30-2-3. Reactコード(登録:フィールドエラー + 全体エラー)

業務アプリでは「どの項目が間違いか」と「全体として失敗した」を分けると親切です。
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { apiCreateUser } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import { userCreateSchema } from "../../server/validation";
import { zodToFieldErrors } from "../../server/zodHelpers";

export default function UserNewPage() {
  const nav = useNavigate();
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [role, setRole] = useState<"admin" | "staff">("staff");

  const [fieldErr, setFieldErr] = useState<Record<string, string>>({});
  const [bannerErr, setBannerErr] = useState<string | null>(null);
  const [ok, setOk] = useState<string | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setPending(true);
    setOk(null);
    setBannerErr(null);
    setFieldErr({});

    try {
      const input = userCreateSchema.parse({ name, email, role });
      await apiCreateUser(input);
      setOk("登録しました。一覧へ戻ります。");
      setTimeout(() => nav("/users?page=1"), 600);
    } catch (err) {
      if (err instanceof z.ZodError) {
        setFieldErr(zodToFieldErrors(err));
        setBannerErr("入力に誤りがあります。");
      } else {
        const ae = toAppError(err);
        setBannerErr(ae.message);
      }
    } finally {
      setPending(false);
    }
  }

  return (
    <div className="card">
      <div className="h">ユーザー登録</div>
      <p className="sub">フィールドエラー(項目ごと)と全体エラー(バナー)を両方出す。</p>

      {bannerErr && <div className="bannerErr">{bannerErr}</div>}
      {ok && <div className="bannerOk">{ok}</div>}

      <form onSubmit={onSubmit}>
        <div className="field">
          <label>氏名</label>
          <input value={name} onChange={(e) => setName(e.target.value)} />
          {fieldErr["name"] && <div className="err">{fieldErr["name"]}</div>}
        </div>

        <div className="field">
          <label>メール</label>
          <input value={email} onChange={(e) => setEmail(e.target.value)} />
          {fieldErr["email"] && <div className="err">{fieldErr["email"]}</div>}
        </div>

        <div className="field">
          <label>権限</label>
          <select value={role} onChange={(e) => setRole(e.target.value as any)}>
            <option value="staff">staff</option>
            <option value="admin">admin</option>
          </select>
          {fieldErr["role"] && <div className="err">{fieldErr["role"]}</div>}
        </div>

        <div className="actions">
          <button className="btn primary" disabled={pending} type="submit">登録</button>
          <button className="btn" disabled={pending} type="button" onClick={() => nav("/users?page=1")}>戻る</button>
        </div>
      </form>
    </div>
  );
}

30-2-4. Reactコード(編集:NotFound / Conflict)

「存在しない」「メール重複」など、業務の理由で失敗するケースを想定します。
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { z } from "zod";
import { apiGetUser, apiUpdateUser } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import { userUpdateSchema } from "../../server/validation";
import { zodToFieldErrors } from "../../server/zodHelpers";

export default function UserEditPage() {
  const { id } = useParams();
  const nav = useNavigate();

  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [role, setRole] = useState<"admin" | "staff">("staff");

  const [fieldErr, setFieldErr] = useState<Record<string, string>>({});
  const [bannerErr, setBannerErr] = useState<string | null>(null);
  const [ok, setOk] = useState<string | null>(null);
  const [pending, setPending] = useState(false);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let mounted = true;
    (async () => {
      try {
        setBannerErr(null);
        if (!id) throw new Error("id required");
        const u = await apiGetUser(id);
        if (!mounted) return;
        setName(u.name);
        setEmail(u.email);
        setRole(u.role);
      } catch (e) {
        const ae = toAppError(e);
        if (mounted) setBannerErr(ae.message);
      } finally {
        if (mounted) setLoading(false);
      }
    })();
    return () => { mounted = false; };
  }, [id]);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setPending(true);
    setOk(null);
    setBannerErr(null);
    setFieldErr({});

    try {
      const input = userUpdateSchema.parse({ id, name, email, role });
      await apiUpdateUser(input);
      setOk("更新しました。");
    } catch (err) {
      if (err instanceof z.ZodError) {
        setFieldErr(zodToFieldErrors(err));
        setBannerErr("入力に誤りがあります。");
      } else {
        const ae = toAppError(err);
        setBannerErr(ae.message);
      }
    } finally {
      setPending(false);
    }
  }

  if (loading) return <div className="card"><div className="muted">読み込み中...</div></div>;

  return (
    <div className="card">
      <div className="h">ユーザー編集</div>
      <p className="sub">NotFound / Conflict(メール重複)などの業務エラーを扱う。</p>

      {bannerErr && <div className="bannerErr">{bannerErr}</div>}
      {ok && <div className="bannerOk">{ok}</div>}

      <form onSubmit={onSubmit}>
        <div className="field">
          <label>氏名</label>
          <input value={name} onChange={(e) => setName(e.target.value)} />
          {fieldErr["name"] && <div className="err">{fieldErr["name"]}</div>}
        </div>

        <div className="field">
          <label>メール</label>
          <input value={email} onChange={(e) => setEmail(e.target.value)} />
          {fieldErr["email"] && <div className="err">{fieldErr["email"]}</div>}
        </div>

        <div className="field">
          <label>権限</label>
          <select value={role} onChange={(e) => setRole(e.target.value as any)}>
            <option value="staff">staff</option>
            <option value="admin">admin</option>
          </select>
          {fieldErr["role"] && <div className="err">{fieldErr["role"]}</div>}
        </div>

        <div className="actions">
          <button className="btn primary" disabled={pending} type="submit">更新</button>
          <button className="btn" disabled={pending} type="button" onClick={() => nav("/users?page=1")}>一覧へ</button>
        </div>
      </form>
    </div>
  );
}

30-3. 売上管理(一覧/登録/簡易編集)

30-3-1. 画面イメージ

売上一覧新規登録
日付取引先金額操作
2026-02-17A商事12,000

30-3-2. Reactコード(一覧)

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import DataTable from "../../components/DataTable";
import { apiListSales } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import type { Sale } from "../../domain/types";

export default function SalesListPage() {
  const [rows, setRows] = useState<Sale[]>([]);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    let mounted = true;
    (async () => {
      try {
        setErr(null);
        const data = await apiListSales();
        if (mounted) setRows(data);
      } catch (e) {
        const ae = toAppError(e);
        if (mounted) setErr(ae.message);
      }
    })();
    return () => { mounted = false; };
  }, []);

  return (
    <div className="card">
      <div className="h">売上一覧</div>
      <p className="sub">まずは「一覧 + 画面遷移」の形を作ります。</p>

      <div className="actions">
        <Link className="btn primary" to="/sales/new">新規登録</Link>
      </div>

      {err && <div className="bannerErr">{err}</div>}

      <DataTable
        keyOf={(r) => r.id}
        rows={rows}
        columns={[
          { header: "日付", render: (r) => r.date, width: "140px" },
          { header: "取引先", render: (r) => r.customer },
          { header: "金額", render: (r) => r.amount.toLocaleString() + " 円", width: "140px" },
          { header: "操作", render: (r) => <Link className="btn" to={`/sales/${r.id}/edit`}>編集</Link>, width: "120px" },
        ]}
      />
    </div>
  );
}

30-3-3. Reactコード(登録:バリデーション付き)

import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { apiCreateSale } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import { salesCreateSchema } from "../../server/validation";
import { zodToFieldErrors } from "../../server/zodHelpers";

export default function SalesNewPage() {
  const nav = useNavigate();
  const [fieldErr, setFieldErr] = useState<Record<string, string>>({});
  const [bannerErr, setBannerErr] = useState<string | null>(null);
  const [ok, setOk] = useState<string | null>(null);
  const [pending, setPending] = useState(false);

  const [date, setDate] = useState("2026-02-17");
  const [customer, setCustomer] = useState("");
  const [amount, setAmount] = useState("0");
  const [note, setNote] = useState("");

  const parsedAmount = useMemo(() => Number(amount), [amount]);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setPending(true);
    setOk(null);
    setBannerErr(null);
    setFieldErr({});

    try {
      const input = salesCreateSchema.parse({
        date,
        customer,
        amount: parsedAmount,
        note: note.trim() ? note : undefined,
      });

      await apiCreateSale(input);
      setOk("登録しました。一覧へ戻ります。");
      setTimeout(() => nav("/sales"), 600);
    } catch (err) {
      if (err instanceof z.ZodError) {
        setFieldErr(zodToFieldErrors(err));
        setBannerErr("入力に誤りがあります。");
      } else {
        const ae = toAppError(err);
        setBannerErr(ae.message);
      }
    } finally {
      setPending(false);
    }
  }

  return (
    <div className="card">
      <div className="h">売上登録</div>
      <p className="sub">バリデーション(入力検証)とエラーハンドリングの基本形。</p>

      {bannerErr && <div className="bannerErr">{bannerErr}</div>}
      {ok && <div className="bannerOk">{ok}</div>}

      <form onSubmit={onSubmit}>
        <div className="field">
          <label>日付</label>
          <input value={date} onChange={(e) => setDate(e.target.value)} />
          {fieldErr["date"] && <div className="err">{fieldErr["date"]}</div>}
        </div>

        <div className="field">
          <label>取引先</label>
          <input value={customer} onChange={(e) => setCustomer(e.target.value)} placeholder="例:A商事" />
          {fieldErr["customer"] && <div className="err">{fieldErr["customer"]}</div>}
        </div>

        <div className="field">
          <label>金額(整数)</label>
          <input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="numeric" />
          {fieldErr["amount"] && <div className="err">{fieldErr["amount"]}</div>}
        </div>

        <div className="field">
          <label>備考(任意)</label>
          <input value={note} onChange={(e) => setNote(e.target.value)} />
          {fieldErr["note"] && <div className="err">{fieldErr["note"]}</div>}
        </div>

        <div className="actions">
          <button className="btn primary" disabled={pending} type="submit">登録</button>
          <button className="btn" disabled={pending} type="button" onClick={() => nav("/sales")}>戻る</button>
        </div>
      </form>
    </div>
  );
}

30-4. 在庫管理(一覧/調整:マイナス不可などの業務ルール)

30-4-1. 画面イメージ

在庫一覧調整業務ルール: マイナス不可
SKU商品名在庫数更新
SKU-0001商品 1162026-02-17 10:00

30-4-2. Reactコード(一覧)

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import DataTable from "../../components/DataTable";
import { apiListStock } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import type { StockItem } from "../../domain/types";

export default function InventoryListPage() {
  const [rows, setRows] = useState<StockItem[]>([]);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    let mounted = true;
    (async () => {
      try {
        setErr(null);
        const data = await apiListStock();
        if (mounted) setRows(data);
      } catch (e) {
        const ae = toAppError(e);
        if (mounted) setErr(ae.message);
      }
    })();
    return () => { mounted = false; };
  }, []);

  return (
    <div className="card">
      <div className="h">在庫一覧</div>
      <p className="sub">在庫調整で「業務ルール(マイナス不可)」を扱います。</p>

      <div className="actions">
        <Link className="btn primary" to="/inventory/adjust">在庫調整へ</Link>
      </div>

      {err && <div className="bannerErr">{err}</div>}

      <DataTable
        keyOf={(r) => r.id}
        rows={rows}
        columns={[
          { header: "SKU", render: (r) => r.sku, width: "140px" },
          { header: "商品名", render: (r) => r.name },
          { header: "在庫数", render: (r) => r.quantity, width: "120px" },
          { header: "更新", render: (r) => r.updatedAt.slice(0, 19).replace("T", " "), width: "170px" },
        ]}
      />
    </div>
  );
}

30-4-3. Reactコード(調整:Conflictで弾く)

import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { apiAdjustStock, apiListStock } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import { stockAdjustSchema } from "../../server/validation";
import { zodToFieldErrors } from "../../server/zodHelpers";
import type { StockItem } from "../../domain/types";

export default function InventoryAdjustPage() {
  const nav = useNavigate();
  const [stocks, setStocks] = useState<StockItem[]>([]);
  const [fieldErr, setFieldErr] = useState<Record<string, string>>({});
  const [bannerErr, setBannerErr] = useState<string | null>(null);
  const [ok, setOk] = useState<string | null>(null);
  const [pending, setPending] = useState(false);

  const [sku, setSku] = useState("");
  const [delta, setDelta] = useState("0");
  const [reason, setReason] = useState("");

  const parsedDelta = useMemo(() => Number(delta), [delta]);

  useEffect(() => {
    (async () => {
      try {
        const data = await apiListStock();
        setStocks(data);
        if (data[0]) setSku(data[0].sku);
      } catch {}
    })();
  }, []);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setPending(true);
    setOk(null);
    setBannerErr(null);
    setFieldErr({});

    try {
      const input = stockAdjustSchema.parse({ sku, delta: parsedDelta, reason });
      const updated = await apiAdjustStock(input.sku, input.delta, input.reason);
      setOk(`調整しました。SKU=${updated.sku} 在庫=${updated.quantity}`);
    } catch (err) {
      if (err instanceof z.ZodError) {
        setFieldErr(zodToFieldErrors(err));
        setBannerErr("入力に誤りがあります。");
      } else {
        const ae = toAppError(err);
        setBannerErr(ae.message);
      }
    } finally {
      setPending(false);
    }
  }

  return (
    <div className="card">
      <div className="h">在庫調整</div>
      <p className="sub">業務ルール例:在庫がマイナスになる調整は <b>Conflict</b> として拒否。</p>

      {bannerErr && <div className="bannerErr">{bannerErr}</div>}
      {ok && <div className="bannerOk">{ok}</div>}

      <form onSubmit={onSubmit}>
        <div className="field">
          <label>SKU</label>
          <select value={sku} onChange={(e) => setSku(e.target.value)}>
            {stocks.map((s) => (
              <option key={s.id} value={s.sku}>{s.sku} - {s.name}</option>
            ))}
          </select>
          {fieldErr["sku"] && <div className="err">{fieldErr["sku"]}</div>}
        </div>

        <div className="field">
          <label>増減数(例:-2, +5)</label>
          <input value={delta} onChange={(e) => setDelta(e.target.value)} inputMode="numeric" />
          {fieldErr["delta"] && <div className="err">{fieldErr["delta"]}</div>}
        </div>

        <div className="field">
          <label>理由</label>
          <input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="例:棚卸差異" />
          {fieldErr["reason"] && <div className="err">{fieldErr["reason"]}</div>}
        </div>

        <div className="actions">
          <button className="btn primary" disabled={pending} type="submit">実行</button>
          <button className="btn" disabled={pending} type="button" onClick={() => nav("/inventory")}>戻る</button>
        </div>
      </form>
    </div>
  );
}

30-5. 出荷ステータス(一覧/詳細)

30-5-1. 画面イメージ

出荷一覧詳細
注文番号ステータス配送会社追跡番号操作
ORD-9001出荷済yamatoTRK-xxxx

30-5-2. Reactコード(一覧)

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import DataTable from "../../components/DataTable";
import { apiListShipments } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import type { Shipment } from "../../domain/types";

function statusLabel(s: Shipment["status"]) {
  switch (s) {
    case "pending": return "未処理";
    case "picked": return "ピッキング済";
    case "shipped": return "出荷済";
    case "delivered": return "配達完了";
    case "hold": return "保留";
  }
}

export default function ShippingListPage() {
  const [rows, setRows] = useState<Shipment[]>([]);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    let mounted = true;
    (async () => {
      try {
        setErr(null);
        const data = await apiListShipments();
        if (mounted) setRows(data);
      } catch (e) {
        const ae = toAppError(e);
        if (mounted) setErr(ae.message);
      }
    })();
    return () => { mounted = false; };
  }, []);

  return (
    <div className="card">
      <div className="h">出荷ステータス一覧</div>
      <p className="sub">一覧→詳細の典型。ステータス表示と追跡番号を扱います。</p>

      {err && <div className="bannerErr">{err}</div>}

      <DataTable
        keyOf={(r) => r.id}
        rows={rows}
        columns={[
          { header: "注文番号", render: (r) => r.orderNo, width: "140px" },
          { header: "ステータス", render: (r) => statusLabel(r.status), width: "140px" },
          { header: "配送会社", render: (r) => r.carrier, width: "120px" },
          { header: "追跡番号", render: (r) => r.trackingNo ?? "-", width: "180px" },
          { header: "操作", render: (r) => <Link className="btn" to={`/shipping/${r.id}`}>詳細</Link>, width: "120px" },
        ]}
      />
    </div>
  );
}

30-5-3. Reactコード(詳細:戻る + 例外表示)

import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiGetShipment } from "../../api/mockApi";
import { toAppError } from "../../server/errors";
import type { Shipment } from "../../domain/types";

export default function ShippingDetailPage() {
  const { id } = useParams();
  const nav = useNavigate();
  const [row, setRow] = useState<Shipment | null>(null);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    let mounted = true;
    (async () => {
      try {
        setErr(null);
        if (!id) throw new Error("id required");
        const data = await apiGetShipment(id);
        if (mounted) setRow(data);
      } catch (e) {
        const ae = toAppError(e);
        if (mounted) setErr(ae.message);
      }
    })();
    return () => { mounted = false; };
  }, [id]);

  return (
    <div className="card">
      <div className="h">出荷詳細</div>
      <p className="sub">詳細画面は「戻る」と「例外時の表示」が重要です。</p>

      {err && <div className="bannerErr">{err}</div>}

      {row ? (
        <>
          <table className="table">
            <tbody>
              <tr><th>注文番号</th><td>{row.orderNo}</td></tr>
              <tr><th>ステータス</th><td>{row.status}</td></tr>
              <tr><th>配送会社</th><td>{row.carrier}</td></tr>
              <tr><th>追跡番号</th><td>{row.trackingNo ?? "-"}</td></tr>
              <tr><th>更新</th><td>{row.updatedAt.slice(0, 19).replace("T", " ")}</td></tr>
            </tbody>
          </table>

          <div className="actions">
            <button className="btn" onClick={() => nav("/shipping")}>一覧へ戻る</button>
          </div>
        </>
      ) : (
        <div className="muted">読み込み中...</div>
      )}
    </div>
  );
}

30-6. バリデーションと業務エラー(共通部品)

30-6-1. AppError(業務用の例外型)

export type ErrorCode =
  | "VALIDATION"
  | "NOT_FOUND"
  | "CONFLICT"
  | "FORBIDDEN"
  | "UNAUTHORIZED"
  | "INTERNAL";

/**
 * AppError:
 *  UI と API が「原因を判定できる」ように、code を必ず持ちます。
 */
export class AppError extends Error {
  constructor(
    public code: ErrorCode,
    message: string,
    public detail?: string,
    public cause?: unknown
  ) {
    super(message);
  }
}

export function toAppError(e: unknown): AppError {
  if (e instanceof AppError) return e;
  const msg = e instanceof Error ? e.message : String(e);
  return new AppError("INTERNAL", "予期しないエラーが発生しました", msg, e);
}

30-6-2. zod(入力検証)

import { z } from "zod";

export const userCreateSchema = z.object({
  name: z.string().min(1, "氏名は必須です").max(50, "氏名は50文字まで"),
  email: z.string().min(1, "メールは必須です").email("メール形式が正しくありません"),
  role: z.enum(["admin", "staff"]),
});

export const userUpdateSchema = userCreateSchema.extend({
  id: z.string().min(1),
});

export const salesCreateSchema = z.object({
  date: z.string().min(1, "日付は必須です"),
  customer: z.string().min(1, "取引先は必須です").max(80),
  amount: z.number().int("金額は整数").nonnegative("金額は0以上"),
  note: z.string().max(200).optional(),
});

export const stockAdjustSchema = z.object({
  sku: z.string().min(1, "SKUは必須です"),
  delta: z.number().int("増減数は整数です"),
  reason: z.string().min(1, "理由は必須です").max(120),
});
VALIDATION(入力ミス)と CONFLICT(業務ルール違反: 重複/マイナス不可など)と NOT_FOUND(存在しない)を分けると、UIが作りやすくなります。
ここで示したコードは「Reactでの一般形」です。Next.jsに移植する場合は、データ取得・保存部分を Route Handler / Server Actions に置き換え、画面(page.tsx)は “呼び出す側” にします。

サンプル:ログイン時に UserSetting(JSON) を読み込み、以後「全処理」から参照できるようにする(React → API → Service → Repo → DB → DTO/Entity → React)

画面イメージ(HTTPリクエストを出す画面/リダイレクト後の画面)

① ログイン画面(HTTPリクエストを出す画面)

ログイン画面イメージ 成功したら:/dashboard にリダイレクト
② ダッシュボード(リダイレクト後の画面)

ダッシュボード画面イメージ

仕様のポイント(このサンプルが満たすこと)

  • UserSetting テーブル:user_idsettings(JSON) だけ
  • ログイン成功時に DB から settings JSON を読む
  • settings が無ければデフォルト(例:paging.per_page = 20)
  • 次リクエストでも残すために セッション(cookie) に保存
  • 各リクエスト開始時に セッション → UserSettingsStore に復元
  • Controller / Service / Repository / View(Server Component) から同じ方法で参照可能
  • DB例外はログに詳細、クライアントには安全なエラー

settings JSON 例(DBに入る値)

{
  "paging": { "per_page": 20 },
  "theme": "light",
  "feature": { "beta": false }
}

プロジェクト構成(App Router / TypeScript)

my-app/
  app/
    login/
      page.tsx
    dashboard/
      page.tsx
    api/
      auth/
        login/
          route.ts
      me/
        settings/
          route.ts
  middleware.ts
  server/
    db/
      sqlite.ts
    auth/
      session.ts
    users/
      entity.ts
      dto.ts
      repository.ts
      service.ts
    userSettings/
      entity.ts
      dto.ts
      repository.ts
      service.ts
      store.ts
      requestContext.ts
    logger/
      logger.ts

DB(SQLite)

テーブルは「例」です。ログイン判定用に users も置いています(実務だと別認証でもOK)。
-- users
CREATE TABLE IF NOT EXISTS users (
  id TEXT PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  name TEXT NOT NULL
);

-- user_settings
CREATE TABLE IF NOT EXISTS user_settings (
  user_id TEXT PRIMARY KEY,
  settings TEXT NOT NULL
);

1) logger(詳細ログはサーバー側だけ)

// server/logger/logger.ts
export const logger = {
  info: (msg: string, meta?: unknown) => console.log("[INFO]", msg, meta ?? ""),
  warn: (msg: string, meta?: unknown) => console.warn("[WARN]", msg, meta ?? ""),
  error: (msg: string, meta?: unknown) => console.error("[ERROR]", msg, meta ?? "")
};

2) DB接続

// server/db/sqlite.ts
import Database from "better-sqlite3";

export const db = new Database("app.db");

3) Entity / DTO(users)

// server/users/entity.ts
export type UserEntity = {
  id: string;
  email: string;
  passwordHash: string;
  name: string;
};
// server/users/dto.ts
export type LoginInputDto = {
  email: string;
  password: string;
};

export type LoginResultDto = {
  userId: string;
  name: string;
};

4) Entity / DTO(user_settings)

// server/userSettings/entity.ts
export type UserSettingsEntity = {
  userId: string;
  settingsJson: string; // DBには JSON文字列として保存
};
// server/userSettings/dto.ts
export type UserSettings = {
  paging: { per_page: number };
  theme: string;
  feature?: { beta?: boolean };
};

export const DEFAULT_SETTINGS: UserSettings = {
  paging: { per_page: 20 },
  theme: "light"
};

5) Repository(DBアクセス:users / user_settings)

// server/users/repository.ts
import { db } from "@/server/db/sqlite";
import type { UserEntity } from "./entity";

export const userRepository = {
  findByEmail(email: string): UserEntity | null {
    const row = db
      .prepare("SELECT id, email, password_hash as passwordHash, name FROM users WHERE email = ?")
      .get(email) as UserEntity | undefined;
    return row ?? null;
  }
};
// server/userSettings/repository.ts
import { db } from "@/server/db/sqlite";
import type { UserSettingsEntity } from "./entity";

export const userSettingsRepository = {
  findByUserId(userId: string): UserSettingsEntity | null {
    const row = db
      .prepare("SELECT user_id as userId, settings as settingsJson FROM user_settings WHERE user_id = ?")
      .get(userId) as UserSettingsEntity | undefined;
    return row ?? null;
  }
};

6) UserSettingsStore(全処理から参照する「設定ストア」)

// server/userSettings/store.ts
type JsonValue = any;

export class UserSettingsStore {
  private settings: Record<string, JsonValue> = {};

  setAll(settingsObj: Record<string, JsonValue>) {
    this.settings = settingsObj ?? {};
  }

  // "paging.per_page" のようなパスで参照
  get(path: string, defaultValue?: any) {
    const parts = path.split(".");
    let cur: any = this.settings;
    for (const p of parts) {
      if (cur == null || typeof cur !== "object" || !(p in cur)) return defaultValue;
      cur = cur[p];
    }
    return cur ?? defaultValue;
  }

  getInt(path: string, defaultValue: number) {
    const v = this.get(path, defaultValue);
    const n = Number(v);
    return Number.isFinite(n) ? Math.trunc(n) : defaultValue;
  }
}

7) リクエスト開始時に「セッション → Store」を復元する仕組み(requestContext)

Next.js/Node は「リクエストごとに別スレッド」ではありません。
だから「どこからでも参照」を実現するには、リクエスト単位のコンテキストが必要です。
ここでは AsyncLocalStorage を使って、同一リクエスト内でどの層からでも Store を取れるようにします。
// server/userSettings/requestContext.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { UserSettingsStore } from "./store";

type Ctx = { store: UserSettingsStore };
const als = new AsyncLocalStorage<Ctx>();

export function runWithStore<T>(store: UserSettingsStore, fn: () => T): T {
  return als.run({ store }, fn);
}

export function getStoreOrThrow(): UserSettingsStore {
  const ctx = als.getStore();
  if (!ctx) throw new Error("UserSettingsStore is not initialized for this request.");
  return ctx.store;
}

8) セッション(cookie)に settings を保存する(ログイン後も維持)

ここは「PHP/Laravel の session と同じ役割」です。
Next.js では cookie-based session ライブラリ(例:iron-session)を使うと近い形になります。
// server/auth/session.ts
import { getIronSession, IronSessionData } from "iron-session";
import { cookies } from "next/headers";

declare module "iron-session" {
  interface IronSessionData {
    userId?: string;
    userName?: string;
    settingsJson?: string; // 次リクエスト用
  }
}

export const sessionOptions = {
  password: process.env.SESSION_PASSWORD!, // 32文字以上推奨
  cookieName: "myapp_session",
  cookieOptions: {
    secure: process.env.NODE_ENV === "production"
  }
};

export async function getSession() {
  return getIronSession<IronSessionData>(cookies(), sessionOptions);
}

9) UserSettingsService(DB→JSON→デフォルト適用)

// server/userSettings/service.ts
import { logger } from "@/server/logger/logger";
import { userSettingsRepository } from "./repository";
import { DEFAULT_SETTINGS, type UserSettings } from "./dto";

export const userSettingsService = {
  loadForUser(userId: string): { settings: UserSettings; settingsJson: string } {
    try {
      const row = userSettingsRepository.findByUserId(userId);
      if (!row) {
        const json = JSON.stringify(DEFAULT_SETTINGS);
        return { settings: DEFAULT_SETTINGS, settingsJson: json };
      }

      // 破損JSONにも備える(業務)
      try {
        const parsed = JSON.parse(row.settingsJson) as UserSettings;
        // 必須のデフォルトだけは保証(不足していたら補う)
        const merged: UserSettings = {
          ...DEFAULT_SETTINGS,
          ...parsed,
          paging: { ...DEFAULT_SETTINGS.paging, ...(parsed.paging ?? {}) }
        };
        return { settings: merged, settingsJson: JSON.stringify(merged) };
      } catch (e) {
        logger.error("User settings JSON is invalid. Using default.", { userId, error: String(e) });
        const json = JSON.stringify(DEFAULT_SETTINGS);
        return { settings: DEFAULT_SETTINGS, settingsJson: json };
      }
    } catch (e) {
      // DB例外は詳細ログ、呼び出し側には安全なエラー
      logger.error("DB error while loading user settings", { userId, error: String(e) });
      throw new Error("SETTINGS_LOAD_FAILED");
    }
  }
};

10) AuthService(ログイン成功時に settings を DB→session に保存)

// server/users/service.ts
import type { LoginInputDto, LoginResultDto } from "./dto";
import { userRepository } from "./repository";
import { userSettingsService } from "@/server/userSettings/service";

// ※ デモ用:実務では bcrypt 等で検証
function verifyPassword(plain: string, hash: string): boolean {
  return plain === hash; // デモ
}

export const authService = {
  login(input: LoginInputDto): { result: LoginResultDto; settingsJson: string } {
    const user = userRepository.findByEmail(input.email);
    if (!user) throw new Error("LOGIN_FAILED");
    if (!verifyPassword(input.password, user.passwordHash)) throw new Error("LOGIN_FAILED");

    const { settingsJson } = userSettingsService.loadForUser(user.id);

    return {
      result: { userId: user.id, name: user.name },
      settingsJson
    };
  }
};

11) Controller(Route Handler:ログインAPI)

// app/api/auth/login/route.ts
import { authService } from "@/server/users/service";
import { getSession } from "@/server/auth/session";
import { logger } from "@/server/logger/logger";

export async function POST(req: Request) {
  try {
    const body = await req.json(); // ReactがJSONで送る
    const { result, settingsJson } = authService.login(body);

    const session = await getSession();
    session.userId = result.userId;
    session.userName = result.name;
    session.settingsJson = settingsJson; // 次リクエストでも維持
    await session.save();

    return Response.json({ ok: true, userId: result.userId, name: result.name });
  } catch (e) {
    const code = String((e as any)?.message ?? e);

    // 詳細はログ、クライアントは安全に
    logger.warn("Login failed", { error: code });

    if (code === "LOGIN_FAILED") {
      return Response.json({ ok: false, message: "メールアドレスまたはパスワードが違います。" }, { status: 401 });
    }
    if (code === "SETTINGS_LOAD_FAILED") {
      return Response.json({ ok: false, message: "設定の読み込みに失敗しました。" }, { status: 500 });
    }
    return Response.json({ ok: false, message: "予期しないエラーが発生しました。" }, { status: 500 });
  }
}

12) middleware(各リクエスト開始時:session → UserSettingsStore に復元)

middleware は「入口で読み、各リクエストで復元」の役を担います。
ただし Next.js の middleware は Edge 実行で制約があるため、
ここでは “サーバー側の処理が始まる直前に初期化する” 方式を採用します。
(= 実際の初期化は「サーバー側で最初に呼ばれる箇所」で行う)
// middleware.ts
import { NextResponse } from "next/server";

export function middleware(req: Request) {
  // ここでは単に通す(Edge制約で session/ALS を触らない)
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"]
};

13) 「各リクエストで必ず最初に呼ぶ」初期化関数(session→store→ALS)

// server/userSettings/initPerRequest.ts
import { getSession } from "@/server/auth/session";
import { UserSettingsStore } from "@/server/userSettings/store";
import { runWithStore } from "@/server/userSettings/requestContext";
import { DEFAULT_SETTINGS } from "@/server/userSettings/dto";

export async function withUserSettingsStore<T>(fn: () => Promise<T>): Promise<T> {
  const session = await getSession();

  const store = new UserSettingsStore();
  if (session.settingsJson) {
    try {
      store.setAll(JSON.parse(session.settingsJson));
    } catch {
      store.setAll(DEFAULT_SETTINGS as any);
    }
  } else {
    store.setAll(DEFAULT_SETTINGS as any);
  }

  return runWithStore(store, fn);
}

14) 「どの層からでも参照」:Controller / Service / Repository / View での使い方

参照API(完成形のイメージ)
getStoreOrThrow().getInt("paging.per_page", 20)
getStoreOrThrow().get("theme", "light")
// app/api/me/settings/route.ts (Controller)
import { withUserSettingsStore } from "@/server/userSettings/initPerRequest";
import { getStoreOrThrow } from "@/server/userSettings/requestContext";

export async function GET() {
  return withUserSettingsStore(async () => {
    const store = getStoreOrThrow();
    return Response.json({
      perPage: store.getInt("paging.per_page", 20),
      theme: store.get("theme", "light")
    });
  });
}
// server/users/someBusinessService.ts (Service 例)
import { getStoreOrThrow } from "@/server/userSettings/requestContext";

export function calcPagingLimit(): number {
  const store = getStoreOrThrow();
  return store.getInt("paging.per_page", 20);
}
// app/dashboard/page.tsx (View = Server Component)
import { withUserSettingsStore } from "@/server/userSettings/initPerRequest";
import { getStoreOrThrow } from "@/server/userSettings/requestContext";
import { getSession } from "@/server/auth/session";

export default async function DashboardPage() {
  return withUserSettingsStore(async () => {
    const session = await getSession();
    const store = getStoreOrThrow();

    const perPage = store.getInt("paging.per_page", 20);
    const theme = store.get("theme", "light");

    return (
      <main>
        <h1>Dashboard</h1>
        <p>Hello, {session.userName ?? "Unknown"}</p>
        <p>Paging per_page: {perPage}</p>
        <p>Theme: {theme}</p>
      </main>
    );
  });
}

15) React(ログイン画面:HTTPリクエスト→成功で redirect)

// app/login/page.tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";

export default function LoginPage() {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setLoading(true);

    try {
      const res = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password })
      });

      const json = await res.json();

      if (!res.ok || !json.ok) {
        setError(json.message ?? "ログインに失敗しました。");
        return;
      }

      // 成功:ダッシュボードへ
      router.replace("/dashboard");
    } catch {
      setError("通信に失敗しました。");
    } finally {
      setLoading(false);
    }
  }

  return (
    <main style={{ maxWidth: 420, margin: "40px auto" }}>
      <h1>ユーザーログイン</h1>

      {error && <p style={{ color: "red" }}>{error}</p>}

      <form onSubmit={onSubmit}>
        <div>
          <label>Email</label><br />
          <input value={email} onChange={(e) => setEmail(e.target.value)} />
        </div>

        <div style={{ marginTop: 12 }}>
          <label>Password</label><br />
          <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
        </div>

        <button style={{ marginTop: 16 }} disabled={loading}>
          {loading ? "ログイン中..." : "ログイン"}
        </button>
      </form>
    </main>
  );
}

使用説明(このサンプルの「流れ」)

  1. Reactログイン画面が POST /api/auth/login を送る(JSON)
  2. Route Handler(Controller)が AuthService を呼ぶ
  3. AuthService が user_settings を読み、無ければデフォルトを採用
  4. 採用した settings JSON を session(cookie) に保存
  5. 以後のリクエスト(dashboard / API)では、最初に withUserSettingsStore() を通す
  6. すると session → UserSettingsStore に復元され、Controller/Service/View から参照できる

HTTP(JSON)

リクエストPOST /api/auth/login
{
  "email": "alice@example.com",
  "password": "password123"
}
成功レスポンス(例)
{
  "ok": true,
  "userId": "u_001",
  "name": "Alice"
}
失敗レスポンス(例)
{
  "ok": false,
  "message": "メールアドレスまたはパスワードが違います。"
}

補足(あなたの「Laravelの完成形」に対応させると)

Laravel の
app(UserSettingsStore::class)->getInt("paging.per_page", 20)
は、このサンプルでは
getStoreOrThrow().getInt("paging.per_page", 20)
に相当します。

※「次リクエストでも維持」は session(cookie) が担います。

ユーザー新規登録(Next.js版:DTO / Entity / Repository / Service / Route Handler / React / Config / i18n)

仕様(Next.js向けに読み替え)

この仕様は Laravel(Blade/Eloquent/session)前提の文章でしたが、Next.js では次のように置き換えます。

  • View(Blade)React(app/register/page.tsx)
  • ControllerRoute Handler(app/api/users/register/route.ts)
  • EloquentRepository(SQL/ORMどちらでも可)(このサンプルは SQLite + SQL)
  • DTO(Blade⇔Controller)DTO(React⇔API JSON)
  • Lang(i18n)辞書(ja/en)をクライアント・サーバで共通利用
  • Configconfig/registration.ts(成功モーダルの自動消滅ON/OFFと秒数)

画面仕様

入力項目:name / email / password

登録成功時
  • 成功モーダル(ダイアログ)表示
  • 数秒後に自動で消える(ON/OFF・秒数は設定ファイルで制御)
  • (このサンプルでは)モーダルが消えたら /login にリダイレクト
登録失敗時(バリデーションエラー)
  • 該当入力欄の近くに赤文字でメッセージ
  • email 重複は email 欄のそばに「すでに登録済みのメールアドレスです」
表示言語
  • 日本語/英語切り替え(このサンプルは ?lang=ja / ?lang=en

バリデーション仕様(業務向け:サーバ側を正、クライアント側は補助)

name
  • 必須(空文字NG)
  • 許可:英小文字 + 数字のみ(正規表現:^[a-z0-9]+$
  • 例:taro01 OK / Taro NG / taro_01 NG
email
  • 必須(空文字NG)
  • email形式
  • 重複NG(論理削除も含めて重複扱い=登録不可)
password
  • 必須(空文字NG)
  • 8文字以上
  • 許可:英数字 + _ , - , @ , # , $ , % , &
  • 正規表現例:^[A-Za-z0-9_\\-@#$%&]+$

登録処理仕様

  • users に INSERT
  • password はハッシュ化して password_hash に保存(生パスワード保存禁止)
  • is_active = truerole = 'user' をデフォルト
  • deleted_at != null のユーザーも重複扱い(事故防止)

例外処理仕様(業務レベル)

  • DB例外などの詳細はサーバ側ログに出す
  • 画面には安全なメッセージ(内部情報を出さない)
  • email重複は「業務例外」として扱い、email欄にピンポイントで返す

注意点(Next.jsでの現実)

  • クライアントのバリデーションは改ざん可能なので、サーバ側が必須
  • 同時登録(レース)対策として、最終的には DBのUNIQUE制約が必要(このサンプルも入れます)
  • 「重複チェック→INSERT」は競合し得るので、INSERT失敗時のハンドリングも入れます
  • i18n は本来 next-intl 等の導入が一般的だが、ここでは外部依存を増やさず 辞書方式で実装します

画面イメージ(SVG:外部ファイルなし)

登録フォーム(HTTPリクエストを出す画面)

ユーザー登録画面イメージ
成功モーダル(自動消滅)

登録成功モーダルイメージ

コード(この機能に必要な全コード)

依存パッケージ(例):
better-sqlite3(SQLite) / bcryptjs(パスワードハッシュ)
※ 本文のコードは TypeScript 前提です。

0) DBスキーマ(SQLite例)

-- users テーブル(論理削除あり)
CREATE TABLE IF NOT EXISTS users (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  password_hash TEXT NOT NULL,
  is_active INTEGER NOT NULL,
  role TEXT NOT NULL,
  deleted_at TEXT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

-- 重要:重複防止(論理削除も含めて重複扱い=UNIQUEは email 全体に貼る)
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email);

1) config(成功モーダルの自動消滅ON/OFFと秒数)

// config/registration.ts
export const registrationConfig = {
  successModal: {
    autoDismiss: true,
    dismissSeconds: 3
  }
} as const;

2) i18n(日本語/英語のメッセージ)

// config/i18n.ts
export type Lang = "ja" | "en";

export const messages = {
  ja: {
    title: "ユーザー新規登録",
    name: "ユーザー名",
    email: "メールアドレス",
    password: "パスワード",
    submit: "登録",
    ok: "OK",
    successTitle: "登録が完了しました",
    successBody: "数秒後に自動で閉じます",
    toLogin: "ログインへ移動します",

    errors: {
      required: "必須です",
      invalidName: "英小文字 + 数字のみで入力してください",
      invalidEmail: "メールアドレスの形式が正しくありません",
      duplicateEmail: "すでに登録済みのメールアドレスです",
      invalidPasswordChars: "使用できる文字は 英数字 と _ , - , @ , # , $ , % , & のみです",
      passwordMin: "8文字以上で入力してください",
      safeGeneric: "登録に失敗しました。時間をおいて再度お試しください。"
    }
  },
  en: {
    title: "User Registration",
    name: "Name",
    email: "Email",
    password: "Password",
    submit: "Register",
    ok: "OK",
    successTitle: "Registration completed",
    successBody: "This dialog will close automatically",
    toLogin: "Redirecting to login",

    errors: {
      required: "This field is required",
      invalidName: "Only lowercase letters and digits are allowed",
      invalidEmail: "Invalid email format",
      duplicateEmail: "This email is already registered",
      invalidPasswordChars: "Allowed characters: letters/digits and _ , - , @ , # , $ , % , &",
      passwordMin: "Must be at least 8 characters",
      safeGeneric: "Registration failed. Please try again later."
    }
  }
} as const;

export function pickLang(input: string | null | undefined): Lang {
  return input === "en" ? "en" : "ja";
}

3) logger(詳細はサーバのみ)

// server/logger/logger.ts
export const logger = {
  info: (msg: string, meta?: unknown) => console.log("[INFO]", msg, meta ?? ""),
  warn: (msg: string, meta?: unknown) => console.warn("[WARN]", msg, meta ?? ""),
  error: (msg: string, meta?: unknown) => console.error("[ERROR]", msg, meta ?? "")
};

4) DB接続

// server/db/sqlite.ts
import Database from "better-sqlite3";

export const db = new Database("app.db");

5) Entity(ドメインのUser)

// server/users/entity.ts
export type UserEntity = {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
  isActive: boolean;
  role: "user" | "admin";
  deletedAt: string | null;
  createdAt: string; // ISO
  updatedAt: string; // ISO
};

6) DTO(React ⇔ API の入出力)

// server/users/dto.ts
import type { Lang } from "@/config/i18n";

export type RegisterUserInputDto = {
  name: string;
  email: string;
  password: string;
  lang: Lang;
};

export type RegisterUserSuccessDto = {
  ok: true;
  userId: string;
};

export type RegisterUserErrorDto = {
  ok: false;
  message: string; // 安全なメッセージ
  fieldErrors?: Partial<Record<"name" | "email" | "password", string>>;
};

export type RegisterUserResponseDto = RegisterUserSuccessDto | RegisterUserErrorDto;

7) Repository(DBアクセス)

// server/users/repository.ts
import { db } from "@/server/db/sqlite";
import type { UserEntity } from "./entity";

export const userRepository = {
  // 論理削除も含めて重複扱い:deleted_at を条件にしない
  existsByEmail(email: string): boolean {
    const row = db.prepare("SELECT 1 FROM users WHERE email = ? LIMIT 1").get(email) as any;
    return !!row;
  },

  insert(user: UserEntity): void {
    const sql = `
      INSERT INTO users (
        id, name, email, password_hash, is_active, role, deleted_at, created_at, updated_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
    `;
    db.prepare(sql).run(
      user.id,
      user.name,
      user.email,
      user.passwordHash,
      user.isActive ? 1 : 0,
      user.role,
      user.deletedAt,
      user.createdAt,
      user.updatedAt
    );
  }
};

8) Service(バリデーション・重複判定・例外変換・ハッシュ化)

// server/users/service.ts
import { randomUUID } from "node:crypto";
import bcrypt from "bcryptjs";
import { userRepository } from "./repository";
import type { RegisterUserInputDto, RegisterUserResponseDto } from "./dto";
import type { UserEntity } from "./entity";
import { messages } from "@/config/i18n";
import { logger } from "@/server/logger/logger";

const NAME_RE = /^[a-z0-9]+$/;
const EMAIL_RE =
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // 実務ではより厳密orライブラリ使用も可
const PASS_RE = /^[A-Za-z0-9_\-@#$%&]+$/;

function trimOrEmpty(v: unknown): string {
  return typeof v === "string" ? v.trim() : "";
}

export const userService = {
  async register(inputRaw: any): Promise<RegisterUserResponseDto> {
    const lang = (inputRaw?.lang === "en" ? "en" : "ja") as const;
    const t = messages[lang];

    // DTO化(ここで型を固める=業務的に重要)
    const input: RegisterUserInputDto = {
      name: trimOrEmpty(inputRaw?.name),
      email: trimOrEmpty(inputRaw?.email).toLowerCase(),
      password: trimOrEmpty(inputRaw?.password),
      lang
    };

    // 1) バリデーション(業務:全項目まとめて返す)
    const fieldErrors: any = {};

    if (!input.name) fieldErrors.name = t.errors.required;
    else if (!NAME_RE.test(input.name)) fieldErrors.name = t.errors.invalidName;

    if (!input.email) fieldErrors.email = t.errors.required;
    else if (!EMAIL_RE.test(input.email)) fieldErrors.email = t.errors.invalidEmail;

    if (!input.password) fieldErrors.password = t.errors.required;
    else if (input.password.length < 8) fieldErrors.password = t.errors.passwordMin;
    else if (!PASS_RE.test(input.password)) fieldErrors.password = t.errors.invalidPasswordChars;

    if (Object.keys(fieldErrors).length > 0) {
      return { ok: false, message: t.errors.safeGeneric, fieldErrors };
    }

    // 2) 重複チェック(業務例外:email にピンポイント)
    try {
      if (userRepository.existsByEmail(input.email)) {
        return {
          ok: false,
          message: t.errors.safeGeneric,
          fieldErrors: { email: t.errors.duplicateEmail }
        };
      }
    } catch (e) {
      logger.error("DB error during duplicate check", { email: input.email, error: String(e) });
      return { ok: false, message: t.errors.safeGeneric };
    }

    // 3) 登録(INSERT)…競合(レース)に備えて UNIQUE 例外も email重複扱いにする
    try {
      const now = new Date().toISOString();
      const user: UserEntity = {
        id: randomUUID(),
        name: input.name,
        email: input.email,
        passwordHash: await bcrypt.hash(input.password, 12),
        isActive: true,
        role: "user",
        deletedAt: null,
        createdAt: now,
        updatedAt: now
      };

      userRepository.insert(user);

      return { ok: true, userId: user.id };
    } catch (e: any) {
      // UNIQUE制約違反(SQLite)
      const msg = String(e?.message ?? e);
      logger.error("DB error during insert", { email: input.email, error: msg });

      if (msg.includes("UNIQUE") && msg.includes("users.email")) {
        return {
          ok: false,
          message: t.errors.safeGeneric,
          fieldErrors: { email: t.errors.duplicateEmail }
        };
      }

      return { ok: false, message: t.errors.safeGeneric };
    }
  }
};

9) Controller(Route Handler:HTTP入出力 / 安全なエラー返却)

// app/api/users/register/route.ts
import { userService } from "@/server/users/service";
import type { RegisterUserResponseDto } from "@/server/users/dto";

export async function POST(req: Request) {
  const body = await req.json().catch(() => ({}));
  const result: RegisterUserResponseDto = await userService.register(body);

  // 業務:HTTPステータスも分ける(クライアントが判断しやすい)
  if (!result.ok) {
    // バリデーション/重複は 400、内部は 500…が一般的だが、
    // ここでは fieldErrors がある場合は 400、それ以外は 500
    const status = result.fieldErrors ? 400 : 500;
    return Response.json(result, { status });
  }

  return Response.json(result, { status: 201 });
}

10) React(登録画面:フィールド別エラー・成功モーダル・自動消滅・言語切替)

// app/register/page.tsx
"use client";

import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { messages, pickLang, type Lang } from "@/config/i18n";
import { registrationConfig } from "@/config/registration";
import type { RegisterUserResponseDto } from "@/server/users/dto";

type FieldErrors = Partial<Record<"name" | "email" | "password", string>>;

export default function RegisterPage() {
  const router = useRouter();
  const sp = useSearchParams();

  const lang: Lang = useMemo(() => pickLang(sp.get("lang")), [sp]);
  const t = messages[lang];

  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
  const [safeError, setSafeError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const [successOpen, setSuccessOpen] = useState(false);
  const [successSecondsLeft, setSuccessSecondsLeft] = useState<number | null>(null);

  function clearErrors() {
    setFieldErrors({});
    setSafeError(null);
  }

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    clearErrors();
    setLoading(true);

    try {
      const res = await fetch("/api/users/register", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name, email, password, lang })
      });

      const json = (await res.json().catch(() => null)) as RegisterUserResponseDto | null;

      if (!json || typeof json !== "object") {
        setSafeError(t.errors.safeGeneric);
        return;
      }

      if (!res.ok || !json.ok) {
        setSafeError(json.message ?? t.errors.safeGeneric);
        setFieldErrors(json.fieldErrors ?? {});
        return;
      }

      // 成功
      setSuccessOpen(true);

      if (registrationConfig.successModal.autoDismiss) {
        setSuccessSecondsLeft(registrationConfig.successModal.dismissSeconds);
      } else {
        setSuccessSecondsLeft(null);
      }
    } catch {
      setSafeError(t.errors.safeGeneric);
    } finally {
      setLoading(false);
    }
  }

  // 自動消滅 + リダイレクト
  useEffect(() => {
    if (!successOpen) return;
    if (!registrationConfig.successModal.autoDismiss) return;

    let cancelled = false;

    const tick = () => {
      setSuccessSecondsLeft((s) => {
        if (s == null) return null;
        const next = s - 1;
        if (next <= 0) {
          if (!cancelled) {
            setSuccessOpen(false);
            router.replace(`/login?lang=${lang}`);
          }
          return 0;
        }
        return next;
      });
    };

    const id = window.setInterval(tick, 1000);
    return () => {
      cancelled = true;
      window.clearInterval(id);
    };
  }, [successOpen, router, lang]);

  return (
    <main style={{ maxWidth: 520, margin: "40px auto", padding: "0 16px" }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
        <h1 style={{ margin: 0 }}>{t.title}</h1>
        <div>
          <a href="?lang=ja" style={{ marginRight: 8 }}>日本語</a>
          <a href="?lang=en">English</a>
        </div>
      </div>

      {safeError && (
        <p style={{ color: "#b00020", marginTop: 12 }}>{safeError}</p>
      )}

      <form onSubmit={onSubmit} style={{ marginTop: 16 }}>
        <div style={{ marginBottom: 14 }}>
          <label>{t.name}</label><br />
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
            autoComplete="username"
            style={{ width: "100%", padding: "10px", boxSizing: "border-box" }}
          />
          {fieldErrors.name && (
            <div style={{ color: "#b00020", marginTop: 6 }}>{fieldErrors.name}</div>
          )}
        </div>

        <div style={{ marginBottom: 14 }}>
          <label>{t.email}</label><br />
          <input
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            autoComplete="email"
            style={{ width: "100%", padding: "10px", boxSizing: "border-box" }}
          />
          {fieldErrors.email && (
            <div style={{ color: "#b00020", marginTop: 6 }}>{fieldErrors.email}</div>
          )}
        </div>

        <div style={{ marginBottom: 14 }}>
          <label>{t.password}</label><br />
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            autoComplete="new-password"
            style={{ width: "100%", padding: "10px", boxSizing: "border-box" }}
          />
          {fieldErrors.password && (
            <div style={{ color: "#b00020", marginTop: 6 }}>{fieldErrors.password}</div>
          )}
        </div>

        <button
          disabled={loading}
          style={{ padding: "10px 14px", cursor: loading ? "not-allowed" : "pointer" }}
        >
          {loading ? "..." : t.submit}
        </button>
      </form>

      {/* 成功モーダル */}
      {successOpen && (
        <div
          role="dialog"
          aria-modal="true"
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.25)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            padding: 16
          }}
        >
          <div style={{ width: "min(420px, 100%)", background: "white", borderRadius: 12, padding: 18 }}>
            <h2 style={{ marginTop: 0 }}>{t.successTitle}</h2>
            <p style={{ marginTop: 6, color: "#555" }}>{t.successBody}</p>

            {registrationConfig.successModal.autoDismiss && successSecondsLeft != null && (
              <p style={{ marginTop: 6, color: "#777" }}>
                {t.toLogin}... ({successSecondsLeft})
              </p>
            )}

            <div style={{ marginTop: 12 }}>
              <button
                onClick={() => {
                  setSuccessOpen(false);
                  router.replace(`/login?lang=${lang}`);
                }}
              >
                {t.ok}
              </button>
            </div>
          </div>
        </div>
      )}
    </main>
  );
}

11) (任意)ログイン画面(リダイレクト先の最小例)

// app/login/page.tsx
"use client";

import { useSearchParams } from "next/navigation";
import { messages, pickLang } from "@/config/i18n";

export default function LoginPage() {
  const sp = useSearchParams();
  const lang = pickLang(sp.get("lang"));
  const t = messages[lang];

  return (
    <main style={{ maxWidth: 520, margin: "40px auto", padding: "0 16px" }}>
      <h1>Login</h1>
      <p style={{ color: "#555" }}>(サンプル)登録後の遷移先です。</p>
      <p><a href={`/register?lang=${lang}`}>{t.title}へ戻る</a></p>
    </main>
  );
}

12) package.json(依存の例)

{
  "dependencies": {
    "bcryptjs": "^2.4.3",
    "better-sqlite3": "^11.0.0",
    "next": "latest",
    "react": "latest",
    "react-dom": "latest"
  }
}
これで、React → Route Handler(Controller) → Service → Repository → DB → DTO → React(モーダル)まで、 指定された責務分離と業務レベルのエラーハンドリングを含めた「全体」が揃います。

実務サンプル:ユーザー一覧(name / email)表示(Next.js + Prisma(SQLite) + 20件ページネーション + 業務レベル例外処理)

1. 仕様

1-1. 画面仕様

  • users テーブルから name, email を取得し、HTML の <table> に 1行 = 1ユーザーで表示
  • 一覧は 20件ごと にページネーション(例:/users?page=2
  • 表示対象は 論理削除除外deleted_at IS NULL
  • 本サンプルでは 有効ユーザーのみis_active = 1)を表示(ON)

1-2. エラー処理仕様(業務プログラム並み)

  • DBアクセス等で例外が発生した場合:
    • サーバ側:例外を握りつぶさず ログに詳細 を記録し、画面には 安全なメッセージ を返す
    • 画面側:一覧の上に ダイアログ(モーダル) を表示してエラー通知
  • ユーザー向けメッセージ例:
    • 「ユーザー一覧の取得に失敗しました。時間をおいて再度お試しください。」

1-3. 責務分離(サンプルとしての設計)

  • Entity:ドメインとしての User(name/email 等)
  • DTO:画面表示向けの UserRowDto(name/email)
  • Repository:Prisma を使った取得(paginate 20件)を隠蔽
  • Service:取得処理+DTO変換+例外を業務例外へ変換
  • Controller(API):HTTPリクエストを受けて JSON を返す(Next.js Route Handler)
  • View:テーブル表示+ページネーションリンク+エラーダイアログ

2. 注意点

SQLiteの永続化について:
Vercel などのサーバレス環境では、サーバー内ファイル(SQLite)を永続的に保持できないことがあります。
本サンプルは「設計例」として SQLite を使いますが、運用では外部DBを検討してください。
Next.jsのAPI(Route Handler)app/api/**/route.ts に置くと作れます(ページとは役割が違う)。:contentReference[oaicite:0]{index=0}
本サンプルは「想定外エラーで error.tsx に落とす」ではなく、
APIは安全なメッセージを返し、画面はモーダルで表示する方式です(業務アプリでよくある形)。 ※error.tsx は「例外時の代替画面」で、ブラウザ描画になるため Client Component が必要、という考え方は添付資料の通りです。:contentReference[oaicite:1]{index=1}

3. 画面(SVGワイヤー)

ユーザー一覧 /users?page=2 エラー(モーダル) ユーザー一覧の取得に失敗しました。時間をおいて再度お試しください。 閉じる name email 山田 太郎 yamada@example.com 佐藤 花子 sato@example.com « 前へ ページ 2 / 10 次へ »

4. コード

4-1. フォルダ構成(最小)

app/
  users/
    page.tsx                 // 一覧ページ(Server Component)
    UsersTableClient.tsx     // 一覧テーブル(Client Component:取得・モーダル制御)
  api/
    users/
      route.ts               // API(Controller)
lib/
  prisma.ts                  // Prisma Client(singleton)
domain/
  user/
    UserEntity.ts            // Entity
    UserRowDto.ts            // DTO
    UserQuery.ts             // クエリ(page等)
  errors/
    BizError.ts              // 業務例外
    ValidationError.ts       // バリデーション例外
infra/
  user/
    UserRepository.ts        // Repository
service/
  user/
    UserService.ts           // Service
validation/
  UserListQueryValidator.ts  // バリデーション(クラス分割)
prisma/
  schema.prisma

4-2. Prisma(SQLite)

// prisma/schema.prisma
// 役割:DB定義(SQLite)。論理削除・有効フラグを含む

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id         Int       @id @default(autoincrement())
  name       String
  email      String    @unique
  is_active  Boolean   @default(true)
  deleted_at DateTime?

  @@index([is_active])
  @@index([deleted_at])
}
// lib/prisma.ts
// 役割:PrismaClient を 1プロセス内で使い回す(開発時の多重生成を防ぐ)

import { PrismaClient } from "@prisma/client";

declare global {
  // eslint-disable-next-line no-var
  var prisma: PrismaClient | undefined;
}

/**
 * PrismaClient を singleton として提供する関数。
 * - 開発環境では Hot Reload で複数回初期化されがちなので、global に保持する。
 * - 本番環境では単純生成で問題ないケースが多い。
 */
export function getPrismaClient(): PrismaClient {
  if (process.env.NODE_ENV === "production") {
    return new PrismaClient();
  }
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }
  return global.prisma;
}

4-3. Domain(Entity / DTO / Query)

// domain/user/UserEntity.ts
// 役割:ドメインとしての User(DBの生データをそのまま画面に渡さないための土台)

export class UserEntity {
  constructor(
    public readonly name: string,
    public readonly email: string
  ) {}
}
// domain/user/UserRowDto.ts
// 役割:画面表示用DTO(一覧行)。画面は DTO だけを参照する。

export class UserRowDto {
  constructor(
    public readonly name: string,
    public readonly email: string
  ) {}
}
// domain/user/UserQuery.ts
// 役割:検索条件・ページング条件をまとめる(Controller から Service へ渡す)

export class UserQuery {
  constructor(
    public readonly page: number,
    public readonly pageSize: number,
    public readonly activeOnly: boolean
  ) {}
}

4-4. Errors(業務例外 / バリデーション例外)

// domain/errors/BizError.ts
// 役割:ユーザーに見せる「安全な文言」を持つ業務例外。
//       例外の詳細はログで扱い、画面には userMessage だけ返す。

export class BizError extends Error {
  public readonly userMessage: string;
  public readonly cause?: unknown;

  /**
   * @param userMessage ユーザーに表示して良い安全な文言
   * @param cause       内部原因(ログ用)
   */
  constructor(userMessage: string, cause?: unknown) {
    super(userMessage);
    this.name = "BizError";
    this.userMessage = userMessage;
    this.cause = cause;
  }
}
// domain/errors/ValidationError.ts
// 役割:入力(クエリ等)のバリデーションエラー。
//       画面に出す文言と、開発者向け情報を分ける。

export class ValidationError extends Error {
  public readonly userMessage: string;

  /**
   * @param userMessage ユーザーに表示する文言(例:不正なページ番号です)
   */
  constructor(userMessage: string) {
    super(userMessage);
    this.name = "ValidationError";
    this.userMessage = userMessage;
  }
}

4-5. Validation(クラス分割で“きちんと”)

// validation/UserListQueryValidator.ts
// 役割:/api/users のクエリを検証する。
//       Controller(route.ts)は「受け取る」だけにし、検証ルールはここに隔離する。

import { ValidationError } from "@/domain/errors/ValidationError";

/**
 * ユーザー一覧のクエリを検証するクラス。
 * - page は 1以上の整数
 * - pageSize は固定 20(今回はサンプルなので外から受け取らない)
 */
export class UserListQueryValidator {
  /**
   * page を number に変換し、範囲を検証して返す。
   * @param rawPage URLSearchParams.get("page") の値(string | null)
   */
  public parsePage(rawPage: string | null): number {
    // 未指定なら 1
    if (rawPage === null || rawPage.trim() === "") return 1;

    // 数値化
    const n = Number(rawPage);

    // 整数チェック(NaN や小数を弾く)
    if (!Number.isInteger(n)) {
      throw new ValidationError("ページ番号が不正です。");
    }

    // 範囲チェック
    if (n < 1) {
      throw new ValidationError("ページ番号が不正です。");
    }

    return n;
  }
}

4-6. Repository(Prismaを隠蔽)

// infra/user/UserRepository.ts
// 役割:DBアクセス(Prisma)を隠蔽して、上位層に “取得方法” を漏らさない。

import { PrismaClient } from "@prisma/client";
import { UserEntity } from "@/domain/user/UserEntity";
import { UserQuery } from "@/domain/user/UserQuery";

/**
 * User の永続化層(Repository)。
 * - 取得条件(論理削除除外 / activeOnly 等)を DB クエリとしてまとめる。
 */
export class UserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  /**
   * 指定条件でユーザーを 1ページ分取得する。
   * @param q UserQuery(page, pageSize, activeOnly)
   */
  public async findPage(q: UserQuery): Promise<{ items: UserEntity[]; total: number }> {
    const where = {
      deleted_at: null,
      ...(q.activeOnly ? { is_active: true } : {}),
    };

    // 1) total(総件数)
    const total = await this.prisma.user.count({ where });

    // 2) items(1ページ分)
    const rows = await this.prisma.user.findMany({
      where,
      select: { name: true, email: true },
      orderBy: { id: "asc" },
      skip: (q.page - 1) * q.pageSize,
      take: q.pageSize,
    });

    // 3) Entity 化(DBの生データをドメインに寄せる)
    const items = rows.map((r) => new UserEntity(r.name, r.email));

    return { items, total };
  }
}

4-7. Service(DTO変換+業務例外化)

// service/user/UserService.ts
// 役割:ユースケース層。
// - Repository から取得
// - DTO 変換
// - 例外を BizError に変換(画面に安全な文言を返す)

import { UserRepository } from "@/infra/user/UserRepository";
import { UserRowDto } from "@/domain/user/UserRowDto";
import { UserQuery } from "@/domain/user/UserQuery";
import { BizError } from "@/domain/errors/BizError";

/**
 * ユーザー一覧のユースケースを提供する Service。
 */
export class UserService {
  constructor(private readonly repo: UserRepository) {}

  /**
   * 20件ページングでユーザー一覧を返す。
   * @param q UserQuery(page, pageSize, activeOnly)
   */
  public async getUserRows(q: UserQuery): Promise<{
    rows: UserRowDto[];
    total: number;
    page: number;
    pageSize: number;
    totalPages: number;
  }> {
    try {
      const { items, total } = await this.repo.findPage(q);

      // DTO 化(画面が使う形に整える)
      const rows = items.map((u) => new UserRowDto(u.name, u.email));

      const totalPages = Math.max(1, Math.ceil(total / q.pageSize));

      return {
        rows,
        total,
        page: q.page,
        pageSize: q.pageSize,
        totalPages,
      };
    } catch (e) {
      // ここで “安全な文言” に変換(内部原因は cause に保持)
      throw new BizError(
        "ユーザー一覧の取得に失敗しました。時間をおいて再度お試しください。",
        e
      );
    }
  }
}

4-8. Controller(Route Handler:/api/users)

// app/api/users/route.ts
// 役割:HTTP を受ける(Controller)。
// - クエリ検証(Validator に委譲)
// - Service を呼ぶ
// - 例外発生時はログを出し、安全な JSON を返す
//
// Note: Route Handler は「API(サーバー処理)」で、page.tsx と役割が違う。:contentReference[oaicite:2]{index=2}

import { NextResponse } from "next/server";
import { getPrismaClient } from "@/lib/prisma";
import { UserRepository } from "@/infra/user/UserRepository";
import { UserService } from "@/service/user/UserService";
import { UserQuery } from "@/domain/user/UserQuery";
import { UserListQueryValidator } from "@/validation/UserListQueryValidator";
import { BizError } from "@/domain/errors/BizError";
import { ValidationError } from "@/domain/errors/ValidationError";

/**
 * GET /api/users?page=2
 * - 成功:{ ok:true, data:{ rows, page, totalPages, ... } }
 * - 失敗:{ ok:false, message:"安全な文言" }
 */
export async function GET(req: Request) {
  const url = new URL(req.url);
  const rawPage = url.searchParams.get("page");

  // 依存生成(本来はDIコンテナでも良いが、サンプルなので直書き)
  const prisma = getPrismaClient();
  const repo = new UserRepository(prisma);
  const service = new UserService(repo);
  const validator = new UserListQueryValidator();

  try {
    // 1) バリデーション(クラスに分離)
    const page = validator.parsePage(rawPage);

    // 2) クエリ組み立て(pageSize=20, activeOnly=true)
    const q = new UserQuery(page, 20, true);

    // 3) 取得
    const result = await service.getUserRows(q);

    return NextResponse.json({ ok: true, data: result });
  } catch (e) {
    // 4) 業務レベル:ログは詳細、返すのは安全な文言のみ
    //    - ここで cause や stack をログに残す(Sentry 等に送る想定)
    console.error("[GET /api/users] error:", e);

    // ValidationError は 400 相当、BizError は 500 相当として扱う
    if (e instanceof ValidationError) {
      return NextResponse.json({ ok: false, message: e.userMessage }, { status: 400 });
    }
    if (e instanceof BizError) {
      return NextResponse.json({ ok: false, message: e.userMessage }, { status: 500 });
    }

    // 想定外(最後の砦)
    return NextResponse.json(
      { ok: false, message: "ユーザー一覧の取得に失敗しました。時間をおいて再度お試しください。" },
      { status: 500 }
    );
  }
}

4-9. View(/users:Server + Client 分離)

// app/users/page.tsx
// 役割:ページ(View)。
// - ここは Server Component(デフォルト)でOK。
// - “操作(fetch・モーダル表示)” は Client Component に寄せる。

import UsersTableClient from "./UsersTableClient";

export default function UsersPage() {
  return (
    <div>
      <h1>ユーザー一覧</h1>

      {/* Client 側が /api/users を呼んで表示する */}
      <UsersTableClient />
    </div>
  );
}
// app/users/UsersTableClient.tsx
// 役割:一覧テーブル(Client Component)。
// - /api/users を fetch する
// - 失敗時は “一覧の上にモーダル” を表示する
// - ページネーションリンク(<a href="/users?page=2">)を生成する
//
// 注意:Client Component なのでファイル先頭に "use client" が必要。

"use client";

import { useEffect, useMemo, useState } from "react";

type ApiOk = {
  ok: true;
  data: {
    rows: { name: string; email: string }[];
    page: number;
    pageSize: number;
    total: number;
    totalPages: number;
  };
};

type ApiNg = {
  ok: false;
  message: string;
};

type ApiResponse = ApiOk | ApiNg;

/**
 * UsersTableClient
 * - 画面表示と UI 制御の中心(テーブル / ページャ / モーダル)
 */
export default function UsersTableClient() {
  // 一覧データ
  const [rows, setRows] = useState<{ name: string; email: string }[]>([]);

  // ページング情報
  const [page, setPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);

  // エラーモーダル
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  /**
   * 現在の URL から page を読む。
   * - Next.js の searchParams を Server で受ける形でもよいが、
   *   ここでは “クライアントでURLを読んでAPIに渡す” 方式のサンプルにする。
   */
  const currentPage = useMemo(() => {
    const sp = new URLSearchParams(window.location.search);
    const raw = sp.get("page");
    const n = raw ? Number(raw) : 1;
    return Number.isInteger(n) && n >= 1 ? n : 1;
  }, []);

  /**
   * API からユーザー一覧を取得する関数。
   * - 成功:state に反映
   * - 失敗:モーダル表示
   */
  async function loadUsers(p: number) {
    try {
      setErrorMessage(null);

      const res = await fetch(`/api/users?page=${p}`, {
        method: "GET",
        cache: "no-store", // 業務一覧は「最新を見せたい」ことが多いのでキャッシュしない
      });

      const json = (await res.json()) as ApiResponse;

      if (!json.ok) {
        // サーバーが返した “安全な文言” を表示
        setErrorMessage(json.message);
        return;
      }

      // 正常反映
      setRows(json.data.rows);
      setPage(json.data.page);
      setTotalPages(json.data.totalPages);
    } catch (e) {
      // ネットワーク等で JSON が取れない場合など
      console.error("[UsersTableClient] loadUsers error:", e);
      setErrorMessage("ユーザー一覧の取得に失敗しました。時間をおいて再度お試しください。");
    }
  }

  /**
   * 初回表示時に読み込み。
   */
  useEffect(() => {
    loadUsers(currentPage);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  /**
   * モーダルを閉じる(エラー解除)。
   */
  function closeModal() {
    setErrorMessage(null);
  }

  /**
   * 前へ・次へリンク用の URL を作る。
   */
  function pageUrl(p: number) {
    return `/users?page=${p}`;
  }

  return (
    <div>
      {/* ===== エラーモーダル(一覧の上) ===== */}
      {errorMessage && (
        <div
          role="dialog"
          aria-modal="true"
          style={{
            border: "1px solid #f59e0b",
            background: "#fff7d6",
            borderRadius: 12,
            padding: 12,
            margin: "12px 0",
          }}
        >
          <div style={{ fontWeight: 700, marginBottom: 6 }}>エラー</div>
          <div style={{ marginBottom: 10 }}>{errorMessage}</div>
          <button
            onClick={closeModal}
            style={{
              background: "#111827",
              color: "#fff",
              border: "none",
              borderRadius: 10,
              padding: "8px 12px",
              cursor: "pointer",
            }}
          >
            閉じる
          </button>
        </div>
      )}

      {/* ===== 一覧テーブル ===== */}
      <table>
        <thead>
          <tr>
            <th>name</th>
            <th>email</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((r, i) => (
            <tr key={`${r.email}-${i}`}>
              <td>{r.name}</td>
              <td>{r.email}</td>
            </tr>
          ))}
          {rows.length === 0 && (
            <tr>
              <td colSpan={2} style={{ color: "#6b7280" }}>
                表示するユーザーがありません。
              </td>
            </tr>
          )}
        </tbody>
      </table>

      {/* ===== ページネーション ===== */}
      <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
        {/* 前へ */}
        {page > 1 ? (
          <a href={pageUrl(page - 1)} onClick={(e) => { e.preventDefault(); loadUsers(page - 1); window.history.pushState(null, "", pageUrl(page - 1)); }}>
            « 前へ
          </a>
        ) : (
          <span style={{ color: "#9ca3af" }}>« 前へ</span>
        )}

        <span>
          ページ {page} / {totalPages}
        </span>

        {/* 次へ */}
        {page < totalPages ? (
          <a href={pageUrl(page + 1)} onClick={(e) => { e.preventDefault(); loadUsers(page + 1); window.history.pushState(null, "", pageUrl(page + 1)); }}>
            次へ »
          </a>
        ) : (
          <span style={{ color: "#9ca3af" }}>次へ »</span>
        )}
      </div>
    </div>
  );
}
これで要件の「責務分離」「20件ページネーション」「論理削除除外+activeOnly」「業務レベル例外処理(ログ+安全文言+モーダル)」が揃います。

在庫管理:在庫一覧検索(想定UI)— Next.js + React + Prisma(SQLite) / 責務分離 / 業務レベル例外処理

1. 仕様

1-1. 画面仕様(在庫検索)

  • 検索条件(画面上部)を入力して「検索」で一覧を更新する
  • 検索条件(例)
    • 製品コード / 商品コード / 品番 / 品名(部分一致)
    • 種別(複数選択:完成品 / 仕掛品 / 部材 / 支給品 / 素材 / 部品 / 資材 / 治具)
    • カテゴリー(部分一致)
    • 製品グループ / 保管場所 / 得意先 / 仕入先(部分一致)
    • 安全在庫数未満のみ(ON の場合:quantity < safety_stock)
    • 集計方法(例:保管場所毎 ON の場合:製品×保管場所で集計、OFF の場合:製品で集計)
    • 並び順(例:製品コード / 品番 / 品名 / 数量 desc)
  • 検索結果(画面下部)
    • テーブル表示:1行=1在庫レコード(または集計単位)
    • 列例:No / 製品コード / 商品コード / 品番 / 品名 / 種別 / カテゴリー / 保管場所コード / 保管場所 / 数量
    • ページネーション:50件/ページ(例:/inventory?page=2

1-2. エラー処理(業務プログラム並み)

  • DBアクセス等で例外が発生した場合:
    • サーバ側:例外を握りつぶさずログに詳細を記録し、画面には安全なメッセージを返す
    • 画面側:一覧の上にモーダルを表示してエラーを通知
  • ユーザー向けメッセージ例:
    「在庫一覧の取得に失敗しました。時間をおいて再度お試しください。」

1-3. 責務分離(設計)

  • Entity:Inventory / Product / Location(ドメインの実体)
  • DTO:InventoryRowDto(画面表示用に整形された1行)
  • Repository:Prisma を使った検索・ページング・集計を隠蔽
  • Service:検索処理+DTO変換+例外を業務例外に変換
  • Controller(API):HTTPを受け、バリデーションし、JSONを返す(Next.js Route Handler)
  • View:Reactで検索フォーム+テーブル+ページャ+エラーモーダル

2. 注意点

SQLite運用注意:
サーバレス環境ではファイルDB(SQLite)が永続化できない/同時書き込みに弱い場合があります。
本サンプルは「設計例」として SQLite を使用します。実運用では PostgreSQL 等も検討してください。
検索条件が多いUIのコツ:
・クエリを “全部URLに載せる” と共有・再現が簡単(業務で便利)
・ただしクエリが長くなるので、必要に応じて「検索条件をPOSTで送る」方式も検討
バリデーションはクラス分割:
ページ番号、並び順、チェック項目などを “小さな検証クラス” に分けると保守しやすいです(本コードで実施)。

3. 画面(SVGワイヤー)

在庫管理システム - 在庫一覧検索 製品コード 商品コード 品番 品名 種別(複数) 完成品 / 仕掛品 / 部材 / … 製品グループ 保管場所 得意先 仕入先 集計:保管場所毎 / 安全在庫未満のみ / 並び順 検索 エラー(モーダル) 在庫一覧の取得に失敗しました。時間をおいて再度お試しください。 No / 製品コード / 商品コード / 品番 / 品名 / 種別 / カテゴリー / 保管場所 / 数量 … 50件/ページ、ページャで移動 …

4. コード(Next.js + React + Prisma(SQLite) / TypeScript)

4-1. フォルダ構成(貼り付け先)

app/
  inventory/
    page.tsx                   // View(Server Component:枠だけ)
    InventorySearchClient.tsx   // View(Client Component:フォーム・取得・モーダル・表)
  api/
    inventory/
      search/
        route.ts               // Controller(API)
domain/
  errors/
    BizError.ts
    ValidationError.ts
  inventory/
    InventoryEntity.ts
    InventoryRowDto.ts
    InventorySearchQuery.ts
infra/
  inventory/
    InventoryRepository.ts
lib/
  prisma.ts
service/
  inventory/
    InventoryService.ts
validation/
  PageValidator.ts
  SortValidator.ts
  TypesValidator.ts
  InventorySearchQueryValidator.ts
prisma/
  schema.prisma

4-2. Prisma schema(SQLite / 最小構成)

// prisma/schema.prisma
// ============================================
// 役割:在庫検索に必要な最小スキーマ
// - Product:製品マスタ(製品コード/品番/品名/種別/カテゴリー/グループ/取引先など)
// - Location:保管場所マスタ
// - Inventory:在庫(製品×保管場所×数量)
// - 論理削除:deleted_at
// ============================================

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model Product {
  id             Int       @id @default(autoincrement())
  product_code    String    @unique   // 製品コード
  item_code       String?             // 商品コード(任意)
  part_no         String?             // 品番(任意)
  name            String              // 品名
  type_code       String              // 種別(例:FINISHED/WIP/MATERIAL...)
  category        String?             // カテゴリー
  product_group   String?             // 製品グループ
  customer_name   String?             // 得意先(簡略化:文字列)
  supplier_name   String?             // 仕入先(簡略化:文字列)
  safety_stock    Int       @default(0)// 安全在庫数
  is_active       Boolean   @default(true)
  deleted_at      DateTime?

  inventories     Inventory[]

  @@index([type_code])
  @@index([category])
  @@index([product_group])
  @@index([is_active])
  @@index([deleted_at])
}

model Location {
  id            Int       @id @default(autoincrement())
  location_code String    @unique // 保管場所コード
  name          String            // 保管場所名
  deleted_at    DateTime?

  inventories   Inventory[]

  @@index([deleted_at])
}

model Inventory {
  id           Int       @id @default(autoincrement())
  product_id   Int
  location_id  Int
  quantity     Int       @default(0)
  deleted_at   DateTime?

  product      Product   @relation(fields: [product_id], references: [id])
  location     Location  @relation(fields: [location_id], references: [id])

  @@index([product_id])
  @@index([location_id])
  @@index([deleted_at])
}

4-3. PrismaClient(Singleton)

// lib/prisma.ts
// ============================================
// 役割:PrismaClient をシングルトンで提供する
// - 開発時の Hot Reload で PrismaClient が増殖しないようにする
// ============================================

import { PrismaClient } from "@prisma/client";

declare global {
  // eslint-disable-next-line no-var
  var __prisma__: PrismaClient | undefined;
}

/**
 * getPrismaClient()
 * --------------------------------------------
 * PrismaClient を返す関数(singleton)
 */
export function getPrismaClient(): PrismaClient {
  if (process.env.NODE_ENV === "production") {
    // 本番ではプロセスが安定している想定なので都度生成でも可
    return new PrismaClient();
  }
  if (!global.__prisma__) {
    global.__prisma__ = new PrismaClient();
  }
  return global.__prisma__;
}

4-4. Domain(Entity / DTO / Query)

// domain/inventory/InventoryEntity.ts
// ============================================
// 役割:ドメインとしての在庫(必要ならここに業務ロジックを集約)
// 今回は「表示に必要な情報」を保持する軽量Entityにする
// ============================================

export class InventoryEntity {
  /**
   * InventoryEntity()
   * @param productCode   製品コード
   * @param itemCode      商品コード
   * @param partNo        品番
   * @param productName   品名
   * @param typeCode      種別コード
   * @param category      カテゴリー
   * @param locationCode  保管場所コード
   * @param locationName  保管場所名
   * @param quantity      数量
   * @param safetyStock   安全在庫数
   */
  constructor(
    public readonly productCode: string,
    public readonly itemCode: string | null,
    public readonly partNo: string | null,
    public readonly productName: string,
    public readonly typeCode: string,
    public readonly category: string | null,
    public readonly locationCode: string | null,
    public readonly locationName: string | null,
    public readonly quantity: number,
    public readonly safetyStock: number
  ) {}
}
// domain/inventory/InventoryRowDto.ts
// ============================================
// 役割:画面表示向けDTO(テーブルの1行)
// - 画面は DTO のみ参照する(Entity/DB生データは直接触らない)
// ============================================

export class InventoryRowDto {
  /**
   * InventoryRowDto()
   * @param no           表示行番号(ページ内1..N)
   * @param productCode  製品コード
   * @param itemCode     商品コード
   * @param partNo       品番
   * @param productName  品名
   * @param typeLabel    種別ラベル(表示用)
   * @param category     カテゴリー
   * @param locationCode 保管場所コード
   * @param locationName 保管場所名
   * @param quantity     数量
   */
  constructor(
    public readonly no: number,
    public readonly productCode: string,
    public readonly itemCode: string,
    public readonly partNo: string,
    public readonly productName: string,
    public readonly typeLabel: string,
    public readonly category: string,
    public readonly locationCode: string,
    public readonly locationName: string,
    public readonly quantity: number
  ) {}
}
// domain/inventory/InventorySearchQuery.ts
// ============================================
// 役割:検索条件をまとめる(Controller → Serviceへ渡す)
// ============================================

export type InventorySortKey =
  | "product_code"
  | "part_no"
  | "name"
  | "quantity_desc"
  | "quantity_asc";

export class InventorySearchQuery {
  /**
   * InventorySearchQuery()
   * @param productCode   製品コード(部分一致)
   * @param itemCode      商品コード(部分一致)
   * @param partNo        品番(部分一致)
   * @param name          品名(部分一致)
   * @param typeCodes     種別コード(複数)
   * @param category      カテゴリー(部分一致)
   * @param productGroup  製品グループ(部分一致)
   * @param location      保管場所(コード/名称の部分一致)
   * @param customer      得意先(部分一致)
   * @param supplier      仕入先(部分一致)
   * @param belowSafetyOnly 安全在庫未満のみ
   * @param groupByLocation 保管場所毎に集計
   * @param sortKey       並び順キー
   * @param page          ページ番号(1始まり)
   * @param pageSize      ページサイズ(本仕様:50)
   */
  constructor(
    public readonly productCode: string,
    public readonly itemCode: string,
    public readonly partNo: string,
    public readonly name: string,
    public readonly typeCodes: string[],
    public readonly category: string,
    public readonly productGroup: string,
    public readonly location: string,
    public readonly customer: string,
    public readonly supplier: string,
    public readonly belowSafetyOnly: boolean,
    public readonly groupByLocation: boolean,
    public readonly sortKey: InventorySortKey,
    public readonly page: number,
    public readonly pageSize: number
  ) {}
}

4-5. Domain(例外)

// domain/errors/BizError.ts
// ============================================
// 役割:業務例外(安全なユーザー向け文言 + 内部原因)
// ============================================

export class BizError extends Error {
  public readonly userMessage: string;
  public readonly cause?: unknown;

  /**
   * BizError()
   * @param userMessage ユーザーに表示して良い安全文言
   * @param cause       内部原因(ログ用)
   */
  constructor(userMessage: string, cause?: unknown) {
    super(userMessage);
    this.name = "BizError";
    this.userMessage = userMessage;
    this.cause = cause;
  }
}
// domain/errors/ValidationError.ts
// ============================================
// 役割:バリデーション例外(400相当)
// ============================================

export class ValidationError extends Error {
  public readonly userMessage: string;

  /**
   * ValidationError()
   * @param userMessage ユーザーに表示する文言
   */
  constructor(userMessage: string) {
    super(userMessage);
    this.name = "ValidationError";
    this.userMessage = userMessage;
  }
}

4-6. Validation(クラス分割)

// validation/PageValidator.ts
// ============================================
// 役割:ページ番号の検証に責務を限定する
// ============================================

import { ValidationError } from "@/domain/errors/ValidationError";

export class PageValidator {
  /**
   * parse()
   * @param raw URLSearchParams.get("page") の値
   */
  public parse(raw: string | null): number {
    if (!raw || raw.trim() === "") return 1;
    const n = Number(raw);
    if (!Number.isInteger(n) || n < 1) {
      throw new ValidationError("ページ番号が不正です。");
    }
    return n;
  }
}
// validation/SortValidator.ts
// ============================================
// 役割:並び順キーの検証
// ============================================

import { ValidationError } from "@/domain/errors/ValidationError";
import { InventorySortKey } from "@/domain/inventory/InventorySearchQuery";

export class SortValidator {
  private readonly allowed: InventorySortKey[] = [
    "product_code",
    "part_no",
    "name",
    "quantity_desc",
    "quantity_asc",
  ];

  /**
   * parse()
   * @param raw URLSearchParams.get("sort") の値
   */
  public parse(raw: string | null): InventorySortKey {
    if (!raw) return "product_code";
    if (this.allowed.includes(raw as InventorySortKey)) {
      return raw as InventorySortKey;
    }
    throw new ValidationError("並び順が不正です。");
  }
}
// validation/TypesValidator.ts
// ============================================
// 役割:種別(複数)の検証
// - UIからは "FINISHED,WIP,MATERIAL" のように来る想定
// ============================================

import { ValidationError } from "@/domain/errors/ValidationError";

export class TypesValidator {
  // 許可する種別コード(必要に応じて追加)
  private readonly allowed = new Set([
    "FINISHED", // 完成品
    "WIP",      // 仕掛品
    "MATERIAL", // 部材
    "SUPPLIED", // 支給品
    "RAW",      // 素材
    "PART",     // 部品
    "ASSET",    // 資材
    "JIG",      // 治具
  ]);

  /**
   * parse()
   * @param raw URLSearchParams.get("types") の値(例:"FINISHED,WIP")
   */
  public parse(raw: string | null): string[] {
    if (!raw || raw.trim() === "") return [];
    const list = raw.split(",").map((s) => s.trim()).filter(Boolean);

    for (const t of list) {
      if (!this.allowed.has(t)) {
        throw new ValidationError("種別の指定が不正です。");
      }
    }
    return list;
  }
}
// validation/InventorySearchQueryValidator.ts
// ============================================
// 役割:検索クエリ全体を組み立てる(小さなValidatorを合成)
// ============================================

import { InventorySearchQuery } from "@/domain/inventory/InventorySearchQuery";
import { PageValidator } from "@/validation/PageValidator";
import { SortValidator } from "@/validation/SortValidator";
import { TypesValidator } from "@/validation/TypesValidator";

export class InventorySearchQueryValidator {
  private readonly pageV = new PageValidator();
  private readonly sortV = new SortValidator();
  private readonly typesV = new TypesValidator();

  /**
   * build()
   * @param sp URLSearchParams
   */
  public build(sp: URLSearchParams): InventorySearchQuery {
    // ページング
    const page = this.pageV.parse(sp.get("page"));
    const pageSize = 50; // 仕様固定(業務画面は固定が多い)

    // 文字列条件(部分一致)
    const productCode = (sp.get("productCode") ?? "").trim();
    const itemCode = (sp.get("itemCode") ?? "").trim();
    const partNo = (sp.get("partNo") ?? "").trim();
    const name = (sp.get("name") ?? "").trim();
    const category = (sp.get("category") ?? "").trim();
    const productGroup = (sp.get("productGroup") ?? "").trim();
    const location = (sp.get("location") ?? "").trim();
    const customer = (sp.get("customer") ?? "").trim();
    const supplier = (sp.get("supplier") ?? "").trim();

    // boolean("1" / "0" を想定)
    const belowSafetyOnly = (sp.get("belowSafetyOnly") ?? "0") === "1";
    const groupByLocation = (sp.get("groupByLocation") ?? "1") === "1"; // 本サンプルはONデフォ

    // 種別(複数)
    const typeCodes = this.typesV.parse(sp.get("types"));

    // 並び順
    const sortKey = this.sortV.parse(sp.get("sort"));

    return new InventorySearchQuery(
      productCode,
      itemCode,
      partNo,
      name,
      typeCodes,
      category,
      productGroup,
      location,
      customer,
      supplier,
      belowSafetyOnly,
      groupByLocation,
      sortKey,
      page,
      pageSize
    );
  }
}

4-7. Repository(Prisma検索・集計・ページングを隠蔽)

// infra/inventory/InventoryRepository.ts
// ============================================
// 役割:Prismaを使った検索を隠蔽
// - 論理削除除外(deleted_at IS NULL)
// - is_active = true(有効のみ)
// - 部分一致条件の組み立て
// - groupByLocation ON/OFF の集計分岐(簡易実装)
// ============================================

import { PrismaClient } from "@prisma/client";
import { InventorySearchQuery } from "@/domain/inventory/InventorySearchQuery";
import { InventoryEntity } from "@/domain/inventory/InventoryEntity";

export class InventoryRepository {
  /**
   * InventoryRepository()
   * @param prisma PrismaClient
   */
  constructor(private readonly prisma: PrismaClient) {}

  /**
   * search()
   * - 画面の検索条件で在庫一覧を返す
   * - 戻りは Entity(ServiceがDTOに変換)
   */
  public async search(q: InventorySearchQuery): Promise<{ items: InventoryEntity[]; total: number }> {
    // 共通 where(製品側)
    const productWhere: any = {
      deleted_at: null,
      is_active: true,
      ...(q.productCode ? { product_code: { contains: q.productCode } } : {}),
      ...(q.itemCode ? { item_code: { contains: q.itemCode } } : {}),
      ...(q.partNo ? { part_no: { contains: q.partNo } } : {}),
      ...(q.name ? { name: { contains: q.name } } : {}),
      ...(q.category ? { category: { contains: q.category } } : {}),
      ...(q.productGroup ? { product_group: { contains: q.productGroup } } : {}),
      ...(q.customer ? { customer_name: { contains: q.customer } } : {}),
      ...(q.supplier ? { supplier_name: { contains: q.supplier } } : {}),
      ...(q.typeCodes.length ? { type_code: { in: q.typeCodes } } : {}),
    };

    // Location 部分一致(コード/名称)
    const locationWhere: any = {
      deleted_at: null,
      ...(q.location
        ? {
            OR: [
              { location_code: { contains: q.location } },
              { name: { contains: q.location } },
            ],
          }
        : {}),
    };

    // Inventory 共通 where(論理削除)
    const invWhere: any = { deleted_at: null };

    // 安全在庫未満(quantity < safety_stock)
    // Prismaで cross-field 比較は工夫が要るため、ここでは簡易に「後段フィルタ」ではなく、
    // “集計なし/保管場所毎” のどちらでも同じ比較が必要なので Service 側でフィルタする案もある。
    // 今回は Repository 側で「いったん全部取得 → Serviceでfilter」でもよいが、
    // サンプルでは “DBでやる風” に見せたいので rawSql を避け、Service側filterを採用する。
    const needBelowSafety = q.belowSafetyOnly;

    // 並び順(productのカラム or quantity)
    const orderBy: any =
      q.sortKey === "product_code"
        ? [{ product: { product_code: "asc" } }]
        : q.sortKey === "part_no"
        ? [{ product: { part_no: "asc" } }]
        : q.sortKey === "name"
        ? [{ product: { name: "asc" } }]
        : q.sortKey === "quantity_asc"
        ? [{ quantity: "asc" }]
        : [{ quantity: "desc" }];

    if (q.groupByLocation) {
      // --------------------------------------------------
      // 保管場所毎(= inventory 行をそのまま表示)
      // --------------------------------------------------
      // total(件数)
      const total = await this.prisma.inventory.count({
        where: {
          ...invWhere,
          product: productWhere,
          location: locationWhere,
        },
      });

      // 1ページ分
      const rows = await this.prisma.inventory.findMany({
        where: {
          ...invWhere,
          product: productWhere,
          location: locationWhere,
        },
        include: { product: true, location: true },
        orderBy,
        skip: (q.page - 1) * q.pageSize,
        take: q.pageSize,
      });

      // Entity化
      let items = rows.map(
        (r) =>
          new InventoryEntity(
            r.product.product_code,
            r.product.item_code ?? null,
            r.product.part_no ?? null,
            r.product.name,
            r.product.type_code,
            r.product.category ?? null,
            r.location.location_code,
            r.location.name,
            r.quantity,
            r.product.safety_stock
          )
      );

      // 安全在庫未満のみ(Serviceでやっても良いが、Repositoryで完結させる)
      if (needBelowSafety) {
        items = items.filter((x) => x.quantity < x.safetyStock);
      }

      return { items, total };
    } else {
      // --------------------------------------------------
      // 製品単位で集計(=保管場所を跨いで数量合算)
      // Prismaの groupBy を使う簡易版:
      //   Inventoryを product_id で groupBy し sum(quantity) を取る
      //   その後 product を引いて表示用Entityに組み立てる
      // --------------------------------------------------

      // 1) groupBy(全件を対象にする)
      const grouped = await this.prisma.inventory.groupBy({
        by: ["product_id"],
        where: {
          ...invWhere,
          product: productWhere,
          location: locationWhere, // location条件は集計に含める(保管場所検索時)
        },
        _sum: { quantity: true },
      });

      const total = grouped.length;

      // 2) ページング(配列から切る:サンプル簡易)
      const pageSlice = grouped.slice((q.page - 1) * q.pageSize, q.page * q.pageSize);

      // 3) product をまとめて取得
      const productIds = pageSlice.map((g) => g.product_id);
      const products = await this.prisma.product.findMany({
        where: { id: { in: productIds }, deleted_at: null, is_active: true },
      });

      const map = new Map(products.map((p) => [p.id, p]));

      // 4) Entity化(locationは集計なので null 扱い)
      let items = pageSlice
        .map((g) => {
          const p = map.get(g.product_id);
          if (!p) return null;
          const qty = g._sum.quantity ?? 0;
          return new InventoryEntity(
            p.product_code,
            p.item_code ?? null,
            p.part_no ?? null,
            p.name,
            p.type_code,
            p.category ?? null,
            null,
            null,
            qty,
            p.safety_stock
          );
        })
        .filter((x): x is InventoryEntity => x !== null);

      // 安全在庫未満のみ
      if (needBelowSafety) {
        items = items.filter((x) => x.quantity < x.safetyStock);
      }

      // 並び替え(集計版はJS側で簡易ソート)
      if (q.sortKey === "quantity_desc") items.sort((a, b) => b.quantity - a.quantity);
      if (q.sortKey === "quantity_asc") items.sort((a, b) => a.quantity - b.quantity);
      if (q.sortKey === "product_code") items.sort((a, b) => a.productCode.localeCompare(b.productCode));

      return { items, total };
    }
  }
}

4-8. Service(DTO変換 + 業務例外化)

// service/inventory/InventoryService.ts
// ============================================
// 役割:ユースケース層
// - Repository検索
// - 表示用DTOに変換
// - 例外をBizErrorに変換(ユーザーへ安全文言)
// ============================================

import { InventoryRepository } from "@/infra/inventory/InventoryRepository";
import { InventorySearchQuery } from "@/domain/inventory/InventorySearchQuery";
import { InventoryRowDto } from "@/domain/inventory/InventoryRowDto";
import { BizError } from "@/domain/errors/BizError";

function typeLabel(typeCode: string): string {
  // ==========================================
  // 役割:種別コード → 表示名
  // ==========================================
  switch (typeCode) {
    case "FINISHED": return "完成品";
    case "WIP": return "仕掛品";
    case "MATERIAL": return "部材";
    case "SUPPLIED": return "支給品";
    case "RAW": return "素材";
    case "PART": return "部品";
    case "ASSET": return "資材";
    case "JIG": return "治具";
    default: return typeCode;
  }
}

export class InventoryService {
  /**
   * InventoryService()
   * @param repo InventoryRepository
   */
  constructor(private readonly repo: InventoryRepository) {}

  /**
   * search()
   * - 検索してDTOに変換して返す
   */
  public async search(q: InventorySearchQuery): Promise<{
    rows: InventoryRowDto[];
    page: number;
    pageSize: number;
    total: number;
    totalPages: number;
  }> {
    try {
      const { items, total } = await this.repo.search(q);

      // totalPages(最低1)
      const totalPages = Math.max(1, Math.ceil(total / q.pageSize));

      // DTO変換(Noはページ内の連番にする)
      const rows = items.map((x, idx) => new InventoryRowDto(
        idx + 1,
        x.productCode,
        x.itemCode ?? "",
        x.partNo ?? "",
        x.productName,
        typeLabel(x.typeCode),
        x.category ?? "",
        x.locationCode ?? "",
        x.locationName ?? (q.groupByLocation ? "" : "(集計)"),
        x.quantity
      ));

      return { rows, page: q.page, pageSize: q.pageSize, total, totalPages };
    } catch (e) {
      // 例外は握りつぶさない(ログはControllerで出す)
      throw new BizError(
        "在庫一覧の取得に失敗しました。時間をおいて再度お試しください。",
        e
      );
    }
  }
}

4-9. Controller(API:/api/inventory/search)

// app/api/inventory/search/route.ts
// ============================================
// 役割:Controller(HTTP入口)
// - URLクエリを受け取る
// - Validatorで検証し Query を構築
// - Serviceを呼ぶ
// - 例外時はログに詳細を出し、安全な文言を返す
// ============================================

import { NextResponse } from "next/server";
import { getPrismaClient } from "@/lib/prisma";
import { InventoryRepository } from "@/infra/inventory/InventoryRepository";
import { InventoryService } from "@/service/inventory/InventoryService";
import { InventorySearchQueryValidator } from "@/validation/InventorySearchQueryValidator";
import { ValidationError } from "@/domain/errors/ValidationError";
import { BizError } from "@/domain/errors/BizError";

/**
 * GET /api/inventory/search?...(クエリ多数)
 */
export async function GET(req: Request) {
  const url = new URL(req.url);

  // 依存生成(サンプル簡易DI)
  const prisma = getPrismaClient();
  const repo = new InventoryRepository(prisma);
  const service = new InventoryService(repo);
  const validator = new InventorySearchQueryValidator();

  try {
    // 1) バリデーション+Query構築
    const q = validator.build(url.searchParams);

    // 2) 検索
    const data = await service.search(q);

    // 3) 正常応答
    return NextResponse.json({ ok: true, data }, { status: 200 });
  } catch (e) {
    // 4) 業務レベル:詳細ログ(ここにstack/causeが残る)
    console.error("[GET /api/inventory/search] error:", e);

    // 5) ユーザーへは安全文言のみ
    if (e instanceof ValidationError) {
      return NextResponse.json({ ok: false, message: e.userMessage }, { status: 400 });
    }
    if (e instanceof BizError) {
      return NextResponse.json({ ok: false, message: e.userMessage }, { status: 500 });
    }

    // 想定外
    return NextResponse.json(
      { ok: false, message: "在庫一覧の取得に失敗しました。時間をおいて再度お試しください。" },
      { status: 500 }
    );
  }
}

4-10. View(React:検索フォーム + テーブル + ページャ + エラーモーダル)

// app/inventory/page.tsx
// ============================================
// 役割:在庫検索ページの枠(Server Component)
// - 実際の操作(フォーム入力/取得/モーダル)は Client Component に分離
// ============================================

import InventorySearchClient from "./InventorySearchClient";

export default function InventoryPage() {
  return (
    <main style={{ padding: 16 }}>
      <h1 style={{ marginBottom: 10 }}>在庫一覧検索</h1>
      <InventorySearchClient />
    </main>
  );
}
// app/inventory/InventorySearchClient.tsx
// ============================================
// 役割:Client Component
// - 検索フォーム(条件多数)
// - API呼び出し(GET /api/inventory/search)
// - 失敗時モーダル表示
// - テーブル表示とページ移動
// ============================================

"use client";

import { useEffect, useMemo, useState } from "react";

type Row = {
  no: number;
  productCode: string;
  itemCode: string;
  partNo: string;
  productName: string;
  typeLabel: string;
  category: string;
  locationCode: string;
  locationName: string;
  quantity: number;
};

type ApiOk = {
  ok: true;
  data: { rows: Row[]; page: number; pageSize: number; total: number; totalPages: number };
};

type ApiNg = { ok: false; message: string };
type ApiResponse = ApiOk | ApiNg;

type TypeKey = "FINISHED" | "WIP" | "MATERIAL" | "SUPPLIED" | "RAW" | "PART" | "ASSET" | "JIG";

const TYPE_LABEL: Record<TypeKey, string> = {
  FINISHED: "完成品",
  WIP: "仕掛品",
  MATERIAL: "部材",
  SUPPLIED: "支給品",
  RAW: "素材",
  PART: "部品",
  ASSET: "資材",
  JIG: "治具",
};

type SortKey = "product_code" | "part_no" | "name" | "quantity_desc" | "quantity_asc";

/**
 * InventorySearchClient()
 * - 検索条件は「state」と「URLクエリ」を同期する(業務で便利)
 */
export default function InventorySearchClient() {
  // ---------------------------
  // 1) 検索条件(state)
  // ---------------------------
  const [productCode, setProductCode] = useState("");
  const [itemCode, setItemCode] = useState("");
  const [partNo, setPartNo] = useState("");
  const [name, setName] = useState("");
  const [category, setCategory] = useState("");
  const [productGroup, setProductGroup] = useState("");
  const [location, setLocation] = useState("");
  const [customer, setCustomer] = useState("");
  const [supplier, setSupplier] = useState("");

  const [types, setTypes] = useState<Record<TypeKey, boolean>>({
    FINISHED: false, WIP: false, MATERIAL: false, SUPPLIED: false,
    RAW: false, PART: false, ASSET: false, JIG: false,
  });

  const [belowSafetyOnly, setBelowSafetyOnly] = useState(false);
  const [groupByLocation, setGroupByLocation] = useState(true);
  const [sort, setSort] = useState<SortKey>("product_code");

  // ---------------------------
  // 2) 一覧(state)
  // ---------------------------
  const [rows, setRows] = useState<Row[]>([]);
  const [page, setPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);

  // ---------------------------
  // 3) エラーモーダル
  // ---------------------------
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  /**
   * getSelectedTypes()
   * - チェックされた種別コード配列を作る
   */
  function getSelectedTypes(): string[] {
    return (Object.keys(types) as TypeKey[]).filter((k) => types[k]);
  }

  /**
   * buildQueryString()
   * - 現在の検索条件から URLクエリ文字列を作る
   * - “未入力の項目は載せない” ことでURLを少し短くする
   */
  function buildQueryString(p: number): string {
    const sp = new URLSearchParams();

    // ページ番号
    sp.set("page", String(p));

    // テキスト条件(空は省略)
    if (productCode) sp.set("productCode", productCode);
    if (itemCode) sp.set("itemCode", itemCode);
    if (partNo) sp.set("partNo", partNo);
    if (name) sp.set("name", name);
    if (category) sp.set("category", category);
    if (productGroup) sp.set("productGroup", productGroup);
    if (location) sp.set("location", location);
    if (customer) sp.set("customer", customer);
    if (supplier) sp.set("supplier", supplier);

    // 種別(複数)
    const ts = getSelectedTypes();
    if (ts.length) sp.set("types", ts.join(","));

    // フラグ系
    if (belowSafetyOnly) sp.set("belowSafetyOnly", "1");
    sp.set("groupByLocation", groupByLocation ? "1" : "0");

    // 並び順
    sp.set("sort", sort);

    return sp.toString();
  }

  /**
   * pushUrl()
   * - 画面のURLを更新して「検索条件の再現」を可能にする
   */
  function pushUrl(p: number) {
    const qs = buildQueryString(p);
    window.history.pushState(null, "", `/inventory?${qs}`);
  }

  /**
   * load()
   * - APIから一覧取得
   * - 失敗時はモーダル表示
   */
  async function load(p: number) {
    try {
      setErrorMessage(null);

      const qs = buildQueryString(p);
      const res = await fetch(`/api/inventory/search?${qs}`, { cache: "no-store" });
      const json = (await res.json()) as ApiResponse;

      if (!json.ok) {
        setErrorMessage(json.message);
        return;
      }

      setRows(json.data.rows);
      setPage(json.data.page);
      setTotalPages(json.data.totalPages);
    } catch (e) {
      console.error("[InventorySearchClient] load error:", e);
      setErrorMessage("在庫一覧の取得に失敗しました。時間をおいて再度お試しください。");
    }
  }

  /**
   * onSearch()
   * - 検索ボタン押下:page=1で検索
   */
  function onSearch() {
    pushUrl(1);
    load(1);
  }

  /**
   * goTo()
   * - ページ移動
   */
  function goTo(p: number) {
    pushUrl(p);
    load(p);
  }

  /**
   * closeModal()
   * - エラーモーダルを閉じる
   */
  function closeModal() {
    setErrorMessage(null);
  }

  /**
   * initFromUrl()
   * - 初回:URLの検索条件を state に反映(共有URLから復元できる)
   */
  const initFromUrl = useMemo(() => {
    return () => {
      const sp = new URLSearchParams(window.location.search);

      // テキスト
      setProductCode(sp.get("productCode") ?? "");
      setItemCode(sp.get("itemCode") ?? "");
      setPartNo(sp.get("partNo") ?? "");
      setName(sp.get("name") ?? "");
      setCategory(sp.get("category") ?? "");
      setProductGroup(sp.get("productGroup") ?? "");
      setLocation(sp.get("location") ?? "");
      setCustomer(sp.get("customer") ?? "");
      setSupplier(sp.get("supplier") ?? "");

      // フラグ
      setBelowSafetyOnly((sp.get("belowSafetyOnly") ?? "0") === "1");
      setGroupByLocation((sp.get("groupByLocation") ?? "1") === "1");

      // sort
      const s = (sp.get("sort") as SortKey) ?? "product_code";
      setSort(s);

      // types
      const rawTypes = (sp.get("types") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
      setTypes((prev) => {
        const next = { ...prev };
        (Object.keys(next) as TypeKey[]).forEach((k) => (next[k] = rawTypes.includes(k)));
        return next;
      });

      // page は load に渡す
      const rawPage = sp.get("page");
      const n = rawPage ? Number(rawPage) : 1;
      return Number.isInteger(n) && n >= 1 ? n : 1;
    };
  }, []);

  // 初回:URL復元 → load
  useEffect(() => {
    const p = initFromUrl();
    load(p);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <section aria-label="在庫一覧検索">
      {/* ===== エラーモーダル(一覧の上) ===== */}
      {errorMessage && (
        <div
          role="dialog"
          aria-modal="true"
          style={{
            border: "1px solid #f59e0b",
            background: "#fff7d6",
            borderRadius: 12,
            padding: 12,
            marginBottom: 12,
          }}
        >
          <div style={{ fontWeight: 700, marginBottom: 6 }}>エラー</div>
          <div style={{ marginBottom: 10 }}>{errorMessage}</div>
          <button
            type="button"
            onClick={closeModal}
            style={{
              background: "#111827",
              color: "#fff",
              border: "none",
              borderRadius: 10,
              padding: "8px 12px",
              cursor: "pointer",
            }}
          >
            閉じる
          </button>
        </div>
      )}

      {/* ===== 検索フォーム ===== */}
      <div style={{ display: "grid", gridTemplateColumns: "1.6fr 1fr", gap: 12 }}>
        <div style={{ border: "1px solid rgba(0,0,0,.12)", borderRadius: 12, padding: 12, background: "#f8fafc" }}>
          <div style={{ display: "grid", gridTemplateColumns: "140px 1fr", gap: 8, alignItems: "center" }}>
            <label>製品コード</label>
            <input value={productCode} onChange={(e) => setProductCode(e.target.value)} />

            <label>商品コード</label>
            <input value={itemCode} onChange={(e) => setItemCode(e.target.value)} />

            <label>品番</label>
            <input value={partNo} onChange={(e) => setPartNo(e.target.value)} />

            <label>品名</label>
            <input value={name} onChange={(e) => setName(e.target.value)} />

            <label>カテゴリー</label>
            <input value={category} onChange={(e) => setCategory(e.target.value)} />
          </div>

          <div style={{ marginTop: 10 }}>
            <div style={{ fontSize: 12, color: "#334155", marginBottom: 6 }}>種別(複数選択)</div>
            <div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
              {(Object.keys(TYPE_LABEL) as TypeKey[]).map((k) => (
                <label key={k} style={{ display: "flex", gap: 6, alignItems: "center" }}>
                  <input
                    type="checkbox"
                    checked={types[k]}
                    onChange={(e) => setTypes((prev) => ({ ...prev, [k]: e.target.checked }))}
                  />
                  <span>{TYPE_LABEL[k]}</span>
                </label>
              ))}
            </div>
          </div>
        </div>

        <div style={{ border: "1px solid rgba(0,0,0,.12)", borderRadius: 12, padding: 12, background: "#f8fafc" }}>
          <div style={{ display: "grid", gridTemplateColumns: "140px 1fr", gap: 8, alignItems: "center" }}>
            <label>製品グループ</label>
            <input value={productGroup} onChange={(e) => setProductGroup(e.target.value)} />

            <label>保管場所</label>
            <input value={location} onChange={(e) => setLocation(e.target.value)} />

            <label>得意先</label>
            <input value={customer} onChange={(e) => setCustomer(e.target.value)} />

            <label>仕入先</label>
            <input value={supplier} onChange={(e) => setSupplier(e.target.value)} />
          </div>
        </div>
      </div>

      {/* ===== オプション + 検索 ===== */}
      <div
        style={{
          marginTop: 12,
          border: "1px solid rgba(0,0,0,.12)",
          borderRadius: 12,
          padding: 12,
          background: "#fff1e6",
          display: "flex",
          gap: 16,
          alignItems: "center",
          flexWrap: "wrap",
          justifyContent: "space-between",
        }}
      >
        <div style={{ display: "flex", gap: 14, alignItems: "center", flexWrap: "wrap" }}>
          <label style={{ display: "flex", gap: 6, alignItems: "center" }}>
            <input type="checkbox" checked={groupByLocation} onChange={(e) => setGroupByLocation(e.target.checked)} />
            保管場所毎に表示
          </label>

          <label style={{ display: "flex", gap: 6, alignItems: "center" }}>
            <input type="checkbox" checked={belowSafetyOnly} onChange={(e) => setBelowSafetyOnly(e.target.checked)} />
            安全在庫未満のみ
          </label>

          <label style={{ display: "flex", gap: 8, alignItems: "center" }}>
            並び順
            <select value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
              <option value="product_code">製品コード</option>
              <option value="part_no">品番</option>
              <option value="name">品名</option>
              <option value="quantity_desc">数量(多い順)</option>
              <option value="quantity_asc">数量(少ない順)</option>
            </select>
          </label>
        </div>

        <button
          type="button"
          onClick={onSearch}
          style={{
            background: "#111827",
            color: "#fff",
            border: "none",
            borderRadius: 10,
            padding: "10px 16px",
            cursor: "pointer",
            fontWeight: 700,
          }}
        >
          検索
        </button>
      </div>

      {/* ===== テーブル ===== */}
      <div style={{ marginTop: 12, border: "1px solid rgba(0,0,0,.12)", borderRadius: 12, overflow: "hidden" }}>
        <table style={{ width: "100%", borderCollapse: "collapse" }}>
          <thead style={{ background: "#f9fafb" }}>
            <tr>
              <th style={{ textAlign: "left", padding: 8 }}>No</th>
              <th style={{ textAlign: "left", padding: 8 }}>製品コード</th>
              <th style={{ textAlign: "left", padding: 8 }}>商品コード</th>
              <th style={{ textAlign: "left", padding: 8 }}>品番</th>
              <th style={{ textAlign: "left", padding: 8 }}>品名</th>
              <th style={{ textAlign: "left", padding: 8 }}>種別</th>
              <th style={{ textAlign: "left", padding: 8 }}>カテゴリー</th>
              <th style={{ textAlign: "left", padding: 8 }}>保管場所コード</th>
              <th style={{ textAlign: "left", padding: 8 }}>保管場所</th>
              <th style={{ textAlign: "right", padding: 8 }}>数量</th>
            </tr>
          </thead>

          <tbody>
            {rows.map((r) => (
              <tr key={`${r.productCode}-${r.locationCode}-${r.no}`}>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.no}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.productCode}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.itemCode}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.partNo}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.productName}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.typeLabel}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.category}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.locationCode}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)" }}>{r.locationName}</td>
                <td style={{ padding: 8, borderTop: "1px solid rgba(0,0,0,.08)", textAlign: "right" }}>
                  {r.quantity.toLocaleString()}
                </td>
              </tr>
            ))}

            {rows.length === 0 && (
              <tr>
                <td colSpan={10} style={{ padding: 12, color: "#6b7280" }}>
                  該当する在庫がありません。
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>

      {/* ===== ページャ ===== */}
      <div style={{ display: "flex", gap: 12, alignItems: "center", marginTop: 10 }}>
        {page > 1 ? (
          <a
            href="#"
            onClick={(e) => {
              e.preventDefault();
              goTo(page - 1);
            }}
          >
            « 前へ
          </a>
        ) : (
          <span style={{ color: "#9ca3af" }}>« 前へ</span>
        )}

        <span>ページ {page} / {totalPages}</span>

        {page < totalPages ? (
          <a
            href="#"
            onClick={(e) => {
              e.preventDefault();
              goTo(page + 1);
            }}
          >
            次へ »
          </a>
        ) : (
          <span style={{ color: "#9ca3af" }}>次へ »</span>
        )}
      </div>
    </section>
  );
}
✅ これで「在庫検索っぽいUI」+「Controller/Service/Repository/DTO/Entity」+「クラス分割バリデーション」+「業務レベル例外処理(ログ+安全文言+モーダル)」が一通り揃います。

React useState サンプル(SVG付き)

仕様

  • ボタンで「いいね数(count)」を増減できる
  • 「リセット」で 0 に戻す
  • SVGでメーター(バー)を表示し、count に応じて伸びる
  • count が 0〜100 の範囲を超えないように制御する

注意点

  • useState の更新は非同期に見えることがあるため、前の値を使う場合は setCount(prev => prev + 1) を使う
  • この例は React コンポーネントです。Next.js ならクライアントで動かすために先頭に "use client" が必要です

useState は「画面の状態(State)」を管理するための Hook

React における useState は、 「画面が覚えておくべき値」を管理するための Hook です。 ボタン操作、入力欄、選択状態、取得したデータなど、 ユーザー操作や時間の経過で変わる値はすべて state になります。

なぜ useState が必要なのか?

JavaScript の普通の変数は、再描画(re-render)が起きると 値が失われます

// ❌ 普通の変数(再描画に耐えない)
function Counter() {
  let count = 0;

  function onClick() {
    count = count + 1;
    console.log(count); // 1,2,3... と増えるが
  }

  return <button onClick={onClick}>{count}</button>;
}

クリックすると console 上では増えているように見えますが、 画面は更新されません。 なぜなら React は「変数が変わった」ことを知らないからです。

そこで useState を使います。

// ✅ useState を使う
function Counter() {
  const [count, setCount] = useState(0);

  function onClick() {
    setCount(count + 1); // React に「状態が変わった」と伝える
  }

  return <button onClick={onClick}>{count}</button>;
}

setCount を呼ぶことで、 React が再描画を行い、画面に反映されます。


例①:業務画面での useState(検索条件)

在庫検索やユーザー検索などの業務画面では、 検索条件そのものが state になります。

function InventorySearch() {
  const [keyword, setKeyword] = useState("");
  const [page, setPage] = useState(1);

  return (
    <>
      <input
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
      />

      <button onClick={() => setPage(1)}>検索</button>
    </>
  );
}

ここでは次のような状態を useState で管理しています。

  • 検索キーワード
  • 現在のページ番号

これらは 「画面が覚えていないと成立しない情報」です。


例②:なぜ「関数型更新」が必要なのか

setState は即座に値を書き換えるわけではありません。 React は更新を まとめて処理(batch)します。

// ❌ 危険な例
setCount(count + 1);
setCount(count + 1);
// → 期待は +2 だが、+1 になる可能性

正しく書くには、 「直前の状態」を引数でもらう 関数型更新を使います。

// ✅ 安全な書き方
setCount(prev => prev + 1);
setCount(prev => prev + 1);

業務システムでは、 連打・並列処理・非同期処理が絡むため、 この書き方が非常に重要です。


例③:useState は「UIの状態」を持つ

useState が管理するのは 「業務データそのもの」だけではありません

const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);

これらは

  • 通信中かどうか
  • エラーを表示するかどうか

といった UIの状態です。

業務画面では、 「ローディング中」「エラー表示中」「結果表示中」 といった状態管理が不可欠であり、 useState がその土台になります。


useState を使う上での心得(実務向け)

  • 「画面が覚える必要があるか?」で state を決める
  • 計算で導ける値は state にしない(props + 計算でOK)
  • setState は命令ではなく「宣言」だと考える
  • 連打・非同期が絡むものは関数型更新

この考え方が身につくと、 React のコードは 業務UIでも読みやすく、事故りにくく なります。

画面(SVGイメージ)

いいねメーター(useState) count: 42 0 〜 100 +1 -1 reset

コード(useStateサンプル)

/**
 * LikeMeter.tsx
 * ------------------------------------------------------------
 * 役割:
 * - useState を使った「カウント管理」の最小サンプル
 * - count の値に応じて SVG のバー(メーター)を伸縮させる
 *
 * ポイント:
 * - 前の値を使う更新は setCount(prev => ...) を使う
 * - count は 0〜100 にクランプ(範囲制御)する
 *
 * Next.js でこのコンポーネントを使う場合:
 * - クライアントコンポーネントとして動かすため "use client" を先頭に付ける
 */

// "use client"; // Next.js(app router) で使うときはコメントを外す

import React, { useMemo, useState } from "react";

/**
 * clamp()
 * ------------------------------------------------------------
 * 役割:
 * - 数値を min〜max の範囲に収める(範囲外なら丸める)
 */
function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

/**
 * LikeMeter()
 * ------------------------------------------------------------
 * 役割:
 * - いいね数(count)を useState で管理し、UIに反映する
 * - SVGのバーを count に合わせて伸ばす
 */
export default function LikeMeter() {
  // ----------------------------------------------------------
  // State:いいね数(0〜100)
  // ----------------------------------------------------------
  const [count, setCount] = useState<number>(42);

  // ----------------------------------------------------------
  // SVGバーの幅(px)を count から計算(重い処理ではないが例として useMemo)
  // - バー背景:BAR_W px
  // - 塗りつぶし:count% に応じて伸びる
  // ----------------------------------------------------------
  const BAR_W = 560;

  const fillWidth = useMemo(() => {
    // count が 0〜100 なので、その割合で幅を決める
    return Math.round((BAR_W * count) / 100);
  }, [count]);

  /**
   * increase()
   * ----------------------------------------------------------
   * 役割:
   * - count を +1 する(上限 100)
   * - 前の値を参照するので関数型更新を使う
   */
  function increase() {
    setCount((prev) => clamp(prev + 1, 0, 100));
  }

  /**
   * decrease()
   * ----------------------------------------------------------
   * 役割:
   * - count を -1 する(下限 0)
   * - 前の値を参照するので関数型更新を使う
   */
  function decrease() {
    setCount((prev) => clamp(prev - 1, 0, 100));
  }

  /**
   * reset()
   * ----------------------------------------------------------
   * 役割:
   * - count を 0 に戻す
   */
  function reset() {
    setCount(0);
  }

  return (
    <div style={{ maxWidth: 760, padding: 16, border: "1px solid rgba(0,0,0,.12)", borderRadius: 16 }}>
      <h3 style={{ margin: "0 0 10px 0" }}>いいねメーター(useState)</h3>

      {/* count表示 */}
      <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 12 }}>
        <div style={{ padding: "10px 12px", borderRadius: 12, background: "#f9fafb", border: "1px solid #e5e7eb" }}>
          <span style={{ color: "#6b7280", marginRight: 8 }}>count:</span>
          <span style={{ fontSize: 20, fontWeight: 700 }}>{count}</span>
        </div>

        {/* 操作ボタン */}
        <button onClick={increase} style={{ padding: "10px 14px", borderRadius: 10, border: "none", background: "#111827", color: "#fff", cursor: "pointer" }}>
          +1
        </button>

        <button onClick={decrease} style={{ padding: "10px 14px", borderRadius: 10, border: "none", background: "#111827", color: "#fff", cursor: "pointer" }}>
          -1
        </button>

        <button onClick={reset} style={{ padding: "10px 14px", borderRadius: 10, border: "1px solid #f59e0b", background: "#fff7d6", color: "#6a4b00", cursor: "pointer" }}>
          reset
        </button>
      </div>

      {/* SVGメーター */}
      <svg viewBox="0 0 760 70" width="100%" height="70" role="img" aria-label="countに応じて伸びるメーター">
        {/* 背景 */}
        <rect x="20" y="24" width={BAR_W} height="22" rx="11" fill="#f1f5f9" stroke="#e5e7eb" />

        {/* 塗り(countに応じて伸びる) */}
        <rect x="20" y="24" width={fillWidth} height="22" rx="11" fill="#111827" />

        {/* 目盛り(簡易) */}
        <text x="20" y="18" fontSize="12" fill="#6b7280">0</text>
        <text x="560" y="18" fontSize="12" fill="#6b7280">100</text>

        {/* 現在値ラベル */}
        <text x="600" y="40" fontSize="12" fill="#111827">{count}/100</text>
      </svg>

      <p style={{ marginTop: 10, color: "#6b7280", fontSize: 12 }}>
        ※ count は 0〜100 の範囲に制御されています。
      </p>
    </div>
  );
}

React useEffect サンプル(SVG付き / CSSは別ファイル)

仕様

  • 画面表示時に /api/time から「現在時刻」を取得して表示する(擬似API)
  • 「更新」ボタンで再取得できる
  • 取得中はローディング表示を出す
  • エラー時はモーダル(ダイアログ)で通知する
  • SVGで「通信ステータス(OK/LOADING/ERROR)」を表示する

注意点

  • useEffect は「副作用(データ取得など)」を行うためのHook
  • コンポーネントのアンマウント後に setState しないよう、AbortController を使ってキャンセルする
  • Next.js(App Router) でクライアントで動かすなら先頭に "use client" が必要
  • CSSは別ファイル(例:TimePanel.css)に分離する

useEffect の最後の }, []); は「依存配列」と「useEffect 呼び出しの閉じ」です

質問の「クセがある」部分は、実は 2つの意味が重なっています。
useEffect(①コールバック関数, ②依存配列) という 関数呼び出しの形になっているためです。

useEffect(() => {
  // ① ここが「副作用の処理(コールバック関数)」
  fetch("/api/inventory")
    .then(res => res.json())
    .then(data => setRows(data));
}, []); // ② ここが「依存配列」 + useEffect(...) の閉じ

1) まず「useEffect は関数」なので、普通の関数と同じカッコ構造です

useEffect は「Hook」ですが、正体は ただの関数です。
なので、文法的にはこうなっています:

// 形だけ書くとこう(普通の関数呼び出し)
useEffect( 引数1, 引数2 );

その 引数1が関数(コールバック)なので、見た目が少し複雑になります。


2) () => { ... } は「後で実行してね」という関数(コールバック)

ここは「今すぐ実行」ではなく、React に “この処理を、指定したタイミングで実行してね” と渡しています。

useEffect(
  () => { /* 後で実行する処理 */ },
  []
);

つまり、この部分:

() => {
  fetch(...);
}

は「無名関数」を作って React に渡しているだけです。


3) [](依存配列)が「いつ実行するか」を決める

[]依存配列(dependency array) と呼ばれ、 「この effect をいつ再実行するか」を決めます。

  • [](空配列): 初回マウント時に1回だけ実行(ページを開いた時の1回)
  • 省略(第2引数なし): 毎回レンダー後に実行(危険:APIが何度も呼ばれやすい)
  • [keyword, page]: keyword や page が変わった時に再実行(検索条件が変わったら再検索)
// 例:検索条件が変わったら再検索
useEffect(() => {
  fetch(`/api/inventory?keyword=${keyword}&page=${page}`)
    .then(r => r.json())
    .then(setRows);
}, [keyword, page]);

4) 最後の ); は「useEffect 関数呼び出しの終了」

改めて見ると、最後はただの「関数呼び出しの閉じ」です。

useEffect( ここまでが引数 );
        ^                ^
        |                |
      開始             終了(閉じ)

そして ; は「この文はここで終わり」と示すセミコロンです (JS/TSでは省略できることも多いですが、付ける方が安全です)。


5) さらに “もう1つクセ” がある:cleanup の戻り値

useEffect のコールバック関数は、 関数を return してよいという特徴があります。 それが cleanup(後始末)です。

useEffect(() => {
  const controller = new AbortController();

  fetch("/api/inventory", { signal: controller.signal });

  // ✅ この return は「アンマウント時や再実行前の後始末」
  return () => {
    controller.abort();
  };
}, []);

つまり、useEffect(() => { ... }, []) の中の return は “関数全体の戻り値”というより、 React に渡す 後始末用の関数です。


まとめ:}, []); を分解するとこう

  • }:コールバック関数のブロック終了
  • ,:useEffect の第1引数と第2引数の区切り
  • []:依存配列(いつ実行するか)
  • ):useEffect(...) の閉じ
  • ;:文の終わり

これが分かると、useEffect の「見た目のクセ」は “関数に関数を渡しているだけ”だと理解できてスッキリします。

useEffect の [] は「わかりにくい」と言われがちな理由

はい、その理解で合っています。 useEffect の依存配列([])は、 React 初学者〜実務者まで混乱しやすい文法として有名です。

特に次のルールが「クセが強い」「直感に反する」と言われます。


基本ルール(公式の考え方)

useEffect の依存配列には、effect の中で参照している「外部の値」をすべて書く というルールがあります。

useEffect(() => {
  fetch(`/api/inventory?keyword=${keyword}&page=${page}`)
    .then(r => r.json())
    .then(setRows);
}, [keyword, page]); // ← 使っている変数を全部書く

ここで言う「外部の値」とは:

  • props(引数で受け取った値)
  • useState で管理している state
  • コンポーネント外で定義された変数

逆に、useEffect の中で 新しく宣言した変数は含めません。


なぜ「全部書け」と言われるのか?

理由はシンプルで、 React が「いつ再実行すべきか」を正しく判断できなくなるからです。

例えば次のコードを見てください。

// ❌ 一見動くが、バグの温床
useEffect(() => {
  fetch(`/api/inventory?keyword=${keyword}`)
    .then(r => r.json())
    .then(setRows);
}, []); // keyword を書いていない

この場合:

  • 初回表示時には keyword の値で fetch される
  • その後 keyword が変わっても effect は再実行されない

結果として、 「画面の入力と表示がズレる」 という業務的に致命的なバグになります。


それでも「全部書くのがつらい」理由

実務では次のような不満がよく出ます。

  • 依存配列がやたら長くなる
  • 1つ足りないだけで ESLint に怒られる
  • 「なんでこの変数まで?」と感じる

これは、 JavaScript のクロージャという仕組みが関係しています。

useEffect のコールバックは、 「そのレンダー時点の値を閉じ込めた関数」 だからです。


クロージャ視点で見ると理解しやすい

function Component() {
  const keyword = "ABC";

  useEffect(() => {
    console.log(keyword);
  }, []);
}

この keyword は、 最初の render 時点の値が固定で閉じ込められます

React はこう考えます:

「この effect は keyword に依存している。 もし keyword が変わったなら、もう一度実行しないとおかしいよね?」

だから依存配列に keyword を書く必要があるのです。


じゃあ [](空配列)はいつ使うの?

[] を使ってよいのは、 「effect の中で外部の値を一切使っていない」 か、 「初回だけでよいと設計上決めている」場合です。

// ✅ OK:外部依存がない
useEffect(() => {
  console.log("mounted");
}, []);

// ⚠ 設計的に OK な例(初回ロード限定)
useEffect(() => {
  fetch("/api/master-data")
    .then(r => r.json())
    .then(setData);
}, []); // 「初期ロード専用」と割り切る

業務では後者はよくありますが、 「なぜ [] なのか」をコメントで明示する のが安全です。


実務的な割り切りパターン(重要)

  • 「検索条件が変わったら再取得」
    → 依存配列に全部書く
  • 「初期ロード専用」
    [] + コメント
  • 「頻繁に変わる関数が邪魔」
    useCallback で安定化させて依存配列を整理

まとめ(正直な結論)

  • [] は「初回だけ」という意味を持つ
  • 何かの値が変わったら再実行したいなら、その値を全部書く必要がある
  • これは React の設計思想(クロージャ安全性)によるもの
  • 分かりにくいが、事故を防ぐための制約

慣れると、 「依存配列 = この effect が成立する条件」 と考えられるようになります。

useEffect は「副作用(データ取得など)」を行うための Hook

React における useEffect は、 「画面を描画する処理(render)」以外のことを行うための Hook です。 これらを総称して 副作用(side effect) と呼びます。

なぜ「副作用」と呼ぶのか?

React の基本的な考え方では、 コンポーネントは「state → UI」を計算する純粋な関数 であるのが理想です。

// 理想的な render(副作用なし)
function View({ count }) {
  return <div>{count}</div>;
}

ところが、実際のアプリでは次のような処理が必要になります。

  • サーバーからデータを取得する(API / DB)
  • タイマーをセットする(setInterval / setTimeout)
  • ブラウザのイベントを登録する(scroll / resize)
  • DOM を直接操作する

これらはすべて 「描画の結果として外の世界に影響を与える」 処理なので、「副作用」と呼ばれます。


例①:在庫検索画面での useEffect

たとえば、在庫一覧画面では 「画面を開いた瞬間に在庫一覧を取得する」必要があります。

// ❌ render 中に書いてはいけない例
function InventoryPage() {
  const data = fetch("/api/inventory"); // 毎回実行されてしまう
  return <Table data={data} />;
}

上記のように書くと、 再レンダーのたびに API が呼ばれるという 重大な問題が起きます。

そこで useEffect を使います。

// ✅ 正しい例:副作用は useEffect に隔離する
function InventoryPage() {
  const [rows, setRows] = useState([]);

  useEffect(() => {
    fetch("/api/inventory")
      .then(res => res.json())
      .then(data => setRows(data));
  }, []); // ← 初回表示時のみ

  return <Table rows={rows} />;
}

このようにすることで、 「描画」と「データ取得」を明確に分離できます。


例②:「依存配列」がある理由(業務あるある)

次に、「検索条件が変わったら再検索したい」ケースです。

function InventoryPage({ keyword }) {
  const [rows, setRows] = useState([]);

  useEffect(() => {
    fetch(`/api/inventory?keyword=${keyword}`)
      .then(res => res.json())
      .then(data => setRows(data));
  }, [keyword]); // ← keyword が変わった時だけ再実行

  return <Table rows={rows} />;
}

ここでの依存配列 [keyword] は、 「この副作用は、keyword が変わった時だけ意味がある」 という宣言です。

業務画面でよくある例:

  • 検索条件が変わった → 再検索
  • ページ番号が変わった → 再検索
  • ログインユーザーが変わった → 再取得

例③:cleanup が必要な理由(実務で重要)

副作用の中には、 「後始末(cleanup)」が必要なもの があります。

代表例が「イベント登録」や「通信」です。

useEffect(() => {
  const controller = new AbortController();

  fetch("/api/inventory", { signal: controller.signal });

  return () => {
    // 画面遷移・アンマウント時に通信を中断
    controller.abort();
  };
}, []);

これをしないと、 「画面が消えた後に setState が走る」 というバグが起きます。

業務システムではこの手のバグは 「再現しにくく、原因が分かりにくい」ため、 非常に嫌われます


まとめ(実務目線)

  • render は純粋に UI を返すだけ
  • 外の世界に触る処理は useEffect に隔離
  • 依存配列は「いつ実行したいか」の宣言
  • cleanup は「業務品質」を守るための必須作業

この考え方が身につくと、 React のコードは 業務向けでも破綻しにくくなります。

画面(SVGイメージ)

現在時刻パネル(useEffect) STATUS: LOADING server time: 2026-02-18 13:05:00 更新 エラー:時刻の取得に失敗しました。時間をおいて再度お試しください。

コード

1) Reactコンポーネント(useEffect)

/**
 * TimePanel.tsx
 * ------------------------------------------------------------
 * 役割:
 * - useEffect を使い、画面表示時にAPIからデータ(現在時刻)を取得して表示する
 * - 「更新」ボタンで再取得できる
 * - 取得中/成功/失敗を UI に反映し、失敗時はモーダルで通知する
 *
 * ポイント:
 * - useEffect は「副作用(fetch等)」に使う
 * - AbortController でアンマウント時の fetch を中断し、不要な setState を防ぐ
 *
 * Next.js(App Router) で使う場合:
 * - クライアントコンポーネントとして動かすため、先頭に "use client" が必要
 */

// "use client"; // Next.js(app router) で使うときはコメントを外す

import React, { useEffect, useMemo, useState } from "react";
import "./TimePanel.css";

/**
 * Status
 * ------------------------------------------------------------
 * 役割:
 * - 通信状態を表すユニオン型
 */
type Status = "IDLE" | "LOADING" | "OK" | "ERROR";

/**
 * ApiResponse
 * ------------------------------------------------------------
 * 役割:
 * - /api/time の想定レスポンス型
 * - 例:{ ok: true, now: "2026-02-18 13:05:00" }
 */
type ApiResponse =
  | { ok: true; now: string }
  | { ok: false; message: string };

/**
 * safeMessage()
 * ------------------------------------------------------------
 * 役割:
 * - 画面に出して良い安全なメッセージを返す(業務UI風)
 */
function safeMessage(): string {
  return "時刻の取得に失敗しました。時間をおいて再度お試しください。";
}

/**
 * TimePanel()
 * ------------------------------------------------------------
 * 役割:
 * - useEffect で初回ロード時に時刻取得
 * - ボタンで再取得
 * - エラー時モーダル表示
 */
export default function TimePanel() {
  // ---------------------------
  // State:時刻 / 状態 / エラー
  // ---------------------------
  const [now, setNow] = useState<string>("----/--/-- --:--:--");
  const [status, setStatus] = useState<Status>("IDLE");
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  /**
   * statusLabel
   * ----------------------------------------------------------
   * 役割:
   * - 画面表示用のラベルを作る(例として useMemo)
   */
  const statusLabel = useMemo(() => {
    return status;
  }, [status]);

  /**
   * fetchTime()
   * ----------------------------------------------------------
   * 役割:
   * - APIから時刻を取得して state に反映する
   * - AbortController を受け取り、キャンセル可能にする
   */
  async function fetchTime(controller: AbortController): Promise<void> {
    try {
      // 取得開始:状態更新
      setStatus("LOADING");
      setErrorMessage(null);

      // API呼び出し(擬似:/api/time)
      const res = await fetch("/api/time", {
        method: "GET",
        cache: "no-store",
        signal: controller.signal,
      });

      const json = (await res.json()) as ApiResponse;

      // APIが安全文言で失敗を返した場合
      if (!json.ok) {
        setStatus("ERROR");
        setErrorMessage(json.message || safeMessage());
        return;
      }

      // 成功
      setNow(json.now);
      setStatus("OK");
    } catch (e: any) {
      // Abort は “エラー扱いにしない” のが業務UIで親切
      if (e?.name === "AbortError") {
        return;
      }

      console.error("[TimePanel] fetchTime error:", e);
      setStatus("ERROR");
      setErrorMessage(safeMessage());
    }
  }

  /**
   * onReload()
   * ----------------------------------------------------------
   * 役割:
   * - 「更新」ボタン押下で再取得する
   * - その場で AbortController を作り、終了後は破棄する
   */
  function onReload() {
    const controller = new AbortController();
    fetchTime(controller);

    // 連打対策などを厳密にやるならここに “前回のcontrollerをabort” を持つ設計も可能
  }

  /**
   * closeModal()
   * ----------------------------------------------------------
   * 役割:
   * - エラーモーダルを閉じる
   */
  function closeModal() {
    setErrorMessage(null);
  }

  /**
   * useEffect(初回ロード)
   * ----------------------------------------------------------
   * 役割:
   * - コンポーネント初回表示時に1回だけ時刻取得
   * - cleanup で fetch を中断する(アンマウント時の setState を防ぐ)
   */
  useEffect(() => {
    const controller = new AbortController();

    // 初回取得
    fetchTime(controller);

    // cleanup(アンマウント時)
    return () => {
      controller.abort();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className="tp-card">
      <div className="tp-head">
        <h3 className="tp-title">現在時刻パネル(useEffect)</h3>

        <span className={`tp-badge tp-badge--${status.toLowerCase()}`}>
          STATUS: {statusLabel}
        </span>
      </div>

      {/* エラーモーダル(上に表示) */}
      {errorMessage && (
        <div className="tp-modal" role="dialog" aria-modal="true">
          <div className="tp-modal__title">エラー</div>
          <div className="tp-modal__msg">{errorMessage}</div>
          <button className="tp-btn tp-btn--dark" type="button" onClick={closeModal}>
            閉じる
          </button>
        </div>
      )}

      {/* 本文 */}
      <div className="tp-body">
        <div className="tp-timebox">
          <div className="tp-timebox__label">server time:</div>
          <div className="tp-timebox__value">{now}</div>
        </div>

        <button
          className="tp-btn tp-btn--dark"
          type="button"
          onClick={onReload}
          disabled={status === "LOADING"}
          title={status === "LOADING" ? "取得中です" : "再取得します"}
        >
          {status === "LOADING" ? "取得中..." : "更新"}
        </button>
      </div>

      {/* SVGステータス(実体も表示) */}
      <svg className="tp-svg" viewBox="0 0 760 56" role="img" aria-label="通信ステータス表示">
        <rect x="14" y="18" width="560" height="20" rx="10" className="tp-svg__bg" />

        {/* 状態により “塗り” を変える(CSSクラスで制御) */}
        <rect
          x="14"
          y="18"
          width={status === "OK" ? 560 : status === "LOADING" ? 340 : status === "ERROR" ? 120 : 0}
          height="20"
          rx="10"
          className={`tp-svg__fill tp-svg__fill--${status.toLowerCase()}`}
        />

        <text x="600" y="34" className="tp-svg__text">{status}</text>
      </svg>

      <p className="tp-note">
        ※ 初回表示時に自動取得し、アンマウント時は AbortController でキャンセルします。
      </p>
    </div>
  );
}

2) CSS(別ファイル:TimePanel.css)

/**
 * TimePanel.css
 * ------------------------------------------------------------
 * 役割:
 * - TimePanel.tsx の見た目を定義する(CSS分離)
 * - “状態(OK/LOADING/ERROR)” の色味はクラスで切り替える
 */

.tp-card {
  max-width: 820px;
  padding: 16px;
  border: 1px solid rgba(0,0,0,.12);
  border-radius: 16px;
  background: #fff;
}

.tp-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 10px;
}

.tp-title {
  margin: 0;
  font-size: 18px;
}

.tp-badge {
  border: 1px solid #e5e7eb;
  background: #f1f5f9;
  border-radius: 999px;
  padding: 6px 10px;
  font-size: 12px;
  color: #334155;
  white-space: nowrap;
}

/* 状態別(バッジ) */
.tp-badge--ok { }
.tp-badge--loading { }
.tp-badge--error { }

.tp-body {
  display: flex;
  gap: 12px;
  align-items: center;
  flex-wrap: wrap;
}

.tp-timebox {
  flex: 1;
  min-width: 320px;
  padding: 12px;
  border-radius: 12px;
  background: #f9fafb;
  border: 1px solid #e5e7eb;
}

.tp-timebox__label {
  font-size: 12px;
  color: #6b7280;
  margin-bottom: 6px;
}

.tp-timebox__value {
  font-size: 18px;
  font-weight: 700;
  color: #111827;
}

.tp-btn {
  border-radius: 10px;
  padding: 10px 14px;
  cursor: pointer;
}

.tp-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.tp-btn--dark {
  border: none;
  background: #111827;
  color: #fff;
}

/* モーダル(画面上の通知) */
.tp-modal {
  border: 1px solid #f59e0b;
  background: #fff7d6;
  border-radius: 12px;
  padding: 12px;
  margin: 10px 0 12px 0;
}

.tp-modal__title {
  font-weight: 800;
  margin-bottom: 6px;
  color: #6a4b00;
}

.tp-modal__msg {
  margin-bottom: 10px;
  color: #6a4b00;
}

/* SVG */
.tp-svg {
  width: 100%;
  height: 56px;
  margin-top: 10px;
}

.tp-svg__bg {
  fill: #f1f5f9;
  stroke: #e5e7eb;
}

.tp-svg__text {
  font-size: 12px;
  fill: #111827;
}

/* 状態別:バーの塗り */
.tp-svg__fill--ok {
  fill: #111827;
}

.tp-svg__fill--loading {
  fill: #94a3b8;
}

.tp-svg__fill--error {
  fill: #f59e0b;
}

.tp-note {
  margin: 10px 0 0 0;
  font-size: 12px;
  color: #6b7280;
}

3) (任意)擬似APIの例(/api/time)

/**
 * app/api/time/route.ts
 * ------------------------------------------------------------
 * 役割:
 * - サンプル用に現在時刻を返すAPI(Next.js Route Handler)
 * - 実運用ではサーバ側でDB/外部APIから取得する想定
 */

import { NextResponse } from "next/server";

export async function GET() {
  try {
    // 現在時刻を “それっぽい文字列” にする(簡易)
    const now = new Date();
    const pad = (n: number) => String(n).padStart(2, "0");

    const s =
      `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
      `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;

    return NextResponse.json({ ok: true, now: s }, { status: 200 });
  } catch (e) {
    console.error("[GET /api/time] error:", e);
    return NextResponse.json(
      { ok: false, message: "時刻の取得に失敗しました。時間をおいて再度お試しください。" },
      { status: 500 }
    );
  }
}

useMemo とは何か(意味・使い方・注意点)

意味(何のための Hook?)

useMemo は、 「計算結果を記憶(メモ化)して、不要な再計算を防ぐ」 ための React Hook です。

React では state が変わるとコンポーネント全体が再実行されます。 その際、重い計算や無駄な処理まで毎回実行されると、 業務画面では 表示遅延・入力の引っかかり が発生します。

useMemo は次の問いに答えるための仕組みです。

「この計算結果は、ある値が変わらない限り再計算しなくていいのでは?」

使い方(基本形)

const memoizedValue = useMemo(() => {
  // 重い計算・まとめ処理
  return result;
}, [依存値]);
  • 第1引数:計算を行う関数
  • 第2引数:依存配列(これが変わった時だけ再計算)

つまり、 「依存配列が変わらない限り、前回の結果を再利用する」 という宣言です。


画面イメージ(SVG)

商品一覧フィルタ(useMemo) 検索キーワード ・商品A ・商品B ・商品C useMemo ✔ キーワードが変わった時だけ ✔ フィルタ処理を再実行 ✖ 無関係な再描画では再計算しない

注意点(重要)

  • すべての計算に使う必要はない
    軽い計算に使うと、逆にコードが読みにくくなる
  • 依存配列は正確に書く
    計算に使っている変数はすべて含める
  • 「キャッシュ」ではなく「再計算制御」
    永続保存ではなく、再レンダー間の最適化
  • 表示が重い・件数が多い画面で真価を発揮
    一覧・集計・フィルタ・ソート処理

コード(useMemo サンプル)


/**
 * ProductFilter.tsx
 * ------------------------------------------------------------
 * 役割:
 * - useMemo を使って「商品一覧のフィルタ結果」をメモ化する
 * - 入力(keyword)が変わった時だけフィルタ処理を再実行する
 *
 * 想定シーン:
 * - 件数が多い業務用一覧画面
 * - 入力中に毎回重い処理を走らせたくない
 */

// "use client"; // Next.js(App Router) で使う場合は有効化

import React, { useMemo, useState } from "react";
import "./ProductFilter.css";

/**
 * Product
 * ------------------------------------------------------------
 * 商品データの型
 */
type Product = {
  id: number;
  name: string;
};

/**
 * ProductFilter
 * ------------------------------------------------------------
 * useMemo の実例コンポーネント
 */
export default function ProductFilter() {
  // 入力キーワード
  const [keyword, setKeyword] = useState("");

  // 商品一覧(本来はAPI取得)
  const products: Product[] = [
    { id: 1, name: "商品A" },
    { id: 2, name: "商品B" },
    { id: 3, name: "商品C" },
    { id: 4, name: "サンプル商品D" },
  ];

/**
 * filteredProducts
 * ----------------------------------------------------------
 * 役割:
 * - keyword を使って products をフィルタする
 * - keyword が変わらない限り、前回の結果を再利用する
 */

  const filteredProducts = useMemo(() => {
    console.log("フィルタ処理を実行");
    return products.filter(p =>
      p.name.toLowerCase().includes(keyword.toLowerCase())
    );
  }, [keyword, products]);

  return (
    <div className="pf-card">
      <h3 className="pf-title">商品一覧</h3>

      <input
        className="pf-input"
        placeholder="商品名で検索"
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
      />

      <ul className="pf-list">
        {filteredProducts.map(p => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </div>
  );
}

CSS(別ファイル)

/**
 * ProductFilter.css
 * ------------------------------------------------------------
 * useMemo サンプル用スタイル
 */

.pf-card {
  max-width: 480px;
  padding: 16px;
  border: 1px solid rgba(0,0,0,.12);
  border-radius: 16px;
  background: #ffffff;
}

.pf-title {
  margin: 0 0 10px 0;
  font-size: 18px;
}

.pf-input {
  width: 100%;
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid #e5e7eb;
  margin-bottom: 12px;
}

.pf-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.pf-list li {
  padding: 8px 10px;
  border-bottom: 1px solid #f1f5f9;
}
まとめ:
useMemo は「速くする魔法」ではなく、 再計算が不要な場面を宣言するための道具です。
業務UIでは「一覧・集計・フィルタ」で使うと効果が分かりやすくなります。

useCallback とは何か(概念・使い方・注意点)

概念:useCallback は「関数の再生成を止めるための Hook」

useCallback は、 「関数そのものをメモ化する」 ための React Hook です。

React では、state が変わるたびに コンポーネント関数が最初から最後まで再実行されます。 その結果、次のことが起きます。

useCallback は、この問題に対してこう宣言します。

「この関数は、特定の値が変わらない限り同一のものとして扱っていい

useMemo との違い(混乱しやすいポイント)

// useMemo:値を固定
const total = useMemo(() => calcTotal(items), [items]);

// useCallback:関数を固定
const onSave = useCallback(() => {
  save(items);
}, [items]);

実は内部的には、 useCallback(fn, deps)useMemo(() => fn, deps) とほぼ同じ意味です。


画面イメージ(SVG)

親コンポーネント state 更新 関数を毎回再生成 子コンポーネント props の関数が変わる → 再レンダー useCallback で「関数の参照」を固定

使い方(基本形)

const onClickSave = useCallback(() => {
  // ボタン押下時の処理
}, [依存値]);

依存配列に書いた値が変わらない限り、 同じ関数インスタンスが再利用されます。


コード例(業務画面風)


/**
 * InventoryActions.tsx
 * ------------------------------------------------------------
 * 役割:
 * - useCallback を使い、子コンポーネントに渡す関数を安定させる
 * - 不要な再レンダーを防ぐ
 *
 * 想定:
 * - 業務用一覧画面
 * - 行コンポーネントが多く、パフォーマンスが重要
 */

import React, { useCallback, useState } from "react";
import "./InventoryActions.css";

/**
 * RowProps
 * ------------------------------------------------------------
 * 行コンポーネント用 props
 */
type RowProps = {
  id: number;
  onDelete: (id: number) => void;
};

/**
 * Row
 * ------------------------------------------------------------
 * 子コンポーネント
 * React.memo により props が変わらなければ再描画しない
 */
const Row = React.memo(function Row({ id, onDelete }: RowProps) {
  console.log("Row render:", id);
  return (
    <div className="row">
      行ID: {id}
      <button onClick={() => onDelete(id)}>削除</button>
    </div>
  );
});

/**
 * InventoryActions
 * ------------------------------------------------------------
 * 親コンポーネント
 */
export default function InventoryActions() {
  const [count, setCount] = useState(0);

  /**
   * onDelete
   * ----------------------------------------------------------
   * useCallback により「同一参照の関数」を維持
   */
  const onDelete = useCallback((id: number) => {
    console.log("delete:", id);
  }, []);

  return (
    <div className="box">
      <button onClick={() => setCount(c => c + 1)}>
        親の state 更新: {count}
      </button>

      <Row id={1} onDelete={onDelete} />
      <Row id={2} onDelete={onDelete} />
    </div>
  );
}

注意点(実務で重要)


CSS(別ファイル)


/**
 * InventoryActions.css
 * ------------------------------------------------------------
 * useCallback サンプル用スタイル
 */

.box {
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  max-width: 420px;
}

.row {
  display: flex;
  justify-content: space-between;
  padding: 8px 0;
  border-bottom: 1px solid #f1f5f9;
}

button {
  padding: 6px 10px;
  border-radius: 8px;
  border: 1px solid #cbd5f5;
  background: #f8fafc;
  cursor: pointer;
}
まとめ(概念の核心)
useCallback は「速くする魔法」ではなく、
「この関数は同じものとして扱っていい」と React に伝える契約です。
業務UIでは「一覧 × 子コンポーネント × 関数 props」の場面で真価を発揮します。

useRef を「実際の業務アプリの中」で理解する

前提:在庫検索画面(実在しそうな業務画面)

想定するのは、あなたがこれまで扱ってきたような 在庫検索・一覧画面です。

ここで問題になるのが、 「前回の検索条件を覚えておきたい」 という業務要件です。


業務で実際に起きる課題

次のような要件は、かなり現実的です。

・検索条件が前回と同じなら API を呼ばない ・ページ遷移時に「検索条件が変わったか」を判定したい ・ログに「どこが変わったか」を残したい

ここで多くの人が最初にやりがちな間違いがあります。


// ❌ よくある失敗(useStateで前回値を持つ)
const [prevKeyword, setPrevKeyword] = useState("");

useEffect(() => {
  if (prevKeyword !== keyword) {
    fetchInventory();
    setPrevKeyword(keyword);
  }
}, [keyword, prevKeyword]);

これは一見正しそうですが、 state 更新が原因で再レンダーが増える、 場合によっては 無限ループ の温床になります。


ここで useRef が「業務的に正解」になる

前回の検索条件は、

つまりこれは state ではなく useRef の仕事です。


業務画面ベースの useRef 実装例


/**
 * InventoryPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 在庫検索画面
 * - 検索条件が変わったときだけ API を呼ぶ
 * - 前回の検索条件は useRef で保持する
 */

import React, { useEffect, useRef, useState } from "react";
import "./InventoryPage.css";

export default function InventoryPage() {
  // 検索条件(画面に影響するので state)
  const [keyword, setKeyword] = useState("");
  const [rows, setRows] = useState([]);

  /**
   * prevKeywordRef
   * ----------------------------------------------------------
   * 前回の検索条件を保持
   * ・変更しても再レンダーしない
   * ・比較用途のみ
   */
  const prevKeywordRef = useRef<string>("");

  useEffect(() => {
    // 初回 or 検索条件変更時のみ実行
    if (prevKeywordRef.current !== keyword) {
      console.log("検索条件変更", {
        before: prevKeywordRef.current,
        after: keyword,
      });

      fetch(`/api/inventory?keyword=${keyword}`)
        .then(res => res.json())
        .then(data => setRows(data));

      // 次回比較用に保存
      prevKeywordRef.current = keyword;
    }
  }, [keyword]);

  return (
    <div className="inv-box">
      <h3>在庫検索</h3>

      <input
        className="inv-input"
        placeholder="商品名"
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
      />

      <table className="inv-table">
        <thead>
          <tr>
            <th>商品名</th>
            <th>在庫数</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((r: any) => (
            <tr key={r.id}>
              <td>{r.name}</td>
              <td>{r.stock}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}


画面イメージ(SVG:実アプリ文脈)

在庫検索画面 商品名 商品A | 10 商品B | 5 商品C | 0 useRef の役割 前回の検索条件を保持 UIには影響しない

なぜこれが「サンプルのためのサンプル」ではないのか

useRef は 「Reactの裏側で、業務ロジックを支える道具」 です。
だからこそ、 画面に出ないが重要な値 を扱うときに 本領を発揮します。

結論:
useRef は「表示しない業務情報(前回値・ID・制御用オブジェクト)」を 安全に保持するための箱
実務では「比較・制御・最適化」のために使われます。

useContext を「実際の業務アプリの中」で理解する

前提:在庫管理システム(よくある業務アプリ)

想定するのは、在庫管理・受注管理を行う社内業務システムです。 多くの画面で「共通して使われる情報」が存在します。

ここで問題になるのが、 「この情報を、どうやって各画面・各コンポーネントに渡すか」 です。


業務で実際に起きる問題(props地獄)

useContext を使わない場合、よく次のようになります。

// ❌ props を延々と渡す(props drilling)
<App user={user} warehouse={warehouse}>
  <Layout user={user} warehouse={warehouse}>
    <InventoryPage user={user} warehouse={warehouse}>
      <InventoryTable user={user} warehouse={warehouse} />
    </InventoryPage>
  </Layout>
</App>

この構造は業務システムではほぼ必ず破綻します。

ここで useContext が登場します。


概念:useContext は「業務アプリの共有棚」

useContext は、 アプリ全体(または一部)で共有したい値をまとめて置く場所 を作る仕組みです。

「この情報は、どの画面からでも参照できていい」

典型的には次のような情報を置きます。


画面イメージ(SVG:実アプリ文脈)

AppContext ・user ・warehouse ・role 在庫一覧画面 受注登録画面 共通ヘッダ

コード例(在庫管理アプリ)

/**
 * AppContext.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 業務アプリ全体で共有する情報を定義する
 * - user / warehouse / role などを一元管理
 */

import React, { createContext, useContext } from "react";

/**
 * AppContextValue
 * ----------------------------------------------------------
 * アプリ全体で共有する値の型
 */
export type AppContextValue = {
  userName: string;
  warehouse: string;
  role: "admin" | "operator";
};

/*
 * AppContext
 * ----------------------------------------------------------
 * Context 本体
 */
const AppContext = createContext<AppContextValue | null>(null);

/*
 * useAppContext
 * ----------------------------------------------------------
 * Context を安全に使うためのカスタムHook
 */
export function useAppContext(): AppContextValue {
  const ctx = useContext(AppContext);
  if (!ctx) {
    throw new Error("useAppContext must be used within AppProvider");
  }
  return ctx;
}

/*
 * AppProvider
 * ----------------------------------------------------------
 * Context の提供者
 */
export function AppProvider({ children }: { children: React.ReactNode }) {
  const value: AppContextValue = {
    userName: "山田 太郎",
    warehouse: "東京倉庫",
    role: "admin",
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

/**
 * InventoryPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - useContext を使って共有情報を取得
 * - props を一切受け取らない業務画面
 */


import React from "react";
import { useAppContext } from "./AppContext";
import "./InventoryPage.css";

export default function InventoryPage() {
  const { userName, warehouse, role } = useAppContext();

  return (
    <div className="inv-box">
      <h3>在庫一覧({warehouse})</h3>
      <p>ログインユーザー:{userName}</p>
      <p>権限:{role}</p>
    </div>
  );
}

注意点(実務で重要)


CSS(別ファイル)


/**
 * InventoryPage.css
 * ------------------------------------------------------------
 * 在庫画面用スタイル
 */


.inv-box {
  max-width: 420px;
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
}
結論(業務目線)
useContext は「React版のグローバル業務情報」。
props地獄を回避し、画面設計を現実的に保つための必須ツールです。

React の props を「業務アプリの文脈」で理解する

概念:props は「親 → 子」への業務データの受け渡し

React における props は、 親コンポーネントから子コンポーネントへ渡す入力データです。

業務システムで例えるなら、

「画面Aが用意した情報を、画面A配下の部品Bに渡す」

という、ごく自然な構造です。


具体例:在庫一覧画面

在庫一覧画面では、 「検索結果一覧」と「1行分の表示」は役割が異なります。


/**
 * InventoryPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 在庫検索・一覧取得(業務ロジック)
 * - 行コンポーネントに表示用データを渡す
 */

function InventoryPage() {
  const rows = [
    { id: 1, name: "商品A", stock: 10 },
    { id: 2, name: "商品B", stock: 0 },
  ];

  return (
    <table>
      <tbody>
        {rows.map(row => (
          <InventoryRow
            key={row.id}
            name={row.name}
            stock={row.stock}
          />
        ))}
      </tbody>
    </table>
  );
}
/**
 * InventoryRow.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 在庫1行分の「表示だけ」を担当
 */

type Props = {
  name: string;
  stock: number;
};

function InventoryRow({ name, stock }: Props) {
  return (
    <tr>
      <td>{name}</td>
      <td>{stock}</td>
    </tr>
  );
}

ここで重要なのは:

これが props の最も健全な使い方です。


なぜ props は変更できないのか

props は「親から渡された入力値」なので、 子が勝手に変更すると データの責務が壊れます

// ❌ やってはいけない
props.stock = 99;

React では必ず次の形を取ります。

// ✅ 親に変更を依頼する
<InventoryRow
  stock={stock}
  onChangeStock={(newStock) => setStock(newStock)}
/>

つまり:

「データを持つのは親、操作は子」

props と state の違い(業務で混乱しやすい点)

項目 props state
所有者 親コンポーネント 自分自身
変更 不可 可能
用途 入力・設定値 画面の状態

業務画面ではこの区別が曖昧になると、 設計が破綻します。


props が増えすぎるとどうなるか

実務ではよく次の状態になります。

// props地獄
<InventoryRow
  name={name}
  stock={stock}
  warehouse={warehouse}
  role={role}
  userName={userName}
  onEdit={onEdit}
  onDelete={onDelete}
/>

この段階で検討すべきなのが:

props は便利ですが、 「近い親子関係」で使うのが原則です。


実務での props の心得

結論:
props は React の「設計を健全に保つための基本ルール」。
業務アプリでは 責務分離の境界線として使います。

props のバケツリレー(props drilling)を防ぐ方法(HTML表現)

props drilling とは、本当は中間コンポーネントが不要なのに、 「親→孫→ひ孫…」へ props を延々と渡す状態です。


0) 典型的な “バケツリレー” の例(悪い例)

<App user={user} warehouse={warehouse} onError={onError}>
  <Layout user={user} warehouse={warehouse} onError={onError}>
    <InventoryPage user={user} warehouse={warehouse} onError={onError}>
      <InventoryTable user={user} warehouse={warehouse} onError={onError} />
    </InventoryPage>
  </Layout>
</App>

この構造だと、LayoutInventoryPageただ渡すだけの中継所になりがちです。


1) Context を使う(共通情報を共有棚に置く)

ログインユーザー / 権限 / 拠点など、 多くの画面で使う値は Context に寄せると中継が消えます。

<AppProvider value={{ user, warehouse, role }}>
  <Layout>
    <InventoryPage>
      <InventoryTable />  <!-- 必要な値は useContext で直接読む -->
    </InventoryPage>
  </Layout>
</AppProvider>

✅ 中間の props リレーが消える。
⚠️ 頻繁に変わる値を全部入れると再レンダーが広がるので、Context は分割がコツ。


2) コンポーネント設計の見直し(状態を持つ場所を “寄せる”)

「その値は本当に上から渡す必要があるか?」を疑います。 例えば、Row が必要な情報は Table 内で完結させる、などです。

<InventoryPage>
  <InventoryTable>
    <!-- Row は Table が作る。Page から Row へ props を運ばない -->
  </InventoryTable>
</InventoryPage>

✅ 一番“業務コードが読みやすく”なりやすい。
⚠️ 分割しすぎ(細かすぎる部品化)が props drilling の原因になることが多い。


3) children / slot(合成)で中間を “枠” にする

中間コンポーネントは「見た目の枠」だけ担当し、 データは渡さずに children として中身をそのまま入れます。

<Layout>
  <!-- Layout は user を受け取らない。中身は children として渡すだけ -->
  <InventoryPage />
</Layout>

✅ 「枠」と「中身」を分離できるので設計が綺麗になりやすい。
⚠️ データを持つ場所(Page側)に責務が寄るので整理が必要。


4) カスタムHookで “渡すべきもの” を減らす

props が増える理由が「取得・変換・判定ロジック」なら、 Hook にまとめて返す値を整理します。

<InventoryPage>
  <InventoryTable
    rows={rows}
    loading={loading}
    error={error}
  />
</InventoryPage>

<!-- InventoryPage 内で useInventorySearch() を使って必要なものだけ取り出す -->

✅ 「渡す props の数」を物理的に減らせる。
⚠️ Hook が巨大化しないよう、役割ごとに分けるのが実務的。


5) URL(searchParams)に寄せる(Next.js だと強い)

検索条件やページ番号は props で運ぶより、 URL を単一の真実にすると自然に整理されます。

/inventory?keyword=abc&page=2

<InventoryPage>
  <InventoryTable />
</InventoryPage>

<!-- keyword / page は URL から読む。中間 props が不要になる -->

✅ リロード・共有・戻る/進む が自然に成立(業務システム向き)。
⚠️ URL設計(パラメータ名・型)を最初に決める必要がある。


実務向けの結論(どれを選ぶ?)

「Context を使う」の本当の意味(見えない部分を可視化)

まず結論:Context は「目に見えない共有棚」

Context は JSX ツリー上に 直接は見えません。 しかし、Provider が置かれた位置より下のすべてのコンポーネントは、 その Context に置かれた値を useContext で取得できます。


① Context を「明示的に定義」する(これが本体)

まず、Context そのものを定義します。 ここで初めて Context という実体が登場します。

// AppContext.ts
const AppContext = React.createContext(null);

この AppContext が、

「アプリ全体で共有するための棚」

です。


② Provider は「棚に値を置く人」

次に登場するのが Provider です。

<AppContext.Provider value={{ user, warehouse, role }}>
  {children}
</AppContext.Provider>

これは日本語にするとこうです:

「これより下のコンポーネントは、
user / warehouse / role を自由に使っていい

ここで重要なのは:


③ AppProvider は「Provider を包んだだけの部品」

よく見るこの形:

<AppProvider>
  <Layout>
    <InventoryPage>
      <InventoryTable />
    </InventoryPage>
  </Layout>
</AppProvider>

実体はこうなっています:

// AppProvider.tsx
function AppProvider({ children }) {
  const value = {
    user,
    warehouse,
    role,
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

つまり AppProvider は、

「Context.Provider を隠しているだけの薄いラッパー」

です。


④ useContext は「棚から値を取る人」

Context を使う側で、初めて useContext が出てきます。

// InventoryTable.tsx
const { user, warehouse, role } = useContext(AppContext);

これはこう読めます:

「一番近い AppContext.Provider に置かれている値を取ってくる」

つまり、


⑤ なぜ「Context が JSX に出てこない」のか

あなたが違和感を覚えた最大の理由はここです。

JSX 上では:

<Layout>
  <InventoryPage>
    <InventoryTable />
  </InventoryPage>
</Layout>

なのに、

「なぜ InventoryTable が user を持っているのか分からない」

これは Context が「見えない依存関係」だからです。

そのため実務では、

といった 可読性対策が必須になります。


⑥ 「頻繁に変わる値を入れるな」の本当の意味

Context の値が変わると、

「その Context を使っている全コンポーネントが再レンダー」

されます。

例えば:

value={{ user, warehouse, searchKeyword }}

ここで searchKeyword が1文字入力されるたびに変わると、

すべて再レンダーされます。

だから:

「Context は 業務的に安定した共通情報だけに使う」

最終まとめ(腑に落とす一文)

Context とは、
「props を運ぶ代わりに、ツリーの途中に“共有棚”を置く仕組み」

JSX に Context が直接出てこないのは、 Provider が “環境” を作っているだけだからです。

カスタムHookの作り方(実在する業務アプリの例で理解する)

前提:在庫検索画面という「実際にあり得る画面」

想定するのは、次のような 在庫検索・一覧画面 です。

まずは カスタムHookを使わない状態 を見てみます。


① カスタムHookを使わない場合(現場でよく見る形)

/**
 * InventoryPage.tsx(Hookなし)
 * ------------------------------------------------------------
 * 問題点:
 * - useState / useEffect / fetch / エラー処理が画面に密集
 * - 画面ロジックと業務ロジックが混ざる
 */

function InventoryPage() {
  const [keyword, setKeyword] = useState("");
  const [rows, setRows] = useState&lt;any[]&gt;([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState&lt;string | null&gt;(null);

  useEffect(() =&gt; {
    setLoading(true);
    setError(null);

    fetch(`/api/inventory?keyword=${keyword}`)
      .then(res =&gt; res.json())
      .then(data =&gt; setRows(data))
      .catch(() =&gt; setError("在庫取得に失敗しました"))
      .finally(() =&gt; setLoading(false));
  }, [keyword]);
}


  return (
    <>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      {loading && <p>読み込み中...</p>}
      {error && <p>{error}</p>}
      {/* 一覧表示 */}
    </>
  );
}

これは動きますが、実務では次の問題が出ます。


② 「業務ロジック」を抜き出したくなる瞬間

ここで考えるべき問いはこれです。

この画面がやっていることは、
「在庫検索」という業務処理ではないか?

そうであれば、それは 画面(JSX)ではなく、Hook に閉じ込める のが自然です。


③ カスタムHookを作る(これが本体)


/**
 * useInventorySearch.ts
 * ------------------------------------------------------------
 * 役割:
 * - 在庫検索という「業務ロジック」をまとめる
 * - 画面から useState / useEffect / fetch を隠す
 */

import { useEffect, useState } from "react";

export function useInventorySearch() {
  // 検索条件
  const [keyword, setKeyword] = useState("");

  // 結果・状態
  const [rows, setRows] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(`/api/inventory?keyword=${keyword}`)
      .then(res => res.json())
      .then(data => setRows(data))
      .catch(() => setError("在庫取得に失敗しました"))
      .finally(() => setLoading(false));
  }, [keyword]);

  /**
   * Hook の戻り値
   * ----------------------------------------------------------
   * 画面が「知りたいこと・操作したいこと」だけ返す
   */
  return {
    keyword,
    setKeyword,
    rows,
    loading,
    error,
  };
}

ここで重要なのは:


④ 画面側は「使うだけ」になる

/**
 * InventoryPage.tsx(カスタムHook使用)
 * ------------------------------------------------------------
 * 役割:
 * - 表示とイベントに集中
 * - 業務ロジックを知らない
 */

import { useInventorySearch } from "./useInventorySearch";

function InventoryPage() {
  const {
    keyword,
    setKeyword,
    rows,
    loading,
    error,
  } = useInventorySearch();

  return (
    <div>
      <input
        value={keyword}
        onChange={e => setKeyword(e.target.value)}
      />

      {loading && <p>読み込み中...</p>}
      {error && <p>{error}</p>}

      <table>
        <tbody>
          {rows.map(r => (
            <tr key={r.id}>
              <td>{r.name}</td>
              <td>{r.stock}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

画面は今や、

「どんな業務処理か」を知らなくてよい

状態になっています。


⑤ なぜこれが「実務で効く」のか


⑥ カスタムHook設計の判断基準(覚え方)

このどれかに当てはまったら、

「それ、カスタムHookにできる」
結論:
カスタムHookとは、 React における「業務処理の部品化」
JSX を綺麗に保ち、設計を長持ちさせるための道具です。

在庫管理システム(React学習用)仕様 & 学べるHooks対応表

1. 目的(学習ゴール)


2. 機能一覧


3. 画面遷移(業務フロー)

  1. ユーザーログイン画面
  2. ログイン成功 → 在庫一覧画面へ遷移
  3. 在庫一覧画面で「追加」ボタン → 在庫新規登録画面
  4. 在庫新規登録画面で「追加」ボタン → 登録 → 在庫一覧画面へ戻る
  5. 在庫一覧画面で任意行の「編集」ボタン → 在庫編集画面
  6. 在庫編集画面で「更新」ボタン → 更新 → 在庫一覧画面へ戻る

4. データ仕様

4.1 在庫テーブル(inventory)

4.2 ユーザーテーブル(users)

※ 学習用のため、パスワードの暗号化などは後回し(本番では必須)。


5. 画面仕様

5.1 ログイン画面

5.2 在庫一覧画面

5.3 在庫新規登録画面

5.4 在庫編集画面


6. React Hooks 学習マップ(どの画面で何を学べるか)

6.1 useState(状態管理の基本)

6.2 useEffect(初期処理・データ取得)

6.3 useContext(propsバケツリレー回避)

6.4 useRef(再レンダー不要な保持・DOM操作)

6.5 useMemo(重い処理の再計算を防ぐ)

6.6 useCallback(関数参照を安定させる)

6.7 カスタムHook(業務ロジックの分離)


7. 学習の進め方(おすすめ順)

  1. ログイン画面(useState の基本)
  2. 在庫一覧(useEffect で一覧取得 + ページネーション)
  3. 新規登録(フォーム入力 + バリデーション)
  4. 編集(詳細取得 + 更新)
  5. useContext(ログインユーザー共有)
  6. 最後に最適化(useMemo / useCallback / React.memo)

結論: この在庫管理システム1本で、React Hooks の「実務で使う主要部分」を一通り学べます。

在庫管理システム v0.1 (学習用) ログイン メールアドレスとパスワードを入力してください メールアドレス example@company.com パスワード •••••••• 例:メールアドレスまたはパスワードが違います ログイン ※ ログイン中はボタン無効化・ローディング表示 画面挙動メモ ・入力は state 管理 ・送信で API 呼び出し ・失敗時はエラー表示 ・成功時は一覧へ遷移 ・ローディング中は  ボタン無効 © Inventory Training App

ログイン画面で学べる React Hooks 一覧(業務目線・少し深め)

ログイン画面は一見シンプルですが、実は React Hooks の基礎〜設計感覚まで一気に学べる 非常に良い題材です。

Hook ログイン画面での役割 意味・理解のポイント(深め)
useState ・メールアドレス
・パスワード
・エラーメッセージ
・ローディング状態
「画面の状態はすべて state」というReactの基本原則を学ぶ。
入力欄は「DOMの値」ではなく「stateの写像」であることを理解するのが重要。

ログイン画面では
  • 入力値が変わる
  • 送信中は状態が変わる
  • 成功/失敗で表示が変わる
という 状態遷移の塊を体験できる。
useEffect ・初期表示時の処理
・既ログイン判定
・ログイン成功後の副作用
「描画とは別のタイミングで起きる処理」を扱うHook。
ログイン画面では、
  • 初回表示時にトークンを確認
  • ログイン成功後に画面遷移
など、 「状態が変わった結果として起きる処理」 を整理して書く感覚が身につく。

特に [](初回のみ)と [isLoggedIn](状態変化時) の違いが腑に落ちる。
useContext ・ログインユーザー情報の保持
・全画面へのログイン状態共有
ログイン画面は 「Contextを導入する必然性が最も分かりやすい画面」

ログイン後、
  • ユーザー名をヘッダに表示
  • 権限で画面制御
したくなった瞬間、 propsでは限界が来る。

Contextは
propsを運ぶ代わりに「共有棚」を作る
という発想だと理解しやすい。
useRef ・初期フォーカス制御
・再レンダー不要な値保持
useRefは 「状態だが、画面には影響しないもの」 を扱うHook。

ログイン画面では
  • 初期表示時にメール入力へフォーカス
  • 前回入力値の一時保持
などで使える。

「これは state にすべきか?」 を考える癖がつくのが最大の学習価値。
カスタムHook
(useAuthなど)
・ログイン処理の共通化
・API呼び出し隠蔽
ログイン処理は 複数画面で再利用される業務ロジック

そのため
  • API呼び出し
  • エラーハンドリング
  • Context更新
をカスタムHookに切り出すことで、 JSXが「画面」に専念できる

これは 「Reactらしい設計」 を学ぶ重要なステップ。

まとめ:
ログイン画面は小さいが、 React Hooks の考え方がすべて詰まっている
ここを丁寧に作れるようになると、 以降の一覧・登録・編集画面が一気に楽になります。

在庫管理システム ログインユーザー: 山田 太郎 拠点: 東京倉庫 在庫一覧 (20件/ページ) 検索 code / 名称 / 倉庫名 など 検索 + 追加 例:在庫一覧の取得に失敗しました。時間をおいて再度お試しください。 id code 名称 個数 倉庫名 操作 101 P-0001 高耐久ドライバーセット 12 東京倉庫 編集 102 P-0002 緩衝材(大) 0 大阪倉庫 編集 103 P-0003 ラベルプリンタ用紙 35 東京倉庫 編集 表示: 1 - 20 / 128 前へ 1 2 3 次へ ※ 検索条件変更・ページ変更で API 再取得(useEffect)、一覧行操作で useCallback / memo を学習

在庫一覧画面で学べる React Hooks(業務目線・少し深め)

在庫一覧は「業務アプリの中心画面」なので、 React Hooks の学習効率が最も高いです。 一覧・検索・ページネーション・行ボタン(編集)などが揃い、Hooksの使い分けが自然に出ます。

Hook 在庫一覧での具体的な役割 意味・理解のポイント(深め)
useState ・一覧データ(rows)
・検索条件(keyword)
・ページ番号(page)
・ローディング(loading)
・エラー文言(error)
一覧画面は「状態の集合体」。
画面が変わる=stateが変わるを実地で学べる。
特に、業務画面では
  • 検索条件 → 一覧が変わる
  • ページ番号 → 一覧が変わる
  • 取得失敗 → エラー表示が変わる
のように、state同士が連動するため、状態設計の練習になる。
useEffect ・初期表示で一覧取得
・page変更で再取得
・検索条件変更で再取得
useEffectは「副作用(データ取得)」の代表。
在庫一覧はまさに
「ある状態が変わったら、APIを呼ぶ」
を行う画面。
依存配列([page, keyword]など)の理解が、実害(呼びすぎ/呼ばなさすぎ)で体感できる。
useRef ・前回検索条件の保持(比較用)
・スクロール位置の保持(任意)
・「現在のリクエストID」保持(任意)
useRefは 「保持したいが、画面を再描画させたくない値」に使う。
一覧画面だと、
  • 前回条件と比較して不要な再取得を避ける
  • 連続リクエスト時に古い応答を捨てる(リクエスト識別)
のような、業務で起きる“細かい事故”を防ぐ用途が出てくる。
useContext ・ログインユーザー表示
・権限によるボタン制御(例:編集はadminのみ)
・共通エラーモーダル表示(任意)
在庫一覧はヘッダや操作ボタンなど、共通情報(ユーザー/権限/拠点)を多用する。
propsで渡すと中継が増え、画面が大きくなるほど破綻するため、Contextの必要性が自然に理解できる。
useMemo ・フィルタ済み一覧
・表示用整形(数量の表示形式など)
・集計値(合計在庫数など)
useMemoは 「重い計算結果を、依存が変わらない限り再利用する」
一覧で件数が増えると、検索入力のたびに filtermap が走り続ける。
そこで
  • keyword が変わった時だけ再計算
  • rows が変わった時だけ再計算
に分ける、という実務的な最適化を学べる。
useCallback ・編集ボタンのクリックハンドラ
・削除ボタン(任意)
・ページ変更ハンドラ
一覧画面では「行コンポーネント」が大量になる。
親が毎回新しい関数を作ると、子(Row)が propsが変わったとみなされ再レンダーしやすい。
useCallbackは
「同一参照の関数を維持して、不要な再レンダーを減らす」
という、性能と設計の両方に効く考え方を学べる。
React.memo(Hookではないが重要) ・Row(1行表示)の再レンダー抑制 一覧の行が多い業務画面では効果が出やすい。
useCallback × React.memo をセットで理解すると、 「なぜ再レンダーされるのか」が腑に落ちる。
カスタムHook ・一覧取得ロジックを分離
・検索/ページング/エラー管理を共通化
在庫一覧の処理は他画面でも使い回されがち。
例えば
  • 在庫参照画面
  • 棚卸画面
  • 受注画面の在庫参照
カスタムHookに切り出すと、画面側は「表示」に集中できる。
これは業務アプリの保守性を上げる設計練習になる。

まとめ:
在庫一覧は useState / useEffect を中心に、
規模が増えるほど useContext / useRef / useMemo / useCallback が必要になる。
つまり、この画面を作り込むほど「実務で使うHookの順番」が自然に学べます。

在庫管理システム 在庫一覧 / 在庫新規登録 在庫新規登録 (必須項目は * 印) 商品コード * 例:P-00123 名称 * 商品名を入力 個数 * 0 倉庫名 * 東京倉庫 例:商品コードは必須です / 個数は 0 以上の数値を入力してください キャンセル 追加 ※ 送信時にバリデーション実行(useState)→ OKなら API 呼び出し(useEffect / カスタムHook) / 送信中はボタン無効化・ローディング表示 © Inventory Training App

在庫新規登録画面で学べる React Hooks(実務目線・理解を深める)

在庫新規登録画面は 「フォーム処理の王道」です。
業務システムの9割はフォームなので、ここで学んだHookの使い方は そのまま実務に直結します。

Hook 在庫新規登録での役割 意味・理解のポイント(深め)
useState ・商品コード
・名称
・個数
・倉庫名
・エラーメッセージ
・送信中フラグ
フォーム画面は 「state管理の集大成」
入力値・エラー・送信状態を すべて useState で管理することで、
  • どの入力が画面に影響するか
  • どの状態が再描画を起こすか
を明確に意識できる。

「入力=stateの変更」というReactの思想が ここで完全に定着する。
useEffect ・初期表示時の処理(任意)
・登録成功後の画面遷移
・エラー発生時の副作用
useEffectは 「処理の結果として起きる動作」 を記述する場所。

在庫登録では
  • 登録完了 → 一覧画面へ戻る
  • エラー発生 → ダイアログ表示
など、 状態変化に反応する処理 を整理して書く訓練になる。

「送信関数の中に全部書かない」設計感覚が身につく。
useRef ・初期フォーカス制御
・前回送信データの保持
・二重送信防止(補助)
useRefは 「値は持つが、画面は変えない」ためのHook。

在庫登録では
  • 画面表示直後に商品コードへフォーカス
  • 直前の送信データと比較
など、UX・安全性向上に使える。

「これは state にする必要があるか?」 を考える力が鍛えられる。
useContext ・ログインユーザー情報取得
・倉庫名の初期値設定
・権限チェック
新規登録画面では 「ログイン情報を当然のように使う」

それを props で渡し始めると、 画面構造が一気に崩れる。

Contextを使うことで
画面は業務に集中し、共通情報は外から供給される
という設計思想を理解できる。
useCallback ・登録ボタンクリック処理
・キャンセル処理
フォーム送信関数は 子コンポーネントに渡されることが多い。

useCallbackを使うことで 関数参照を安定させる意識が身につく。

「今は不要でも、大規模化すると必要になる」 という将来視点の学習ポイント。
カスタムHook ・登録処理ロジックの分離
・バリデーション共通化
・API呼び出し隠蔽
在庫登録処理は 一覧・編集でも再利用される

useInventoryForm のような カスタムHookに切り出すことで、
  • 画面はJSXに集中
  • ロジックはHookに集約
というReactらしい責務分離を体験できる。

これは実務で 「書ける人」と「設計できる人」 を分けるポイント。

まとめ:
在庫新規登録画面は フォーム系Hook(useState / useEffect / useRef) の理解を深める最重要画面。
ここを丁寧に作れるようになると、 編集画面・他業務フォームが一気に楽になります。

```html 在庫管理システム ログインユーザー: 山田 太郎 在庫一覧 / 在庫編集 在庫編集 (ID: 102) 商品コード P-0002(変更不可) 名称 * 緩衝材(大) 個数 * 0 倉庫名 * 大阪倉庫 ※ 変更あり:個数が「5 → 0」に変更されています 例:個数は 0 以上の数値を入力してください 一覧へ戻る リセット 更新 ※ 初期表示で詳細取得(useEffect)/ 変更検知は useRef(original保持) / 更新ボタンは差分がある場合のみ有効 © Inventory Training App

在庫編集画面で学べる React Hooks(業務アプリの核心)

在庫編集画面は、 「React Hooks を使った業務画面の完成形」です。
一覧・新規よりも状態が多く、 なぜこのHookが必要なのかを最も深く理解できます。

Hook 在庫編集での具体的な役割 意味・理解のポイント(深め)
useState ・名称
・個数
・倉庫名
・エラーメッセージ
・更新中フラグ
編集画面では 「初期値が存在する state」 を扱う。
これは新規登録との最大の違い。

既存データを state にコピーして編集することで、
  • 未保存の変更
  • バリデーションエラー
を安全に管理できる。
useEffect ・初期表示で詳細取得
・ID変更時の再取得
・更新成功後の画面遷移
編集画面は
「表示前に必ず API を呼ぶ」
画面。

useEffectを使うことで
  • URLの id が変わったら再取得
  • 更新完了したら一覧へ戻る
といった状態駆動の流れを整理できる。

副作用をイベント関数に混ぜないのがポイント。
useRef ・初期データ(original)の保持
・変更有無の判定
・二重更新防止(補助)
useRefは 編集画面で最も重要なHook

初期取得したデータを originalRef に保存することで、
  • 変更があるか?
  • 更新ボタンを有効にするか?
を判定できる。

stateにすると再レンダーが増えるため、 「比較用データは ref」 という設計感覚が身につく。
useContext ・ログインユーザー取得
・権限チェック(編集可否)
・共通エラーモーダル
編集画面では 「誰が編集しているか」 が重要。

Contextを使うことで、
  • 権限による更新ボタン制御
  • ユーザー名の表示
をprops無しで実現できる。

業務アプリではほぼ必須の設計。
useCallback ・更新ボタン処理
・リセット処理
・戻るボタン処理
編集画面はボタンが多く、 ハンドラ関数も増える。

useCallbackを使うことで 「意図しない再生成」 を防ぎ、将来の最適化に耐えられる構造になる。

特に子コンポーネント化した場合に効果が出る。
useMemo ・変更有無フラグの算出
・差分サマリ生成
差分チェックは オブジェクト比較になりがち。

useMemoを使うことで、
  • stateが変わった時だけ比較
  • 無駄な再計算を防止
できる。

「軽く見える処理ほど積み重なる」 という業務的な視点が身につく。
カスタムHook ・詳細取得+更新処理
・バリデーション共通化
・例外処理集約
編集処理は 一覧・新規と共通部分が多い

useInventoryEdit のような カスタムHookに切り出すことで、
  • 画面は入力と表示に集中
  • 業務ロジックはHookに集約
という実務で通用する構成を学べる。

まとめ:
在庫編集画面は useRef / useEffect / useMemo の「真価」が最も分かる画面。
ここを理解できれば、Reactの業務利用に一気に自信が持てます。

json-server で使う db.json のサンプル


    {
  "users": [
    {
      "id": 1,
      "email": "admin@example.com",
      "userName": "管理者",
      "password": "password123"
    },
    {
      "id": 2,
      "email": "operator@example.com",
      "userName": "担当者",
      "password": "password123"
    }
  ],

  "inventory": [
    {
      "id": 101,
      "code": "P-0001",
      "name": "高耐久ドライバーセット",
      "qty": 12,
      "warehouseName": "東京倉庫"
    },
    {
      "id": 102,
      "code": "P-0002",
      "name": "緩衝材(大)",
      "qty": 0,
      "warehouseName": "大阪倉庫"
    }
  ]
}
  

json-server 起動までの手順(最短まとめ)

  1. 作業用フォルダを作成
    mkdir react-study
    cd react-study
  2. API 用フォルダを作成
    mkdir api
    cd api
  3. db.json を作成
    touch db.json

    ※ db.json は疑似データベース。トップレベルのキーがテーブルになります。

  4. db.json に初期データを書く
    {
      "inventory": [
        { "id": 1, "name": "商品A", "stock": 10 },
        { "id": 2, "name": "商品B", "stock": 0 }
      ]
    }
  5. json-server を起動(JSONサーバー)
    npx json-server@0.17.4 --watch db.json --port 3001
    

    ※ Node.js が入っていれば、事前インストール不要。

  6. 起動確認
    • ブラウザで http://localhost:3001/inventory にアクセス
    • http://localhost:3001/inventory?_page=1&_limit=10
    • http://localhost:3001/inventory?_page=2&_limit=10
    • JSON が表示されれば成功

ここまででできること:
サーバー実装なしで、本物の REST API 形式(GET / POST / PUT / DELETE)を即座に利用可能。 React 学習に集中できます。

Reactプロジェクト作成(VS Code前提)

1. 前提フォルダ構成(あなたの現状)

すでに 在庫管理react/api/db.json がある前提で進めます。
React(フロント)用フォルダを web として作るのが分かりやすいです。

在庫管理react/
  ├─ api/
  │   └─ db.json
  └─ web/         ← これから作る(Reactプロジェクト)

2. 必要なもの(最小)


3. VS Codeで作業フォルダを開く

  1. VS Codeを起動
  2. ファイル → フォルダーを開く から 在庫管理react を開く
  3. VS Codeのターミナルを開く(表示 → ターミナル
    Ctrl + Shift + `

4. Reactプロジェクト作成(Vite + React + TypeScript)

これが現在いちばん定番で、学習にも最適です。
VS Code のターミナルで、在庫管理react 直下にいる状態で実行します。

cd web

※ まだ web フォルダが無い場合は、次のコマンドで作成しながら作れます。

cd ..
npm create vite@latest web -- --template react-ts

webフォルダが存在する場合は、次のコマンドで Reactプロジェクトを作成できます。

npm create vite@latest . -- --template react-ts

ポイント:
react-ts を選ぶことで TypeScript 付きで作成されます。
React学習でも、業務に近づけるなら TypeScript 推奨です。
TypeScript + SWC で、学習中も快適に動作します。


5. 依存関係のインストール

cd web
npm install

6. 開発サーバー起動(React)

npm run dev

起動後、ターミナルに表示されるURL(例:http://localhost:5173)をブラウザで開きます。


7. API(json-server)も別ターミナルで起動

Reactとは別のターミナルを開き、api フォルダで json-server を起動します。

cd api
npx json-server@0.17.4 --watch db.json --port 3001

ブラウザで次が見えればOKです:


8. ここまでの「完成状態」チェック

次は、ReactからAPIにアクセスします。
その際、CORSを避けるために Vite の proxy を設定するのが最短です(次ステップで実施)。

ログイン画面から始める(React + Vite + json-server)

仕様

注意点(学習用)

画面イメージ(SVG)

在庫管理システム ログイン メールアドレス 例:user1@example.com パスワード 例:password123 例:メールアドレスまたはパスワードが違います ログイン

コード(コピペで動く最小構成)

1) CORS回避のため Vite proxy を設定(推奨)

ファイル:web/vite.config.ts を開いて、server を追加します。

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  /**
   * server.proxy
   * ------------------------------------------------------------
   * 役割:
   * - ブラウザの CORS を避けるため、/api を json-server に転送する
   * - React側は fetch("/api/...") と書けばOKになる
   */
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3001",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

2) ログイン画面コンポーネント

ファイル:web/src/LoginPage.tsx

/**
 * LoginPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - ログイン画面(メール・パスワード入力)
 * - json-server の users を照合して学習用ログインを行う
 *
 * 学習ポイント:
 * - useState:入力値・ローディング・エラーを管理する
 * - fetch:APIを呼ぶ(/api/users?...)
 * - 「送信中はボタン無効化」など業務UIの基本
 */

import React, { useState } from "react";
import "./LoginPage.css";

/**
 * User
 * ------------------------------------------------------------
 * users テーブル(db.json)の形に合わせた型
 */
type User = {
  id: number;
  email: string;
  userName: string;
  password: string;
};

/**
 * LoginPageProps
 * ------------------------------------------------------------
 * 親(App)へログイン成功を通知するための props
 */
type LoginPageProps = {
  onLoginSuccess: (user: Pick<User, "id" | "email" | "userName">) => void;
};

export default function LoginPage({ onLoginSuccess }: LoginPageProps) {
  // 入力(画面に影響する=state)
  const [email, setEmail] = useState("user1@example.com");
  const [password, setPassword] = useState("password123");

  // 画面状態
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  /**
   * handleSubmit
   * ----------------------------------------------------------
   * 役割:
   * - ログインボタン押下時の処理
   * - 入力チェック → users照合 → 成功なら親へ通知
   */
  async function handleSubmit(e: React.SyntheticEvent) {
    e.preventDefault();

    // 最低限の入力チェック(業務ではバリデーション層に分ける)
    if (!email.trim()) {
      setError("メールアドレスを入力してください。");
      return;
    }
    if (!password.trim()) {
      setError("パスワードを入力してください。");
      return;
    }

    setLoading(true);
    setError(null);

    try {
      // 学習用:users をクエリで絞り込む(本番ではNG)
      const url = `/api/users?email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`;
      const res = await fetch(url);

      if (!res.ok) {
        throw new Error(`HTTP ${res.status}`);
      }

      const users: User[] = await res.json();

      if (users.length === 0) {
        setError("メールアドレスまたはパスワードが違います。");
        return;
      }

      const u = users[0];
      // 親へ「ログイン成功」を通知(画面切替などは親が行う)
      onLoginSuccess({ id: u.id, email: u.email, userName: u.userName });
    } catch (err) {
      console.error("[Login] failed:", err);
      setError("ログインに失敗しました。時間をおいて再度お試しください。");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="login-page">
      <div className="login-card">
        <h1 className="login-title">ログイン</h1>

        {error && (
          <div className="login-error" role="alert">
            {error}
          </div>
        )}

        <form onSubmit={handleSubmit}>
          <label className="login-label">
            メールアドレス
            <input
              className="login-input"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              autoComplete="username"
            />
          </label>

          <label className="login-label">
            パスワード
            <input
              className="login-input"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              autoComplete="current-password"
            />
          </label>

          <button className="login-button" type="submit" disabled={loading}>
            {loading ? "ログイン中…" : "ログイン"}
          </button>
        </form>

        <p className="login-hint">
          テストユーザー例:user1@example.com / password123
        </p>
      </div>
    </div>
  );
}

3) CSS(別ファイル)

ファイル:web/src/LoginPage.css

/**
 * LoginPage.css
 * ------------------------------------------------------------
 * ログイン画面用スタイル
 */

.login-page{
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: #f8fafc;
}

.login-card{
  width: 100%;
  max-width: 520px;
  background: #fff;
  border: 1px solid rgba(0,0,0,0.12);
  border-radius: 16px;
  padding: 24px;
}

.login-title{
  font-size: 22px;
  margin: 0 0 16px;
  color: #111827;
}

.login-error{
  background: #fff1f2;
  border: 1px solid rgba(190,18,60,0.25);
  color: #be123c;
  padding: 12px 14px;
  border-radius: 12px;
  margin-bottom: 14px;
}

.login-label{
  display: block;
  font-size: 12px;
  color: #111827;
  margin-bottom: 10px;
}

.login-input{
  width: 100%;
  box-sizing: border-box;
  margin-top: 6px;
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid rgba(0,0,0,0.14);
  background: #f9fafb;
  outline: none;
}

.login-button{
  width: 100%;
  margin-top: 14px;
  padding: 12px 12px;
  border-radius: 12px;
  border: none;
  background: #111827;
  color: #fff;
  cursor: pointer;
}

.login-button:disabled{
  opacity: 0.65;
  cursor: not-allowed;
}

.login-hint{
  margin: 12px 0 0;
  font-size: 12px;
  color: #6b7280;
}

4) App.tsx(ログイン成功で画面を切り替える)

ファイル:web/src/App.tsx を置き換えます。

/**
 * App.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 画面の入口
 * - ログイン済みかどうかで表示画面を切り替える(学習用の最小構成)
 */

import React, { useState } from "react";
import LoginPage from "./LoginPage";

export type LoggedInUser = {
  id: number;
  email: string;
  userName: string;
};

export default function App() {
  // ログイン状態(nullなら未ログイン)
  const [user, setUser] = useState<LoggedInUser | null>(null);

  if (!user) {
    return <LoginPage onLoginSuccess={setUser} />;
  }

  return (
    <div style={{ padding: 24 }}>
      <h2>ログイン成功</h2>
      <p>ようこそ、{user.userName} さん</p>
      <p>次は「在庫一覧」を作ります。</p>

      <button onClick={() => setUser(null)}>ログアウト</button>
    </div>
  );
}

index.css(Vite 初期CSSリセット)

Vite テンプレートに最初から含まれている CSS をリセットし、
画面が右や左に寄ってしまう問題を防ぐための index.css です。

/* src/index.css
 * ------------------------------------------------------------
 * 役割:
 * - Viteテンプレの初期CSSをリセットし、画面が変な位置に寄るのを防ぐ
 */

:root {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
    Arial, "Noto Sans JP", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color: #111827;
  background-color: #ffffff;
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: #f8fafc;
}

#root {
  min-height: 100%;
}

このファイルは web/src/index.css に置き、
main.tsx から import "./index.css"; で読み込まれている必要があります。

index.html(エントリHTML)

Vite + React アプリの最初に読み込まれる HTML ファイルです。
画面タイトル(ブラウザのタブ名)や、React を描画する #root 要素を定義します。

<!doctype html>
<html lang="ja"> <!-- 日本語 -->
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>在庫管理システム</title> <!-- ブラウザのタブに表示されるタイトル --> 
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  • <title>:ブラウザのタブに表示されるタイトル
  • #root:React が画面を描画する起点
  • main.tsx:React アプリのエントリポイント

起動手順(確認)

  1. API(別ターミナル):cd apinpx json-server@0.17.4 --watch db.json --port 3001
  2. React:cd webnpm run dev
  3. ブラウザで http://localhost:5173 を開く
  4. user1@example.com / password123 でログインできれば成功

App.tsx と LoginPage.tsx の役割分担

在庫管理システムでは、画面全体の制御個別画面の振る舞いを明確に分けます。 これにより、画面追加や仕様変更に強い構造になります。

役割の全体像

ファイル 責務(何をするか)
App.tsx アプリ全体の状態管理・画面切り替え・ログイン状態の保持
LoginPage.tsx ログイン画面の表示・入力処理・ログイン試行

App.tsx の役割(親コンポーネント)

  • ログイン済みかどうかの状態を保持する
  • ログイン成功時の処理を定義する
  • どの画面を表示するかを決定する

App.tsx は「司令塔」です。
画面の中身には立ち入らず、状態と流れだけを管理します。

// App.tsx(役割イメージ)
const [currentUser, setCurrentUser] = useState<User | null>(null);

function handleLoginSuccess(user: User) {
  setCurrentUser(user);
}

return (
  currentUser
    ? <InventoryPage user={currentUser} />
    : <LoginPage onLoginSuccess={handleLoginSuccess} />
);

LoginPage.tsx の役割(子コンポーネント)

  • メールアドレス・パスワード入力欄の表示
  • 入力チェック(空欄などの最低限)
  • users データとの照合
  • 成功時に親へ通知する

LoginPage は「画面専門」です。
ログイン後に何が起きるかは知りません。

// LoginPage.tsx(役割イメージ)
props.onLoginSuccess({
  id: user.id,
  email: user.email,
  userName: user.userName
});

なぜこの分け方が重要か

  • ログイン画面を差し替えても App.tsx は変更不要
  • 将来、認証方法(API / OAuth)を変えても影響が限定的
  • 業務アプリで必須の「責務分離」を自然に学べる

👉 原則:
「画面は考えない。流れだけ見る」= App.tsx
「流れは考えない。画面だけ作る」= LoginPage.tsx

次のステップ:ログイン成功後に「在庫一覧画面」を表示する(ページング付き)を作ります。

InventoryPage:一覧取得(useEffect)+ ページネーション(_page/_limit)

仕様

注意点

画面イメージ(SVG)

在庫一覧 ページング:1 / 4(例)  表示件数:10件 検索(後で追加) 更新 コード 名称 倉庫 個数 P-0001 商品1 東京倉庫 12 P-0002 商品2 大阪倉庫 4 « 前へ 1 / 4 次へ »

コード

InventoryPage.tsx(コピペ用)

/**
 * InventoryPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - ログイン後に表示される在庫一覧画面
 * - json-server から一覧を取得し、テーブル表示する
 * - _page / _limit でページネーションする
 *
 * 学べる Hook:
 * - useState   : rows, loading, error, page, totalCount を状態として管理
 * - useEffect  : page が変わった時に API 取得(副作用)を実行
 *
 * 前提:
 * - json-server が http://localhost:3001 で起動している
 * - エンドポイント: GET /inventory?_page=1&_limit=10
 */

import { useEffect, useMemo, useState } from "react";
import type { LoggedInUser } from "./App";
import "./InventoryPage.css";

/**
 * InventoryRow
 * ------------------------------------------------------------
 * 画面で扱う在庫行の型(db.json の inventory に合わせる)
 */
type InventoryRow = {
  id: number;
  code: string;
  name: string;
  qty: number;
  warehouseName: string;
};

/**
 * InventoryPageProps
 * ------------------------------------------------------------
 * 親(App)から受け取る props
 */
type InventoryPageProps = {
  user: LoggedInUser;
  onLogout: () => void;
};

/**
 * API_BASE
 * ------------------------------------------------------------
 * json-server のベースURL
 * ※ Vite のプロキシを使う場合は "" にして /inventory にしてもOK
 */
const API_BASE = "http://localhost:3001";

export default function InventoryPage({ user, onLogout }: InventoryPageProps) {
  // 一覧表示データ
  const [rows, setRows] = useState<InventoryRow[]>([]);

  // ローディング/エラー(業務画面の基本)
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // ページング状態
  const [page, setPage] = useState(1);
  const [limit] = useState(10); // 今は固定。将来プルダウンにしても良い

  // 総件数(X-Total-Count から取得)
  const [totalCount, setTotalCount] = useState(0);

  /**
   * totalPages
   * ----------------------------------------------------------
   * 総ページ数(総件数 / limit から算出)
   * useMemo:
   * - totalCount/limit が変わらない限り再計算しない(軽いが習慣として)
   */
  const totalPages = useMemo(() => {
    return Math.max(1, Math.ceil(totalCount / limit));
  }, [totalCount, limit]);

  /**
   * fetchInventory
   * ----------------------------------------------------------
   * 役割:
   * - 指定ページの在庫データを取得する
   * - エラー時は詳細を console に残し、画面には安全なメッセージだけ出す
   *
   * 注意:
   * - json-server は総件数をレスポンスヘッダ X-Total-Count で返す
   */
  async function fetchInventory(targetPage: number) {
    setLoading(true);
    setError(null);

    try {
      const url = `${API_BASE}/inventory?_page=${targetPage}&_limit=${limit}`;

      // fetch 実行(副作用)
      const res = await fetch(url);

      // HTTPエラーも例外扱いにする(業務レベル)
      if (!res.ok) {
        throw new Error(`HTTP ${res.status} ${res.statusText}`);
      }

      // 本文(配列)
      const data: InventoryRow[] = await res.json();

      // 総件数(ヘッダ)
      const total = Number(res.headers.get("X-Total-Count") ?? "0");

      setRows(data);
      setTotalCount(total);
    } catch (e) {
      // 開発者向けログ(詳細)
      console.error("[InventoryPage] fetchInventory failed:", e);

      // 利用者向けメッセージ(安全)
      setError("在庫一覧の取得に失敗しました。時間をおいて再度お試しください。");
      setRows([]);
      setTotalCount(0);
    } finally {
      setLoading(false);
    }
  }

  /**
   * useEffect
   * ----------------------------------------------------------
   * 役割:
   * - page が変わった時に一覧を再取得する(副作用)
   *
   * 依存配列:
   * - [page] により page が変わった時だけ実行される
   */
  useEffect(() => {
    fetchInventory(page);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [page]);

  /**
   * handlePrev / handleNext
   * ----------------------------------------------------------
   * 役割:
   * - ページ切り替え(範囲外にならないようガード)
   */
  function handlePrev() {
    setPage((p) => Math.max(1, p - 1));
  }

  function handleNext() {
    setPage((p) => Math.min(totalPages, p + 1));
  }

  /**
   * handleReload
   * ----------------------------------------------------------
   * 役割:
   * - 現在ページを再取得
   */
  function handleReload() {
    fetchInventory(page);
  }

  return (
    <div className="inv-page">
      <header className="inv-header">
        <div>
          <h2 className="inv-title">在庫一覧</h2>
          <div className="inv-sub">
            ログイン中:{user.userName}({user.email})
          </div>
        </div>

        <div className="inv-header-actions">
          <button className="inv-btn" onClick={handleReload} disabled={loading}>
            更新
          </button>
          <button className="inv-btn outline" onClick={onLogout}>
            ログアウト
          </button>
        </div>
      </header>

      {/* エラー表示(業務画面では「上部に出す」が定番) */}
      {error && (
        <div className="inv-error">
          <strong>エラー</strong><br />
          {error}
        </div>
      )}

      <div className="inv-panel">
        <div className="inv-panel-head">
          <div className="inv-paging-info">
            ページ:{page} / {totalPages} (総件数:{totalCount})
          </div>

          <div className="inv-paging-buttons">
            <button className="inv-btn outline" onClick={handlePrev} disabled={loading || page <= 1}>
              ← 前へ
            </button>
            <button className="inv-btn outline" onClick={handleNext} disabled={loading || page >= totalPages}>
              次へ →
            </button>
          </div>
        </div>

        <div className="inv-table-wrap">
          <table className="inv-table">
            <thead>
              <tr>
                <th>コード</th>
                <th>名称</th>
                <th>倉庫</th>
                <th className="right">個数</th>
              </tr>
            </thead>

            <tbody>
              {loading ? (
                <tr>
                  <td colSpan={4} className="muted">読み込み中...</td>
                </tr>
              ) : rows.length === 0 ? (
                <tr>
                  <td colSpan={4} className="muted">データがありません</td>
                </tr>
              ) : (
                rows.map((r) => (
                  <tr key={r.id}>
                    <td>{r.code}</td>
                    <td>{r.name}</td>
                    <td>{r.warehouseName}</td>
                    <td className="right">{r.qty}</td>
                  </tr>
                ))
              )}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

InventoryPage.css(別ファイル)

/**
 * InventoryPage.css
 * ------------------------------------------------------------
 * 在庫一覧(取得 + ページング)用スタイル
 */

.inv-page{
  min-height: 100vh;
  padding: 24px;
  background: #f8fafc;
}

.inv-header{
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #fff;
  border: 1px solid rgba(0,0,0,0.12);
  border-radius: 16px;
  padding: 16px 18px;
}

.inv-title{
  margin: 0;
  font-size: 20px;
  color: #111827;
}

.inv-sub{
  margin-top: 4px;
  font-size: 12px;
  color: #6b7280;
}

.inv-header-actions{
  display: flex;
  gap: 10px;
}

.inv-btn{
  padding: 10px 14px;
  border-radius: 12px;
  border: 1px solid rgba(0,0,0,0.14);
  background: #111827;
  color: #fff;
  cursor: pointer;
}

.inv-btn:disabled{
  opacity: 0.6;
  cursor: not-allowed;
}

.inv-btn.outline{
  background: #fff;
  color: #111827;
}

.inv-error{
  margin-top: 14px;
  background: #fef2f2;
  border: 1px solid rgba(220,38,38,0.35);
  color: #b91c1c;
  border-radius: 16px;
  padding: 12px 14px;
}

.inv-panel{
  margin-top: 14px;
  background: #fff;
  border: 1px solid rgba(0,0,0,0.12);
  border-radius: 16px;
  padding: 14px;
}

.inv-panel-head{
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  margin-bottom: 12px;
}

.inv-paging-info{
  font-size: 12px;
  color: #374151;
}

.inv-paging-buttons{
  display: flex;
  gap: 10px;
}

.inv-table-wrap{
  border: 1px solid rgba(0,0,0,0.10);
  border-radius: 14px;
  overflow: hidden;
}

.inv-table{
  width: 100%;
  border-collapse: collapse;
}

.inv-table thead th{
  background: #f1f5f9;
  font-size: 12px;
  color: #374151;
  text-align: left;
  padding: 10px 12px;
  border-bottom: 1px solid rgba(0,0,0,0.10);
}

.inv-table tbody td{
  padding: 10px 12px;
  border-bottom: 1px solid rgba(0,0,0,0.06);
  font-size: 14px;
  color: #111827;
}

.inv-table tbody tr:hover{
  background: #f8fafc;
}

.right{
  text-align: right;
}

.muted{
  color: #6b7280;
  text-align: center;
  padding: 18px 12px;
}

動作確認

  1. json-server を localhost:3001 で起動
  2. ログイン成功後、在庫一覧が表示される
  3. 「前へ」「次へ」で _page が変わり、API再取得される

もし X-Total-Count が取れない場合は、ブラウザの DevTools → Network → Response Headers を確認してください。

ログインユーザー情報を Context 化して props リレーを減らす

仕様

注意点(実務のコツ)

画面イメージ(SVG:propsリレーが消える)

Before:props リレー App Layout(中間) InventoryPage user を props で渡す After:Context で共有 AuthProvider(共有棚) user / login() / logout() を Context に置く Layout props なし InventoryPage useAuth() で読む useAuth() children

コード(コピペで動く構成)

1) src/context/AuthContext.tsx

/**
 * AuthContext.tsx
 * ------------------------------------------------------------
 * 役割:
 * - ログインユーザー情報をアプリ全体で共有する Context
 * - login / logout を共通関数として提供する
 *
 * ポイント:
 * - Provider 外で useAuth() が呼ばれた場合は例外を投げる(安全設計)
 */

import React, { createContext, useContext, useMemo, useState } from "react";

/**
 * LoggedInUser
 * ------------------------------------------------------------
 * ログイン後に保持する最小ユーザー情報
 * ※ password は絶対に保持しない
 */
export type LoggedInUser = {
  id: number;
  email: string;
  userName: string;
};

/**
 * AuthContextValue
 * ------------------------------------------------------------
 * Context が提供する値の型
 */
type AuthContextValue = {
  user: LoggedInUser | null;
  login: (user: LoggedInUser) => void;
  logout: () => void;
};

/**
 * AuthContext
 * ------------------------------------------------------------
 * Context 本体(初期値は null:Provider 外利用を検出するため)
 */
const AuthContext = createContext<AuthContextValue | null>(null);

/**
 * useAuth
 * ------------------------------------------------------------
 * 役割:
 * - Context の値を安全に取り出すカスタムHook
 * - Provider 外で呼ばれた場合に即エラーにして事故を防ぐ
 */
export function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (!ctx) {
    throw new Error("useAuth must be used within <AuthProvider>");
  }
  return ctx;
}

/**
 * AuthProvider
 * ------------------------------------------------------------
 * 役割:
 * - user 状態を保持し、子コンポーネントへ共有する
 * - login/logout 関数を提供する
 */
export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<LoggedInUser | null>(null);

  /**
   * login
   * ----------------------------------------------------------
   * 役割:ログイン成功時に user を保存する
   */
  function login(nextUser: LoggedInUser) {
    setUser(nextUser);
  }

  /**
   * logout
   * ----------------------------------------------------------
   * 役割:ログアウト時に user をクリアする
   */
  function logout() {
    setUser(null);
  }

  /**
   * value
   * ----------------------------------------------------------
   * useMemo:
   * - user/login/logout の参照を安定させる(不要な再レンダーを抑える)
   */
  const value = useMemo(() => ({ user, login, logout }), [user]);

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

2) src/App.tsx(propsリレーをやめる)

/**
 * App.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 画面の出し分け(未ログインなら LoginPage、ログイン済みなら InventoryPage)
 * - Context(AuthProvider)で全体を包む
 */

import React from "react";
import { AuthProvider, useAuth } from "./context/AuthContext";
import LoginPage from "./LoginPage";
import InventoryPage from "./InventoryPage";

/**
 * AppRoot
 * ------------------------------------------------------------
 * 役割:
 * - Context の中で「表示する画面」を決める
 * ※ useAuth() は Provider の内側で呼ぶ必要があるため分離
 */
function AppRoot() {
  const { user } = useAuth();

  // user が無ければログイン画面
  if (!user) return <LoginPage />;

  // user があれば在庫一覧
  return <InventoryPage />;
}

export default function App() {
  return (
    <AuthProvider>
      <AppRoot />
    </AuthProvider>
  );
}

3) src/LoginPage.tsx(成功時に Context の login を呼ぶ)

/**
 * LoginPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - ログイン画面(入力・照合・成功時に Context の login を呼ぶ)
 * - 画面遷移(どこへ行くか)は App が決める(LoginPage は知らない)
 */

import React, { useState } from "react";
import { useAuth, type LoggedInUser } from "./context/AuthContext";
import "./LoginPage.css";

const API_BASE = "http://localhost:3001";

type UserRow = {
  id: number;
  email: string;
  userName: string;
  password: string;
};

export default function LoginPage() {
  const { login } = useAuth(); // ★ ここがポイント(props不要)
  const [email, setEmail] = useState("user1@example.com");
  const [password, setPassword] = useState("password123");

  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  /**
   * handleSubmit
   * ----------------------------------------------------------
   * 役割:
   * - users から email/password を照合
   * - 成功したら Context の login(user) を呼ぶ
   */
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setError(null);

    // 最低限の入力チェック(本格化するならバリデーション層へ)
    if (!email.trim() || !password.trim()) {
      setError("メールアドレスとパスワードを入力してください。");
      return;
    }

    setLoading(true);
    try {
      // json-server は filter 的に ?email=xxx が使える
      const res = await fetch(`${API_BASE}/users?email=${encodeURIComponent(email)}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const users: UserRow[] = await res.json();
      const user = users[0];

      if (!user || user.password !== password) {
        setError("メールアドレスまたはパスワードが違います。");
        return;
      }

      // Contextに保存するのは最小情報のみ
      const loggedIn: LoggedInUser = {
        id: user.id,
        email: user.email,
        userName: user.userName,
      };

      // ★ これで App 側の表示が切り替わる(InventoryPageが出る)
      login(loggedIn);
    } catch (e) {
      console.error("[LoginPage] login failed:", e);
      setError("ログインに失敗しました。時間をおいて再度お試しください。");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="login-page">
      <form className="login-card" onSubmit={handleSubmit}>
        <h1 className="login-title">ログイン</h1>

        {error && <div className="login-error">{error}</div>}

        <label className="login-label">
          メールアドレス
          <input
            className="login-input"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="user1@example.com"
          />
        </label>

        <label className="login-label">
          パスワード
          <input
            className="login-input"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="password123"
          />
        </label>

        <button className="login-button" disabled={loading}>
          {loading ? "処理中..." : "ログイン"}
        </button>

        <div className="login-hint">テストユーザー例:user1@example.com / password123</div>
      </form>
    </div>
  );
}

4) src/InventoryPage.tsx(Contextの user/logout を使う)

/**
 * InventoryPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 在庫一覧(ページング付き)
 * - user 表示・ログアウトは Context から取得する
 *
 * ここが Context 化の効果:
 * - props を一切受け取らない(propsリレー消滅)
 */

import { useEffect, useMemo, useState } from "react";
import { useAuth } from "./context/AuthContext";
import "./InventoryPage.css";

type InventoryRow = {
  id: number;
  code: string;
  name: string;
  qty: number;
  warehouseName: string;
};

const API_BASE = "http://localhost:3001";

export default function InventoryPage() {
  const { user, logout } = useAuth(); // ★ ここがポイント(props不要)

  const [rows, setRows] = useState<InventoryRow[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const [page, setPage] = useState(1);
  const [limit] = useState(10);
  const [totalCount, setTotalCount] = useState(0);

  const totalPages = useMemo(() => Math.max(1, Math.ceil(totalCount / limit)), [totalCount, limit]);

  async function fetchInventory(targetPage: number) {
    setLoading(true);
    setError(null);

    try {
      const url = `${API_BASE}/inventory?_page=${targetPage}&_limit=${limit}`;
      const res = await fetch(url);

      if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);

      const data: InventoryRow[] = await res.json();
      const total = Number(res.headers.get("X-Total-Count") ?? "0");

      setRows(data);
      setTotalCount(total);
    } catch (e) {
      console.error("[InventoryPage] fetchInventory failed:", e);
      setError("在庫一覧の取得に失敗しました。時間をおいて再度お試しください。");
      setRows([]);
      setTotalCount(0);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    fetchInventory(page);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [page]);

  return (
    <div className="inv-page">
      <header className="inv-header">
        <div>
          <h2 className="inv-title">在庫一覧</h2>
          <div className="inv-sub">
            ログイン中:{user?.userName}({user?.email})
          </div>
        </div>

        <div className="inv-header-actions">
          <button className="inv-btn outline" onClick={logout}>ログアウト</button>
        </div>
      </header>

      {error && <div className="inv-error">{error}</div>}

      <div className="inv-panel">
        <div className="inv-panel-head">
          <div className="inv-paging-info">
            ページ:{page} / {totalPages} (総件数:{totalCount})
          </div>

          <div className="inv-paging-buttons">
            <button className="inv-btn outline" disabled={loading || page <= 1} onClick={() => setPage(p => Math.max(1, p - 1))}>
              ← 前へ
            </button>
            <button className="inv-btn outline" disabled={loading || page >= totalPages} onClick={() => setPage(p => Math.min(totalPages, p + 1))}>
              次へ →
            </button>
          </div>
        </div>

        <div className="inv-table-wrap">
          <table className="inv-table">
            <thead>
              <tr>
                <th>コード</th>
                <th>名称</th>
                <th>倉庫</th>
                <th className="right">個数</th>
              </tr>
            </thead>
            <tbody>
              {loading ? (
                <tr><td colSpan={4} className="muted">読み込み中...</td></tr>
              ) : rows.length === 0 ? (
                <tr><td colSpan={4} className="muted">データがありません</td></tr>
              ) : (
                rows.map(r => (
                  <tr key={r.id}>
                    <td>{r.code}</td>
                    <td>{r.name}</td>
                    <td>{r.warehouseName}</td>
                    <td className="right">{r.qty}</td>
                  </tr>
                ))
              )}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

5) CSS(LoginPage.css / InventoryPage.css)

CSSは前に作ったものをそのまま使えます。LoginPage.css / InventoryPage.css をそれぞれ src 配下に置いて import してください。

これで何が嬉しい?

在庫 新規追加画面(React + json-server + Hooks)

仕様

注意点

画面イメージ(SVG)

在庫 新規追加 コード・名称・個数・倉庫名を入力して登録します 例:入力内容に誤りがあります(コードは必須、個数は0以上など) コード 例:P-0037 倉庫名 例:東京倉庫 名称 例:業務用プリンター用紙 個数 例:12 キャンセル 登録

コード(Service / Validator / Page)

1) src/domain/InventoryEntity.ts

/**
 * InventoryEntity.ts
 * ------------------------------------------------------------
 * 役割:
 * - 在庫ドメイン(Entity)
 * - DB(json-server)の inventory と対応するデータ構造
 */
export type InventoryEntity = {
  id: number;
  code: string;
  name: string;
  qty: number;
  warehouseName: string;
};

2) src/dto/InventoryCreateDto.ts

/**
 * InventoryCreateDto.ts
 * ------------------------------------------------------------
 * 役割:
 * - 画面(新規登録)から送信する DTO
 * - id はサーバー側が採番するため含めない
 */
export type InventoryCreateDto = {
  code: string;
  name: string;
  qty: number;
  warehouseName: string;
};

3) src/validation/InventoryCreateValidator.ts

/**
 * InventoryCreateValidator.ts
 * ------------------------------------------------------------
 * 役割:
 * - 在庫新規登録の入力値検証(バリデーション)
 * - UI から独立させて「テスト可能」にする
 *
 * 方針:
 * - 検証結果は errors の配列で返す(業務で扱いやすい)
 */

import type { InventoryCreateDto } from "../dto/InventoryCreateDto";

/**
 * ValidationResult
 * ------------------------------------------------------------
 * 役割:
 * - バリデーション結果
 * - ok=false のとき errors にメッセージが入る
 */
export type ValidationResult = {
  ok: boolean;
  errors: string[];
};

/**
 * InventoryCreateValidator
 * ------------------------------------------------------------
 * 役割:
 * - InventoryCreateDto を検証するクラス
 */
export class InventoryCreateValidator {
  /**
   * validate
   * ----------------------------------------------------------
   * 役割:
   * - 入力値を検証し、結果を返す
   */
  validate(dto: InventoryCreateDto): ValidationResult {
    const errors: string[] = [];

    // 必須チェック
    if (!dto.code.trim()) errors.push("コードは必須です。");
    if (!dto.name.trim()) errors.push("名称は必須です。");
    if (!dto.warehouseName.trim()) errors.push("倉庫名は必須です。");

    // 形式チェック(例:P-0001 のような形)
    if (dto.code.trim() && !/^P-\d{4,}$/i.test(dto.code.trim())) {
      errors.push("コードの形式が不正です(例:P-0037)。");
    }

    // 数値チェック
    if (Number.isNaN(dto.qty)) {
      errors.push("個数は数値で入力してください。");
    } else if (dto.qty < 0) {
      errors.push("個数は 0 以上で入力してください。");
    }

    return { ok: errors.length === 0, errors };
  }
}

4) src/service/InventoryService.ts

/**
 * InventoryService.ts
 * ------------------------------------------------------------
 * 役割:
 * - 在庫の登録処理(API呼び出し)を担当
 * - UI(React)に依存しないようにする
 * - 例外を握りつぶさず、ログは詳細・画面は安全メッセージを返す
 */

import type { InventoryEntity } from "../domain/InventoryEntity";
import type { InventoryCreateDto } from "../dto/InventoryCreateDto";

/**
 * InventoryServiceError
 * ------------------------------------------------------------
 * 役割:
 * - 業務向けのサービス例外(ユーザーに安全メッセージを出すため)
 */
export class InventoryServiceError extends Error {
  public readonly safeMessage: string;

  constructor(message: string, safeMessage: string) {
    super(message);
    this.name = "InventoryServiceError";
    this.safeMessage = safeMessage;
  }
}

const API_BASE = "http://localhost:3001";

export class InventoryService {
  /**
   * create
   * ----------------------------------------------------------
   * 役割:
   * - 在庫を新規作成する(POST /inventory)
   * - 成功したら作成済みレコード(id付き)を返す
   */
  async create(dto: InventoryCreateDto): Promise<InventoryEntity> {
    try {
      const res = await fetch(`${API_BASE}/inventory`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(dto),
      });

      if (!res.ok) {
        throw new Error(`HTTP ${res.status} ${res.statusText}`);
      }

      const created: InventoryEntity = await res.json();
      return created;
    } catch (e) {
      // 開発者向けログ(詳細)
      console.error("[InventoryService] create failed:", e);

      // ユーザー向けメッセージ(安全)
      throw new InventoryServiceError(
        "Failed to create inventory.",
        "在庫の登録に失敗しました。時間をおいて再度お試しください。"
      );
    }
  }
}

5) src/components/Modal.tsx(簡易モーダル)

/**
 * Modal.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 成功メッセージ表示の簡易モーダル
 * - 学習用:最低限の UI として実装
 */

import React from "react";
import "./Modal.css";

type ModalProps = {
  title: string;
  message: string;
  onClose: () => void;
};

export default function Modal({ title, message, onClose }: ModalProps) {
  return (
    <div className="modal-backdrop" role="dialog" aria-modal="true">
      <div className="modal-card">
        <h3 className="modal-title">{title}</h3>
        <p className="modal-message">{message}</p>

        <div className="modal-actions">
          <button className="modal-btn" onClick={onClose}>OK</button>
        </div>
      </div>
    </div>
  );
}

6) src/pages/InventoryCreatePage.tsx(画面本体)

/**
 * InventoryCreatePage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 在庫の新規追加画面
 * - フォーム入力(useState)
 * - バリデーション(Validator)
 * - 登録処理(Service)
 * - 成功モーダル表示
 *
 * 学べる Hook:
 * - useState:入力値・エラー・ローディング・モーダル表示状態
 */

import React, { useMemo, useState } from "react";
import "./InventoryCreatePage.css";

import type { InventoryCreateDto } from "../dto/InventoryCreateDto";
import { InventoryCreateValidator } from "../validation/InventoryCreateValidator";
import { InventoryService, InventoryServiceError } from "../service/InventoryService";
import Modal from "../components/Modal";

type InventoryCreatePageProps = {
  onDone: () => void; // 登録後に一覧へ戻るためのコールバック(学習用)
};

export default function InventoryCreatePage({ onDone }: InventoryCreatePageProps) {
  // 入力フォーム state
  const [code, setCode] = useState("");
  const [name, setName] = useState("");
  const [qty, setQty] = useState(0);
  const [warehouseName, setWarehouseName] = useState("");

  // 画面状態
  const [loading, setLoading] = useState(false);
  const [errors, setErrors] = useState<string[]>([]);
  const [successOpen, setSuccessOpen] = useState(false);

  // Validator / Service は画面ごとに 1 つ生成(useMemo で固定化)
  const validator = useMemo(() => new InventoryCreateValidator(), []);
  const service = useMemo(() => new InventoryService(), []);

  /**
   * buildDto
   * ----------------------------------------------------------
   * 役割:
   * - state から DTO を組み立てる(画面 → ロジック層のデータ)
   */
  function buildDto(): InventoryCreateDto {
    return {
      code,
      name,
      qty: Number(qty),
      warehouseName,
    };
  }

  /**
   * handleSubmit
   * ----------------------------------------------------------
   * 役割:
   * - 送信時の処理
   * - バリデーション → OKなら登録(POST)
   */
  async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
    e.preventDefault();

    const dto = buildDto();

    // 1) バリデーション(クラス分け)
    const result = validator.validate(dto);
    if (!result.ok) {
      setErrors(result.errors);
      return;
    }

    // 2) 登録処理(クラス分け)
    setLoading(true);
    setErrors([]);

    try {
      await service.create(dto);
      setSuccessOpen(true);
    } catch (e) {
      if (e instanceof InventoryServiceError) {
        setErrors([e.safeMessage]);
      } else {
        setErrors(["在庫の登録に失敗しました。時間をおいて再度お試しください。"]);
      }
    } finally {
      setLoading(false);
    }
  }

  /**
   * handleCloseSuccess
   * ----------------------------------------------------------
   * 役割:
   * - 成功モーダルを閉じ、一覧へ戻る
   */
  function handleCloseSuccess() {
    setSuccessOpen(false);
    onDone();
  }

  return (
    <div className="ic-page">
      <header className="ic-header">
        <h2 className="ic-title">在庫 新規追加</h2>
        <button className="ic-btn outline" onClick={onDone} disabled={loading}>一覧へ戻る</button>
      </header>

      {errors.length > 0 && (
        <div className="ic-error">
          <strong>入力エラー</strong>
          <ul>
            {errors.map((m, i) => (
              <li key={i}>{m}</li>
            ))}
          </ul>
        </div>
      )}

      <form className="ic-card" onSubmit={handleSubmit}>
        <div className="ic-grid">
          <label className="ic-label">
            コード
            <input className="ic-input" value={code} onChange={(e) => setCode(e.target.value)} placeholder="P-0037" />
          </label>

          <label className="ic-label">
            倉庫名
            <input className="ic-input" value={warehouseName} onChange={(e) => setWarehouseName(e.target.value)} placeholder="東京倉庫" />
          </label>

          <label className="ic-label full">
            名称
            <input className="ic-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="業務用プリンター用紙" />
          </label>

          <label className="ic-label">
            個数
            <input
              className="ic-input"
              type="number"
              value={qty}
              onChange={(e) => setQty(Number(e.target.value))}
              min={0}
            />
          </label>
        </div>

        <div className="ic-actions">
          <button className="ic-btn outline" type="button" onClick={onDone} disabled={loading}>
            キャンセル
          </button>

          <button className="ic-btn" type="submit" disabled={loading}>
            {loading ? "登録中..." : "登録"}
          </button>
        </div>
      </form>

      {successOpen && (
        <Modal title="登録完了" message="在庫を登録しました。" onClose={handleCloseSuccess} />
      )}
    </div>
  );
}

7) src/pages/InventoryCreatePage.css(別ファイル)

/**
 * InventoryCreatePage.css
 * ------------------------------------------------------------
 * 在庫 新規追加画面用スタイル
 */

.ic-page{
  min-height: 100vh;
  padding: 24px;
  background: #f8fafc;
}

.ic-header{
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #fff;
  border: 1px solid rgba(0,0,0,0.12);
  border-radius: 16px;
  padding: 16px 18px;
}

.ic-title{
  margin: 0;
  font-size: 20px;
  color: #111827;
}

.ic-card{
  margin-top: 14px;
  background: #fff;
  border: 1px solid rgba(0,0,0,0.12);
  border-radius: 16px;
  padding: 18px;
}

.ic-grid{
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}

.ic-label{
  font-size: 12px;
  color: #111827;
  display: grid;
  gap: 6px;
}

.ic-label.full{
  grid-column: 1 / -1;
}

.ic-input{
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid rgba(0,0,0,0.14);
  background: #f9fafb;
}

.ic-actions{
  margin-top: 16px;
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}

.ic-btn{
  padding: 10px 14px;
  border-radius: 12px;
  border: 1px solid rgba(0,0,0,0.14);
  background: #111827;
  color: #fff;
  cursor: pointer;
}

.ic-btn.outline{
  background: #fff;
  color: #111827;
}

.ic-btn:disabled{
  opacity: 0.6;
  cursor: not-allowed;
}

.ic-error{
  margin-top: 14px;
  background: #fff1f2;
  border: 1px solid rgba(190,18,60,0.25);
  color: #be123c;
  border-radius: 16px;
  padding: 12px 14px;
}

.ic-error ul{
  margin: 8px 0 0;
  padding-left: 18px;
}

8) src/components/Modal.css(別ファイル)

/**
 * Modal.css
 * ------------------------------------------------------------
 * 簡易モーダル用スタイル
 */

.modal-backdrop{
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.35);
  display: grid;
  place-items: center;
  padding: 16px;
}

.modal-card{
  width: 100%;
  max-width: 420px;
  background: #fff;
  border-radius: 16px;
  border: 1px solid rgba(0,0,0,0.12);
  padding: 18px;
}

.modal-title{
  margin: 0 0 8px;
  font-size: 18px;
  color: #111827;
}

.modal-message{
  margin: 0 0 14px;
  color: #374151;
}

.modal-actions{
  display: flex;
  justify-content: flex-end;
}

.modal-btn{
  padding: 10px 14px;
  border-radius: 12px;
  border: 1px solid rgba(0,0,0,0.14);
  background: #111827;
  color: #fff;
  cursor: pointer;
}

組み込み例(一覧画面から「新規追加」へ)

まだルーター無しで進めている場合、InventoryPage 側に mode を持たせて画面を切り替えると簡単です。

/**
 * 例:AppRoot などで画面切替(学習用)
 * - "list" と "create" を state で切り替える
 */
const [mode, setMode] = useState<"list" | "create">("list");

return mode === "list"
  ? <InventoryPage onCreate={() => setMode("create")} />
  : <InventoryCreatePage onDone={() => setMode("list")} />;

在庫 変更画面(React + json-server + Hooks)

仕様

注意点

画面イメージ(SVG)

在庫 変更 ID: 1 の在庫情報を更新します コード(変更不可) P-0037 倉庫名 横浜倉庫 名称 ネジ 個数 150 キャンセル 変更を保存

コード(Service / Page / App)

1) src/service/InventoryService.ts(更新メソッドの追加)

/**
 * InventoryService.ts
 */
// ... (既存の定義は維持)

export class InventoryService {
  // ... (createメソッド)

  /**
   * update
   * ----------------------------------------------------------
   * 役割:既存の在庫を更新する(PUT /inventory/:id)
   */
  async update(id: number, dto: InventoryCreateDto): Promise<InventoryEntity> {
    try {
      const res = await fetch(`${API_BASE}/inventory/${id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(dto),
      });

      if (!res.ok) {
        throw new Error(`HTTP ${res.status} ${res.statusText}`);
      }

      const updated: InventoryEntity = await res.json();
      return updated;
    } catch (e) {
      console.error("[InventoryService] update failed:", e);
      throw new InventoryServiceError(
        "Failed to update inventory.",
        "在庫の変更に失敗しました。時間をおいて再度お試しください。"
      );
    }
  }
}

2) src/pages/InventoryEditPage.tsx(新規作成)

/**
 * InventoryEditPage.tsx
 */
import React, { useMemo, useState } from "react";
import "./InventoryCreatePage.css"; // スタイルは共通

import type { InventoryEntity } from "../domain/InventoryEntity";
import type { InventoryCreateDto } from "../dto/InventoryCreateDto";
import { InventoryCreateValidator } from "../validation/InventoryCreateValidator";
import { InventoryService, InventoryServiceError } from "../service/InventoryService";
import Modal from "../components/Modal";

type InventoryEditPageProps = {
  item: InventoryEntity; // 編集対象のデータ
  onDone: () => void;
};

export default function InventoryEditPage({ item, onDone }: InventoryEditPageProps) {
  // 編集用の状態(初期値に現在のデータをセット)
  const [code] = useState(item.code);
  const [name, setName] = useState(item.name);
  const [qty, setQty] = useState<number | "">(item.qty);
  const [warehouseName, setWarehouseName] = useState(item.warehouseName);

  const [loading, setLoading] = useState(false);
  const [errors, setErrors] = useState<string[]>([]);
  const [successOpen, setSuccessOpen] = useState(false);

  const validator = useMemo(() => new InventoryCreateValidator(), []);
  const service = useMemo(() => new InventoryService(), []);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    const dto: InventoryCreateDto = {
      code,
      name,
      qty: qty === "" ? 0 : Number(qty),
      warehouseName,
    };

    const result = validator.validate(dto);
    if (!result.ok) {
      setErrors(result.errors);
      return;
    }

    setLoading(true);
    setErrors([]);

    try {
      await service.update(item.id, dto);
      setSuccessOpen(true);
    } catch (e) {
      if (e instanceof InventoryServiceError) {
        setErrors([e.safeMessage]);
      } else {
        setErrors(["変更の保存に失敗しました。"]);
      }
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="ic-page">
      <header className="ic-header">
        <h2 className="ic-title">在庫 変更</h2>
        <button className="ic-btn outline" onClick={onDone} disabled={loading}>戻る</button>
      </header>

      {errors.length > 0 && (
        <div className="ic-error">
          <ul>{errors.map((m, i) => <li key={i}>{m}</li>)}</ul>
        </div>
      )}

      <form className="ic-card" onSubmit={handleSubmit}>
        <div className="ic-grid">
          <label className="ic-label">
            コード(変更不可)
            <input className="ic-input" value={code} readOnly style={{ background: "#f3f4f6" }} />
          </label>

          <label className="ic-label">
            倉庫名
            <input className="ic-input" value={warehouseName} onChange={(e) => setWarehouseName(e.target.value)} />
          </label>

          <label className="ic-label full">
            名称
            <input className="ic-input" value={name} onChange={(e) => setName(e.target.value)} />
          </label>

          <label className="ic-label">
            個数
            <input 
              className="ic-input" 
              type="number" 
              value={qty} 
              onChange={(e) => setQty(e.target.value === "" ? "" : Number(e.target.value))} 
            />
          </label>
        </div>

        <div className="ic-actions">
          <button className="ic-btn outline" type="button" onClick={onDone} disabled={loading}>キャンセル</button>
          <button className="ic-btn" type="submit" disabled={loading}>
            {loading ? "保存中..." : "変更を保存"}
          </button>
        </div>
      </form>

      {successOpen && (
        <Modal title="更新完了" message="在庫情報を更新しました。" onClose={onDone} />
      )}
    </div>
  );
}

3) src/InventoryPage.tsx(行クリックイベントの追加)

/**
 * InventoryPage.tsx (抜粋修正)
 */
type InventoryPageProps = {
  onCreate: () => void;
  onEdit: (item: InventoryRow) => void; // ★ 編集用コールバックを追加
};

// ... (fetch処理などはそのまま)

// テーブル行へのクリックイベント付与
<tbody>
  {loading ? (
    <tr><td colSpan={4} className="muted">読み込み中...</td></tr>
  ) : (
    rows.map(r => (
      <tr 
        key={r.id} 
        onClick={() => onEdit(r)} // ★ 行クリックで編集画面へ
        style={{ cursor: "pointer" }}
        className="inv-row-clickable"
      >
        <td>{r.code}</td>
        <td>{r.name}</td>
        <td>{r.warehouseName}</td>
        <td className="right">{r.qty}</td>
      </tr>
    ))
  )}
</tbody>

4) src/App.tsx(ルーティングロジックの修正)

/**
 * App.tsx
 */
import InventoryEditPage from "./pages/InventoryEditPage"; // ★ インポート追加

function AppRoot() {
  const { user } = useAuth();
  
  // モード管理に "edit" を追加、選択中のアイテムを保持する state を定義
  const [mode, setMode] = useState<"list" | "create" | "edit">("list");
  const [selectedItem, setSelectedItem] = useState<InventoryEntity | null>(null);

  if (!user) return <LoginPage />;

  const backToList = () => {
    setSelectedItem(null);
    setMode("list");
  };

  const startEdit = (item: InventoryEntity) => {
    setSelectedItem(item);
    setMode("edit");
  };

  // 画面の出し分けロジック
  if (mode === "create") {
    return <InventoryCreatePage onDone={backToList} />;
  }

  if (mode === "edit" && selectedItem) {
    return <InventoryEditPage item={selectedItem} onDone={backToList} />;
  }

  return (
    <InventoryPage 
      onCreate={() => setMode("create")} 
      onEdit={startEdit} 
    />
  );
}

在庫 削除(React + json-server + Hooks)

仕様

なぜ「変更画面」に削除ボタンを置くのか

一覧画面に削除ボタンを並べると、クリックミスで誤削除しやすくなります。
そのため、実務では「詳細/編集」画面に移動してから削除する設計が多いです。
このサンプルでも 編集画面に削除ボタン を置き、削除前に confirm を出して事故を防ぎます(学習用)。

json-server の挙動(DELETE)

実装のポイント(Hooks)

1) loading で二重送信を防止

削除は 1 回しか実行してはいけない操作です。
loading=true の間は削除ボタンも戻るボタンも disable にして、連打・二重送信を防ぎます。

2) エラーは「安全なメッセージ」で表示する

画面には「在庫の削除に失敗しました」のような 安全な文言 を出し、
詳細は console.error で開発者が追えるようにします(実務の基本)。

3) 成功時はモーダルで完了を通知する

更新と削除はユーザーにとって影響が大きい操作なので、成功時はモーダルで確実に通知します。
要件どおり、削除成功時は 「在庫を削除しました」 を表示します。

画面イメージ(削除ボタン追加)

在庫 変更 ID: 1 の在庫情報を更新します 削除 コード(変更不可) P-0037 倉庫名 横浜倉庫 名称 ネジ 個数 150 キャンセル 変更を保存 削除完了 在庫を削除しました OK

修正が必要なコード(全コード)

1) src/service/InventoryService.ts(delete: remove を追加)

/**
 * InventoryService.ts
 * ------------------------------------------------------------
 * 役割:
 * - 在庫の登録/更新/削除(API呼び出し)を担当
 * - UI(React)に依存しないようにする
 * - 例外を握りつぶさず、ログは詳細・画面は安全メッセージを返す
 */

import type { InventoryEntity } from "../domain/InventoryEntity";
import type { InventoryCreateDto } from "../dto/InventoryCreateDto";

/**
 * InventoryServiceError
 * ------------------------------------------------------------
 * 役割:
 * - 業務向けのサービス例外(ユーザーに安全メッセージを出すため)
 */
export class InventoryServiceError extends Error {
  public readonly safeMessage: string;

  constructor(message: string, safeMessage: string) {
    super(message);
    this.name = "InventoryServiceError";
    this.safeMessage = safeMessage;
  }
}

const API_BASE = "http://localhost:3001";

export class InventoryService {
  /**
   * create
   * ----------------------------------------------------------
   * 役割:
   * - 在庫を新規作成する(POST /inventory)
   * - 成功したら作成済みレコード(id付き)を返す
   */
  async create(dto: InventoryCreateDto): Promise<InventoryEntity> {
    try {
      const res = await fetch(`${API_BASE}/inventory`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(dto),
      });

      if (!res.ok) {
        throw new Error(`HTTP ${res.status} ${res.statusText}`);
      }

      const created: InventoryEntity = await res.json();
      return created;
    } catch (e) {
      console.error("[InventoryService] create failed:", e);
      throw new InventoryServiceError(
        "Failed to create inventory.",
        "在庫の登録に失敗しました。時間をおいて再度お試しください。"
      );
    }
  }

  /**
   * update
   * ----------------------------------------------------------
   * 役割:
   * - 既存の在庫を更新する(PUT /inventory/:id)
   */
  async update(id: number, dto: InventoryCreateDto): Promise<InventoryEntity> {
    try {
      const res = await fetch(`${API_BASE}/inventory/${id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(dto),
      });

      if (!res.ok) {
        throw new Error(`HTTP ${res.status} ${res.statusText}`);
      }

      const updated: InventoryEntity = await res.json();
      return updated;
    } catch (e) {
      console.error("[InventoryService] update failed:", e);
      throw new InventoryServiceError(
        "Failed to update inventory.",
        "在庫の変更に失敗しました。時間をおいて再度お試しください。"
      );
    }
  }

  /**
   * remove
   * ----------------------------------------------------------
   * 役割:
   * - 在庫を削除する(DELETE /inventory/:id)
   * - json-server は削除成功で 200 / 204 を返します(実装差異あり)
   */
  async remove(id: number): Promise<void> {
    try {
      const res = await fetch(`${API_BASE}/inventory/${id}`, {
        method: "DELETE",
      });

      if (!res.ok) {
        throw new Error(`HTTP ${res.status} ${res.statusText}`);
      }
    } catch (e) {
      console.error("[InventoryService] remove failed:", e);
      throw new InventoryServiceError(
        "Failed to delete inventory.",
        "在庫の削除に失敗しました。時間をおいて再度お試しください。"
      );
    }
  }
}

2) src/pages/InventoryEditPage.tsx(削除ボタン+削除成功モーダル)

/**
 * InventoryEditPage.tsx
 * ------------------------------------------------------------
 * 追加したこと:
 * - 削除ボタン(DELETE /inventory/:id)
 * - 削除成功時にモーダルで「在庫を削除しました」を表示
 *
 * 学べる Hook:
 * - useState:入力値・エラー・ローディング・モーダル表示状態
 * - useMemo:Validator/Service を 1 度だけ生成して使い回す
 */

import React, { useMemo, useState } from "react";
import "./InventoryCreatePage.css"; // スタイルは共通

import type { InventoryEntity } from "../domain/InventoryEntity";
import type { InventoryCreateDto } from "../dto/InventoryCreateDto";
import { InventoryCreateValidator } from "../validation/InventoryCreateValidator";
import { InventoryService, InventoryServiceError } from "../service/InventoryService";
import Modal from "../components/Modal";

type InventoryEditPageProps = {
  item: InventoryEntity; // 編集対象のデータ
  onDone: () => void;
};

type ModalState =
  | { open: false }
  | { open: true; title: string; message: string; onClose: () => void };

export default function InventoryEditPage({ item, onDone }: InventoryEditPageProps) {
  // 編集用の状態(初期値に現在のデータをセット)
  const [code] = useState(item.code);
  const [name, setName] = useState(item.name);
  const [qty, setQty] = useState<number | "">(item.qty);
  const [warehouseName, setWarehouseName] = useState(item.warehouseName);

  const [loading, setLoading] = useState(false);
  const [errors, setErrors] = useState<string[]>([]);
  const [modal, setModal] = useState<ModalState>({ open: false });

  const validator = useMemo(() => new InventoryCreateValidator(), []);
  const service = useMemo(() => new InventoryService(), []);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    const dto: InventoryCreateDto = {
      code,
      name,
      qty: qty === "" ? 0 : Number(qty),
      warehouseName,
    };

    const result = validator.validate(dto);
    if (!result.ok) {
      setErrors(result.errors);
      return;
    }

    setLoading(true);
    setErrors([]);

    try {
      await service.update(item.id, dto);

      setModal({
        open: true,
        title: "更新完了",
        message: "在庫情報を更新しました。",
        onClose: onDone,
      });
    } catch (e) {
      if (e instanceof InventoryServiceError) {
        setErrors([e.safeMessage]);
      } else {
        setErrors(["変更の保存に失敗しました。"]);
      }
    } finally {
      setLoading(false);
    }
  }

  async function handleDelete() {
    // 事故防止:削除前に確認(学習用。要件外なら外してOK)
    const ok = window.confirm("この在庫を削除します。よろしいですか?");
    if (!ok) return;

    setLoading(true);
    setErrors([]);

    try {
      await service.remove(item.id);

      setModal({
        open: true,
        title: "削除完了",
        message: "在庫を削除しました",
        onClose: onDone,
      });
    } catch (e) {
      if (e instanceof InventoryServiceError) {
        setErrors([e.safeMessage]);
      } else {
        setErrors(["在庫の削除に失敗しました。"]);
      }
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="ic-page">
      <header className="ic-header">
        <h2 className="ic-title">在庫 変更</h2>

        <div style={{ display: "flex", gap: 10 }}>
          <button className="ic-btn outline" onClick={onDone} disabled={loading}>
            戻る
          </button>

          <button
            className="ic-btn outline"
            type="button"
            onClick={handleDelete}
            disabled={loading}
            style={{ borderColor: "#ef4444", color: "#ef4444" }}
          >
            削除
          </button>
        </div>
      </header>

      {errors.length > 0 && (
        <div className="ic-error">
          <ul>{errors.map((m, i) => <li key={i}>{m}</li>)}</ul>
        </div>
      )}

      <form className="ic-card" onSubmit={handleSubmit}>
        <div className="ic-grid">
          <label className="ic-label">
            コード(変更不可)
            <input className="ic-input" value={code} readOnly style={{ background: "#f3f4f6" }} />
          </label>

          <label className="ic-label">
            倉庫名
            <input className="ic-input" value={warehouseName} onChange={(e) => setWarehouseName(e.target.value)} />
          </label>

          <label className="ic-label full">
            名称
            <input className="ic-input" value={name} onChange={(e) => setName(e.target.value)} />
          </label>

          <label className="ic-label">
            個数
            <input
              className="ic-input"
              type="number"
              value={qty}
              onChange={(e) => setQty(e.target.value === "" ? "" : Number(e.target.value))}
            />
          </label>
        </div>

        <div className="ic-actions">
          <button className="ic-btn outline" type="button" onClick={onDone} disabled={loading}>
            キャンセル
          </button>

          <button className="ic-btn" type="submit" disabled={loading}>
            {loading ? "保存中..." : "変更を保存"}
          </button>
        </div>
      </form>

      {modal.open && (
        <Modal title={modal.title} message={modal.message} onClose={modal.onClose} />
      )}
    </div>
  );
}

動作確認(手順)

  1. 一覧 → 行クリックで「在庫 変更」へ
  2. 右上の 削除 を押す
  3. 確認ダイアログで OK
  4. モーダルで 「在庫を削除しました」 が出たら OK を押す
  5. 一覧に戻り、該当行が消えていることを確認
補足(本番では)
本来は「削除権限」「監査ログ」「論理削除(deleted フラグ)」などを設けます。
ただし学習用サンプルではまず「DELETE の一連の流れ」と「UI状態管理」を掴むのが目的です。

在庫削除:成功モーダル + 元のページの一覧画面へ戻る処理

本機能の主題は、 削除後に「元のページの一覧画面へ戻る」ことである。

在庫一覧はページングされており、ユーザーは任意のページ(例:3ページ目)を閲覧している。 その状態で変更画面に遷移し、削除を行った場合、 単純に一覧画面へ戻すだけでは どのページへ戻るべきかが定義されない

この問題を解決するため、 変更画面へ遷移する時点で returnPage(一覧で表示していたページ番号)を保持し、 削除完了後に onDone(returnPage) として戻すことで、 元のページの一覧画面へ戻る動作を実現する。

削除によってページ構造が変化する場合の挙動

削除はデータ件数を減少させるため、 総ページ数(totalPages)が変化する可能性がある。

例えば以下のケースを考える。

このとき returnPage=3 をそのまま使用すると、 存在しないページを参照することになる

この問題は、一覧画面側で以下の処理を行うことで解決する。

これにより、 元のページが存在しない場合は自動的に前のページへ戻る動作となる。

データが0件になった場合の挙動

削除によってデータが0件になるケースも存在する。

この場合、単純に計算すると totalPages は 0 になるが、 ページ番号 0 は UI として成立しないため、 以下のルールを適用する。

つまり、データが0件でも 1ページとして扱う

その上で、

の場合、

page = min(returnPage, totalPages) = 1

となり、 1ページ目(データ0件表示の一覧画面)へ戻る

この処理により、

のすべてのケースを、 同一ロジックで一貫して処理できる

修正が必要なファイル


App.tsx(全コード)

/**
 * App.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 画面の出し分け(未ログインなら LoginPage、ログイン済みなら InventoryPage)
 * - Context(AuthProvider)で全体を包む
 *
 * ★ 追加(在庫削除後に「元のページの一覧画面へ戻る」ための仕組み)
 * - InventoryPage(一覧)のページ番号(page)は、InventoryPage内部だけで持つと編集画面に遷移した瞬間に失われる
 *   (InventoryPageがアンマウントされるため)。
 * - そこで App.tsx 側で「一覧で最後に見ていたページ番号(listPage)」を保持し、
 *   編集画面へ行く直前のページ番号(returnPage)を編集画面へ渡す。
 * - 編集画面(更新/削除/キャンセル)は onDone(returnPage) で App.tsx に戻りページを返す。
 * - App.tsx は listPage を returnPage に更新してから一覧へ戻し、InventoryPage には initialPage として渡す。
 *
 * ★ ページが減る/0件になるケースの扱い
 * - 削除で総件数が減ると、returnPage が「存在しないページ」になる可能性がある(例:3ページ目の最後の1件を削除→2ページになる)。
 * - この補正は編集画面では行わず、一覧側(InventoryPage)が totalPages を再計算し、
 *   page が totalPages を超える場合は totalPages に丸める方式で一貫処理する。
 * - totalCount=0 の場合も UI 破綻を避けるため totalPages は最低 1 として扱い、
 *   結果として「1ページ目(空一覧)」へ戻る。
 */

import React, { useState } from "react";
import { AuthProvider, useAuth } from "./context/AuthContext";
import LoginPage from "./LoginPage";
import InventoryPage from "./InventoryPage";
import InventoryCreatePage from "./pages/InventoryCreatePage";
import InventoryEditPage from "./pages/InventoryEditPage";
import type { InventoryEntity } from "./domain/InventoryEntity";

/**
 * AppRoot
 * ------------------------------------------------------------
 * 役割:
 * - Context の中で「表示する画面」を決める
 * ※ useAuth() は Provider の内側で呼ぶ必要があるため分離
 */
function AppRoot() {
  const { user } = useAuth();

  // 画面モード(一覧 / 新規 / 編集)
  const [mode, setMode] = useState<"list" | "create" | "edit">("list");

  // 編集対象(一覧の行クリックで設定される)
  const [selectedItem, setSelectedItem] = useState<InventoryEntity | null>(null);

  /**
   * listPage:一覧で最後に見ていたページ番号
   * ------------------------------------------------------------
   * - InventoryPage 側でページ移動したら onPageChange(page) で更新される
   * - 編集画面から戻るときも、onDone(returnPage) で受け取ったページをここへ反映する
   */
  const [listPage, setListPage] = useState(1);

  /**
   * editReturnPage:編集画面へ入った瞬間の「戻り先ページ番号」
   * ------------------------------------------------------------
   * - listPage は一覧画面操作で変化する値のため、
   *   編集画面へ遷移する瞬間に固定した値を別途保持しておく。
   * - 編集画面(更新/削除/キャンセル)から戻る際は常にこの値を返す。
   */
  const [editReturnPage, setEditReturnPage] = useState(1);

  if (!user) return <LoginPage />;

  /**
   * backToList:一覧へ戻る(更新/削除/キャンセル共通)
   * ------------------------------------------------------------
   * - 編集画面は onDone(returnPage) で「戻り先ページ番号」を返す
   * - App.tsx はそれを listPage に反映してから一覧へ戻す
   * - これにより「常に1ページ目へ戻る」問題を防ぐ
   */
  const backToList = (returnPage: number) => {
    setSelectedItem(null);
    setListPage(returnPage);
    setMode("list");
  };

  /**
   * startEdit:一覧 → 編集
   * ------------------------------------------------------------
   * - InventoryPage が保持している「現在ページ」を returnPage として受け取る
   * - 編集画面へ入った瞬間の戻り先を editReturnPage に固定し、編集画面へ渡す
   */
  const startEdit = (item: InventoryEntity, returnPage: number) => {
    setSelectedItem(item);
    setEditReturnPage(returnPage);
    setMode("edit");
  };

  // 画面の出し分け
  if (mode === "create") {
    // 新規作成は「戻り先ページ」を別途求めないため、直前に見ていた listPage に戻す
    return <InventoryCreatePage onDone={() => backToList(listPage)} />;
  }

  if (mode === "edit" && selectedItem) {
    return (
      <InventoryEditPage
        item={selectedItem}
        returnPage={editReturnPage}
        onDone={backToList}
      />
    );
  }

  return (
    <InventoryPage
      initialPage={listPage}
      onPageChange={setListPage}
      onCreate={() => setMode("create")}
      onEdit={startEdit}
    />
  );
}

export default function App() {
  return (
    <AuthProvider>
      <AppRoot />
    </AuthProvider>
  );
}

InventoryPage.tsx


/**
 * InventoryPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 在庫一覧(ページング付き)
 * - user 表示・ログアウトは Context から取得する
 *
 * ★ バグ修正ポイント(核心)
 * - 一覧に戻った直後、totalCount が未取得なのに 0 扱いされ、
 *   totalPages=1 と誤判定 → page が 1 に落ちる
 * - その状態で「次へ」を押すと 2 になる(本来のページではない)
 *
 * ✅ 対策
 * - totalCount を null(未取得)で扱う
 * - totalPages も null(未確定)にする
 * - 未確定の間はページ補正・ボタン操作を止める
 */

import { useEffect, useMemo, useState } from "react";
import { useAuth } from "./context/AuthContext";
import "./InventoryPage.css";

type InventoryRow = {
  id: number;
  code: string;
  name: string;
  qty: number;
  warehouseName: string;
};

type InventoryPageProps = {
  onCreate: () => void;
  onEdit: (item: InventoryRow, returnPage: number) => void;
  initialPage: number;
  onPageChange: (page: number) => void;
};

const API_BASE = "http://localhost:3001";

export default function InventoryPage({
  onCreate,
  onEdit,
  initialPage,
  onPageChange,
}: InventoryPageProps) {
  const { user, logout } = useAuth();

  const [rows, setRows] = useState<InventoryRow[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // ★ 初期ページ
  const [page, setPage] = useState(initialPage);

  const [limit] = useState(10);

  /**
   * ★ 未取得は null
   */
  const [totalCount, setTotalCount] = useState<number | null>(null);

  /**
   * ★ totalPages も未確定を許容
   */
  const totalPages = useMemo<number | null>(() => {
    if (totalCount === null) return null;
    return Math.max(1, Math.ceil(totalCount / limit));
  }, [totalCount, limit]);

  async function fetchInventory(targetPage: number) {
    setLoading(true);
    setError(null);

    try {
      const url = `${API_BASE}/inventory?_page=${targetPage}&_limit=${limit}`;
      const res = await fetch(url);

      if (!res.ok) throw new Error();

      const data: InventoryRow[] = await res.json();
      const total = Number(res.headers.get("X-Total-Count") ?? "0");

      setRows(data);
      setTotalCount(total); // ← 確定
    } catch {
      setRows([]);
      setTotalCount(0); // ← 空一覧扱い
    } finally {
      setLoading(false);
    }
  }

  /**
   * 戻りページ復元
   */
  useEffect(() => {
    setPage(initialPage);
  }, [initialPage]);

  /**
   * ページ変更
   */
  useEffect(() => {
    fetchInventory(page);
    onPageChange(page);
    // eslint-disable-next-line
  }, [page]);

  /**
   * ★ ページ補正(未確定時はスキップ)
   */
  useEffect(() => {
    if (totalPages === null) return;
    if (page > totalPages) {
      setPage(totalPages);
    }
  }, [page, totalPages]);

  /**
   * 表示用
   */
  const pageInfoTotal = totalPages === null ? "?" : String(totalPages);
  const countInfo = totalCount === null ? "..." : String(totalCount);

  /**
   * ボタン制御
   */
  const disablePrev = loading || totalPages === null || page <= 1;
  const disableNext =
    loading || totalPages === null || (totalPages !== null && page >= totalPages);

  return (
    <div className="inv-page">
      <header className="inv-header">
        <h2>在庫一覧</h2>
        <button onClick={logout}>ログアウト</button>
      </header>

      <div>
        ページ:{page} / {pageInfoTotal}(総件数:{countInfo})
      </div>

      <button disabled={disablePrev} onClick={() => setPage(p => p - 1)}>
        前へ
      </button>

      <button
        disabled={disableNext}
        onClick={() =>
          setPage(p => (totalPages === null ? p : Math.min(totalPages, p + 1)))
        }
      >
        次へ
      </button>

      <table>
        <tbody>
          {rows.map(r => (
            <tr key={r.id} onClick={() => onEdit(r, page)}>
              <td>{r.code}</td>
              <td>{r.name}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <button onClick={onCreate}>新規追加</button>
    </div>
  );
}

InventoryEditPage.tsx(全コード)

/**
 * InventoryEditPage.tsx
 * ------------------------------------------------------------
 * 役割:
 * - 在庫の変更(PUT)
 * - 在庫の削除(DELETE)
 *
 * ★ 仕様
 * - props で returnPage(編集画面へ来る直前に一覧で表示していたページ番号)を受け取る
 * - 更新成功:モーダルを閉じたら onDone(returnPage)
 * - 削除成功:モーダルで「在庫を削除しました」を表示し、閉じたら onDone(returnPage)
 * - キャンセル/戻る:onDone(returnPage)
 *
 * ★ ページが減る/0件になるケースの扱い
 * - 編集画面側では returnPage の正当性(そのページが存在するか)を判断しない
 * - 一覧側(InventoryPage)が totalPages を再計算して page を補正するため、
 *   編集画面は常に「編集直前の returnPage」を返すだけで良い
 */

import React, { useMemo, useState } from "react";
import "./InventoryCreatePage.css"; // スタイルは共通

import type { InventoryEntity } from "../domain/InventoryEntity";
import type { InventoryCreateDto } from "../dto/InventoryCreateDto";
import { InventoryCreateValidator } from "../validation/InventoryCreateValidator";
import { InventoryService, InventoryServiceError } from "../service/InventoryService";
import Modal from "../components/Modal";

type InventoryEditPageProps = {
  item: InventoryEntity; // 編集対象のデータ
  returnPage: number; // ★ 元のページへ戻るためのページ番号
  onDone: (returnPage: number) => void; // ★ 戻り先ページ番号を返して一覧へ戻る
};

type ModalState =
  | { open: false }
  | { open: true; title: string; message: string; onClose: () => void };

export default function InventoryEditPage({
  item,
  returnPage,
  onDone,
}: InventoryEditPageProps) {
  // 編集用の状態(初期値に現在のデータをセット)
  const [code] = useState(item.code);
  const [name, setName] = useState(item.name);
  const [qty, setQty] = useState<number | "">(item.qty);
  const [warehouseName, setWarehouseName] = useState(item.warehouseName);

  const [loading, setLoading] = useState(false);
  const [errors, setErrors] = useState<string[]>([]);
  const [modal, setModal] = useState<ModalState>({ open: false });

  const validator = useMemo(() => new InventoryCreateValidator(), []);
  const service = useMemo(() => new InventoryService(), []);

  /**
   * 更新保存(PUT)
   */
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    const dto: InventoryCreateDto = {
      code,
      name,
      qty: qty === "" ? 0 : Number(qty),
      warehouseName,
    };

    const result = validator.validate(dto);
    if (!result.ok) {
      setErrors(result.errors);
      return;
    }

    setLoading(true);
    setErrors([]);

    try {
      await service.update(item.id, dto);

      setModal({
        open: true,
        title: "更新完了",
        message: "在庫情報を更新しました。",
        onClose: () => onDone(returnPage),
      });
    } catch (e) {
      if (e instanceof InventoryServiceError) {
        setErrors([e.safeMessage]);
      } else {
        setErrors(["変更の保存に失敗しました。"]);
      }
    } finally {
      setLoading(false);
    }
  }

  /**
   * 削除(DELETE)
   */
  async function handleDelete() {
    const ok = window.confirm("この在庫を削除します。よろしいですか?");
    if (!ok) return;

    setLoading(true);
    setErrors([]);

    try {
      await service.remove(item.id);

      setModal({
        open: true,
        title: "削除完了",
        message: "在庫を削除しました",
        onClose: () => onDone(returnPage),
      });
    } catch (e) {
      if (e instanceof InventoryServiceError) {
        setErrors([e.safeMessage]);
      } else {
        setErrors(["在庫の削除に失敗しました。"]);
      }
    } finally {
      setLoading(false);
    }
  }

  /**
   * キャンセル/戻る
   */
  function handleCancel() {
    onDone(returnPage);
  }

  return (
    <div className="ic-page">
      <header className="ic-header">
        <h2 className="ic-title">在庫 変更</h2>

        <div style={{ display: "flex", gap: 10 }}>
          <button
            className="ic-btn outline"
            type="button"
            onClick={handleCancel}
            disabled={loading}
          >
            キャンセル
          </button>

          <button
            className="ic-btn outline"
            type="button"
            onClick={handleDelete}
            disabled={loading}
            style={{ borderColor: "#ef4444", color: "#ef4444" }}
          >
            削除
          </button>
        </div>
      </header>

      {errors.length > 0 && (
        <div className="ic-error">
          <ul>
            {errors.map((m, i) => (
              <li key={i}>{m}</li>
            ))}
          </ul>
        </div>
      )}

      <form className="ic-card" onSubmit={handleSubmit}>
        <div className="ic-grid">
          <label className="ic-label">
            コード(変更不可)
            <input
              className="ic-input"
              value={code}
              readOnly
              style={{ background: "#f3f4f6" }}
            />
          </label>

          <label className="ic-label">
            倉庫名
            <input
              className="ic-input"
              value={warehouseName}
              onChange={(e) => setWarehouseName(e.target.value)}
            />
          </label>

          <label className="ic-label full">
            名称
            <input
              className="ic-input"
              value={name}
              onChange={(e) => setName(e.target.value)}
            />
          </label>

          <label className="ic-label">
            個数
            <input
              className="ic-input"
              type="number"
              value={qty}
              onChange={(e) =>
                setQty(e.target.value === "" ? "" : Number(e.target.value))
              }
            />
          </label>
        </div>

        <div className="ic-actions">
          <button
            className="ic-btn outline"
            type="button"
            onClick={handleCancel}
            disabled={loading}
          >
            キャンセル
          </button>
          <button className="ic-btn" type="submit" disabled={loading}>
            {loading ? "保存中..." : "変更を保存"}
          </button>
        </div>
      </form>

      {modal.open && (
        <Modal title={modal.title} message={modal.message} onClose={modal.onClose} />
      )}
    </div>
  );
}

補足:InventoryCreatePage.tsx は影響を受けるのか?

今回の「元のページへ戻る」仕様は、一覧 → 変更(編集) の導線で必要になるため、 変更が必要なのは上記 3 ファイルだけです。
新規追加(create)は、要件として「元のページへ戻る」対象に含めていないため、 InventoryCreatePage.tsx / InventoryCreatePage.css は修正不要です。

完全分離されたContext、カスタムフック、React Router、再描画しない時計、useMemoを網羅した最終形態のコードです。

【準備】React Router のインストール
このコードを動かすには、プロジェクトのターミナルで以下のコマンドを実行し、標準のルーティングライブラリをインストールしてください。
npm install react-router

各画面がHeader、Body、Footerの3層構造に分かれ、実務の大規模SPA(シングルページアプリケーション)と全く同じアーキテクチャになっています。


1. AuthContext.tsx(状態とロジックの隠蔽)

Contextの作成とタイマー管理をこのファイルに隔離し、他からは useAuth() というフック経由でしか触れないようにカプセル化しています。


import { createContext, useState, useRef, useEffect, MutableRefObject, useContext, ReactNode } from 'react';

// ① 配るデータの型定義
type AuthContextType = {
  user: string;
  setUser: (user: string) => void;
  tokenRef: MutableRefObject<string>;
  expireRef: MutableRefObject<number>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);

// ② 全体を包み込むProviderコンポーネント
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState("未ログイン");
  const tokenRef = useRef<string>("");
  const expireRef = useRef<number>(0);
  const lastActivityRef = useRef<number>(Date.now());

  useEffect(() => {
    const updateActivity = () => { lastActivityRef.current = Date.now(); };
    window.addEventListener('mousemove', updateActivity);
    window.addEventListener('keydown', updateActivity);

    const intervalId = setInterval(() => {
      if (Date.now() - lastActivityRef.current > 600000) {
        setUser("未ログイン"); // 全画面を強制的に未ログイン状態に再描画
        tokenRef.current = "";
        expireRef.current = 0;
      }
    }, 60000);

    return () => {
      window.removeEventListener('mousemove', updateActivity);
      window.removeEventListener('keydown', updateActivity);
      clearInterval(intervalId);
    };
  }, []);

  return (
    <AuthContext.Provider value={{ user, setUser, tokenRef, expireRef }}>
      {children}
    </AuthContext.Provider>
  );
}

// 💡【重要:カスタムフック】
// 他のファイルは useContext ではなく、必ずこの useAuth を呼び出します
export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error("useAuthはAuthProviderの中で使用してください");
  }
  return context;
}
  

2. App.tsx(ルーティングの親玉)

react-router を使用し、URLに応じて表示する画面を振り分けます。全体を BrowserRouter で囲む必要があります。


import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import { AuthProvider, useAuth } from './AuthContext';
import Login from './Login';
import BusinessPage1 from './BusinessPage1';
import BusinessPage2 from './BusinessPage2';
import BusinessPage3 from './BusinessPage3';

// ログイン状態を判定して、画面を振り分ける関所
function RouterGuard() {
  const { user } = useAuth(); // カスタムフックでスッキリ取得!

  if (user === "未ログイン") {
    return <Login />;
  }

  return (
    <Routes>
      <Route path="/page1" element={<BusinessPage1 />} />
      <Route path="/page2" element={<BusinessPage2 />} />
      <Route path="/page3" element={<BusinessPage3 />} />
      {/* 💡 存在しないURLならPage1へ強制送還 */}
      <Route path="*" element={<Navigate to="/page1" replace />} />
    </Routes>
  );
}

export default function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <div style={{ border: '2px solid blue', padding: '20px' }}>
          <h1>App.tsx (システム基盤)</h1>
          <RouterGuard />
        </div>
      </BrowserRouter>
    </AuthProvider>
  );
}
  

3. Header.tsx(再描画させない時計とナビゲーション)

Reactの再描画システムから逃れ、DOMの innerHTML を直接書き換えることでパフォーマンスを極限まで高めたヘッダーです。


import { useEffect, useRef } from 'react';
import { Link } from 'react-router';
import { useAuth } from './AuthContext';

export default function Header() {
  const { user, setUser } = useAuth();
  
  // 💡 DOMを直接操作するためのRef
  const clockRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const updateClock = () => {
      if (clockRef.current) {
        const now = new Date();
        // 💡 画面全体を再描画(useState)させず、HTMLの中身だけをこっそり書き換える
        clockRef.current.innerHTML = now.toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
      }
    };
    
    updateClock(); // 初回表示

    // 毎分0秒に正確に発火させるための遅延計算
    const now = new Date();
    const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();

    let intervalId: NodeJS.Timeout;
    const timeoutId = setTimeout(() => {
      updateClock();
      intervalId = setInterval(updateClock, 60000); // 以降は60秒ごとに更新
    }, delay);

    return () => {
      clearTimeout(timeoutId);
      clearInterval(intervalId);
    };
  }, []);

  return (
    <header style={{ borderBottom: '2px solid #ccc', paddingBottom: '10px', marginBottom: '10px', display: 'flex', gap: '20px', alignItems: 'center' }}>
      <div>ユーザー: <b>{user}</b></div>
      
      {/* 💡 ここが1分ごとに直接書き換わる */}
      <div style={{ fontSize: '1.2em' }}>🕒 <span ref={clockRef}></span></div>

      <nav style={{ marginLeft: 'auto', display: 'flex', gap: '10px' }}>
        {/* 💡 react-router では href ではなく to を使用します */}
        <Link to="/page1"><button>画面1</button></Link>
        <Link to="/page2"><button>画面2</button></Link>
        <Link to="/page3"><button>画面3</button></Link>
        <button onClick={() => setUser("未ログイン")}>ログアウト</button>
      </nav>
    </header>
  );
}
  

4. Footer.tsx(useMemoの学習用)

渡されたページ番号を元に計算を行います。useMemo によって、不要な再計算を防ぎます。


import { useMemo } from 'react';

type FooterProps = {
  pageNumber: number;
};

export default function Footer({ pageNumber }: FooterProps) {
  // 💡 pageNumber が変化した時「だけ」中の計算処理が走る
  const calculatedValue = useMemo(() => {
    console.log(`Footer: ページ番号 ${pageNumber} の100倍を計算しました!`);
    return pageNumber * 100;
  }, [pageNumber]);

  return (
    <footer style={{ borderTop: '2px solid #ccc', paddingTop: '10px', marginTop: '20px' }}>
      <div>
        【フッター】渡されたページ番号 <b>{pageNumber}</b> を100倍にした数値:
        <span style={{ color: 'red', fontSize: '1.2em', fontWeight: 'bold', marginLeft: '10px' }}>
          {calculatedValue}
        </span>
      </div>
    </footer>
  );
}
  

5. 各BusinessPage群(ヘッダー・ボディ・フッターの合体)

ルーティングで呼び出される各ページです。レイアウトを構築し、Footerへ番号を渡します。


// =============== BusinessPage1.tsx ===============
import Header from './Header';
import Footer from './Footer';

export default function BusinessPage1() {
  return (
    <div>
      <Header />
      <main style={{ padding: '20px', backgroundColor: '#eef' }}>
        <h2>業務画面 1</h2>
        <p>ここは画面1のメインボディ領域です。</p>
      </main>
      <Footer pageNumber={1} /> {/* 💡 フッターに 1 を渡す */}
    </div>
  );
}

// =============== BusinessPage2.tsx ===============
import Header from './Header';
import Footer from './Footer';

export default function BusinessPage2() {
  return (
    <div>
      <Header />
      <main style={{ padding: '20px', backgroundColor: '#efe' }}>
        <h2>業務画面 2</h2>
        <p>ここは画面2のメインボディ領域です。</p>
      </main>
      <Footer pageNumber={2} /> {/* 💡 フッターに 2 を渡す */}
    </div>
  );
}

// =============== BusinessPage3.tsx ===============
import Header from './Header';
import Footer from './Footer';

export default function BusinessPage3() {
  return (
    <div>
      <Header />
      <main style={{ padding: '20px', backgroundColor: '#fee' }}>
        <h2>業務画面 3</h2>
        <p>ここは画面3のメインボディ領域です。</p>
      </main>
      <Footer pageNumber={3} /> {/* 💡 フッターに 3 を渡す */}
    </div>
  );
}
  

6. Login.tsx(ログイン画面)


import { useState } from 'react';
import { useAuth } from './AuthContext'; // 💡 カスタムフックを使用

export default function Login() {
  const { setUser, tokenRef, expireRef } = useAuth();
  const [loading, setLoading] = useState(false);

  const handleLogin = async () => {
    setLoading(true);
    await new Promise(resolve => setTimeout(resolve, 1000));

    const apiResponse = {
      name: "山田太郎",
      token: "dummy_token_abc123",
      expiresIn: 3600000
    };

    tokenRef.current = apiResponse.token;
    expireRef.current = Date.now() + apiResponse.expiresIn;
    setUser(apiResponse.name); // App.tsxのRouterGuardが反応し、ページが切り替わる
  };

  return (
    <div style={{ border: '2px solid red', padding: '20px', marginTop: '20px' }}>
      <h2>Login.tsx</h2>
      <button onClick={handleLogin} disabled={loading}>
        {loading ? "通信中..." : "ログインする"}
      </button>
    </div>
  );
}
  

通信の門番(Axiosインターセプター)の実装

これまで App.tsx と Context を使って「画面用のState」と「裏方用のRef」を完全に分離してきたのは、すべてはこの「門番(api.ts)に、再描画の副作用なしで常に最新のトークンを渡し続けるため」だったのです。
この仕組みを作ると、今後アプリ内で100回API通信を書いたとしても、トークンのセットや有効期限切れのチェックを1回も書かなくて済むという、魔法のような状態になります。

実務で必ず使われる、最も美しく安全なインターセプターの実装例を解説します。

準備:Axiosのインストール
ターミナルで以下のコマンドを実行し、Axiosをインストールします。
npm install axios

1. api.ts(門番の本体を作成するファイル)

コンポーネントとは全く別の、純粋なTypeScriptファイル(api.ts など)を新規作成し、そこに「門番付きの専用Axios」を定義します。


// api.ts
import axios from 'axios';
import { MutableRefObject } from 'react';

// ① 自分専用のAxiosインスタンス(分身)を作る
export const api = axios.create({
  baseURL: 'http://localhost:8080/api', // バックエンドのURL
  timeout: 5000,
});

// ② React側の「裏方データ」をこのファイルに注入するためのセットアップ関数
export const setupInterceptors = (
  tokenRef: MutableRefObject<string>,
  logoutCallback: () => void // 強制ログアウト用の関数
) => {

  // 🚪【行きの門番】リクエストを送信する「直前」に発動
  api.interceptors.request.use((config) => {
    // もし裏の金庫(tokenRef)にトークンがあれば、ヘッダーにこっそり忍ばせる
    if (tokenRef.current) {
      config.headers.Authorization = `Bearer ${tokenRef.current}`;
    }
    return config; // そのまま送り出す
  });

  // 🚪【帰りの門番】レスポンスを受け取った「直後」に発動
  api.interceptors.response.use(
    (response) => {
      // 正常な通信だった場合は、そのまま通す
      return response;
    },
    (error) => {
      // 通信エラーの中で、もし「401 Unauthorized(認証切れ)」が返ってきたら
      if (error.response && error.response.status === 401) {
        alert("セッションの有効期限が切れました。再度ログインしてください。");
        logoutCallback(); // React側に「強制ログアウトして!」と指令を出す
      }
      return Promise.reject(error);
    }
  );
};
  

2. AuthContext.tsx(門番を起動する)

先ほど作った setupInterceptors 関数を、アプリの心臓部である AuthContext.tsx の中で1回だけ呼び出して「門番を配置(起動)」します。


// AuthContext.tsx(一部抜粋)
import { createContext, useState, useRef, useEffect, MutableRefObject, useContext, ReactNode } from 'react';
import { setupInterceptors } from './api'; // 💡 門番のセットアップ関数をインポート

// ... (型定義などはそのまま) ...

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState("未ログイン");
  const tokenRef = useRef<string>("");
  const expireRef = useRef<number>(0);

  // 💡 コンポーネントがマウントされた時に、1回だけ門番をセットアップする
  useEffect(() => {
    // 強制ログアウト用の関数を定義
    const handleLogout = () => {
      setUser("未ログイン");
      tokenRef.current = "";
      expireRef.current = 0;
    };

    // 門番に「裏の金庫(tokenRef)」と「ログアウト実行ボタン」を渡す!
    setupInterceptors(tokenRef, handleLogout);
  }, []);

  // ... (無操作タイマーなどの処理はそのまま) ...

  return (
    <AuthContext.Provider value={{ user, setUser, tokenRef, expireRef }}>
      {children}
    </AuthContext.Provider>
  );
}
  

3. 【結果】BusinessPage はどうなるか?

この門番を配置したことで、各画面でのAPI通信のコードが劇的にスッキリします。tokenRef を取り出す必要すら無くなります。


// BusinessPage1.tsx
import { useState } from 'react';
import { api } from './api'; // 💡 門番付きの専用Axiosをインポート

export default function BusinessPage1() {
  const [data, setData] = useState("");

  const fetchData = async () => {
    try {
      // 💡 注目!トークンの「ト」の字も書いていません
      const response = await api.get('/inventory'); 
      setData(response.data.message);
    } catch (error) {
      console.error("データ取得失敗", error);
    }
  };

  return (
    <div>
      <button onClick={fetchData}>在庫データを取得</button>
      <p>結果: {data}</p>
    </div>
  );
}
  

このアーキテクチャの凄さ


インターセプターの仕組みに関するQ&A

Q1. インターセプターはAxios独自の機能ですか?

はい、その通りです。標準の fetch APIには無い、Axiosライブラリ最大の目玉機能と言っても過言ではありません。
React開発の現場で標準の fetch ではなくわざわざ外部ライブラリのAxiosをインストールする最大の理由は、この「インターセプター(門番)機能が最初から用意されていて、認証の管理が圧倒的に楽になるから」です。

Q2. config とはなんですか?

config(コンフィグ:configurationの略)は、これからバックエンド(サーバー)に向けて飛び立とうとしている「APIリクエストのすべての情報が詰まった『荷物の送り状(伝票)』」です。

BusinessPage1 の画面で api.get('/inventory') と実行した瞬間、Axiosの裏側では以下のような config という名前のオブジェクト(伝票)が自動的に作成されます。


// 💡 configオブジェクトの実際の中身(イメージ)
{
  url: '/inventory',          // 宛先(どこに送るか)
  method: 'get',              // 送り方(GETかPOSTかなど)
  baseURL: 'http://localhost:8080/api', // 基本の住所(api.tsで設定したもの)
  timeout: 5000,              // タイムアウト時間
  headers: {                  // 同封するメモ(ヘッダー)
    'Accept': 'application/json'
  },
  data: undefined             // 送信するデータ(POSTの時などに使う)
}
  

門番(インターセプター)が config に対してやっていること

インターセプターの request.use((config) => { ... }) という部分は、「この作成された伝票(config)がブラウザから出発する直前に、一瞬だけ処理を一時停止して伝票を書き換えるチャンスをあげるよ」という機能です。


api.interceptors.request.use((config) => {
  if (tokenRef.current) {
    // 💡 伝票の「headers」という項目の中に、Authorization という新しい行を書き足している!
    config.headers.Authorization = `Bearer ${tokenRef.current}`;
  }
  
  // 書き換えが終わった伝票を「はい、行ってよし!」と送り出す
  return config; 
});
  

つまり、門番は飛び立とうとしているリクエスト(config)をガシッと掴み、その headers(ヘッダー)という情報の中に、裏の金庫から取り出したトークンをペタッとシールで貼り付けてから、再び空へ送り出しているのです。
この仕組みがあるおかげで、画面側で「トークンを手動でくっつける」という作業を一切しなくて済むようになります。


黄金のテンプレートとしての再利用

この api.ts のファイルと、AuthContext.tsx での呼び出し(セットアップ)の組み合わせは、React開発における「黄金のテンプレート(ボイラープレート)」として、ほとんどのプロジェクトでそのまま使い回すことができます。

実務で新しいプロジェクトを立ち上げる際も、基本的にはこのコードをコピペしてきて、プロジェクトの仕様に合わせて以下の部分だけを微調整するだけで完成します。

プロジェクトごとに変更する箇所(微調整のみ)
  • baseURL と timeout の値(接続先のサーバーURLや制限時間)
  • トークンの名前Authorization: Bearer ... の部分が、システムによっては X-API-KEY: ... などになる場合があります)
  • エラー時の処理(今回は401エラーで強制ログアウトにしましたが、システムによっては「トークンを裏で再発行(リフレッシュ)して、もう一度通信をやり直す」という処理をここに書き足すこともあります)

これだけで、どんな規模のアプリを作っても「通信時のトークン付与」と「認証エラー時の強制ログアウト」が全自動で守られる堅牢なシステムが立ち上がります。

再描画の最適化:useCallbackとReact.memo

Reactにおける「再描画の最適化」の最後のパズルピースである useCallback ですね。
ここを理解するには、「React.memo(子コンポーネントのメモ化)」という前提知識とセットで見るのが一番分かりやすいです。

C#などのオブジェクト指向言語に慣れている方なら、「関数のメモリアドレス(参照)が変わるかどうか」という視点で見ると一瞬で腑に落ちるはずです。具体的な比較コードを見てみましょう。


1. 動きの比較コード(親と子)

以下のコードは、親コンポーネント(Parent)の中に、2つの子コンポーネント(ChildButton)が配置されています。


import { useState, useCallback, memo } from 'react';

// 👶 子コンポーネント
// 💡 React.memo で囲むと、「親から渡されたProps(データや関数)が変化しない限り、再描画しない」というバリアが張られます。
const ChildButton = memo(({ name, onClick }: { name: string, onClick: () => void }) => {
  // 再描画された時だけコンソールに文字が出る仕掛け
  console.log(`[描画されました] ${name}`);
  
  return (
    <button onClick={onClick} style={{ padding: '10px', margin: '5px' }}>
      {name}
    </button>
  );
});

// 👨 親コンポーネント
export default function Parent() {
  // ① 親のState(これが変わると、Parent全体が再描画される)
  const [count, setCount] = useState(0);

  // ❌ 悪い例:ただの関数
  // Parentが再描画されるたびに、メモリ上に「新しい関数」として作り直されてしまう
  const handleBadClick = () => {
    console.log("悪いボタンが押されました");
  };

  // ⭕ 良い例:useCallbackで包んだ関数
  // 「最初([])に作った関数を、メモリ上にずっと保存して使い回してね」という指示
  const handleGoodClick = useCallback(() => {
    console.log("良いボタンが押されました");
  }, []); 

  return (
    <div style={{ border: '2px solid black', padding: '20px' }}>
      <h2>親コンポーネント</h2>
      <p>現在のカウント: <b>{count}</b></p>
      
      {/* 💡 ここを押すと count が増え、Parent全体が再描画される! */}
      <button onClick={() => setCount(count + 1)}>
        カウントアップ(親を再描画)
      </button>

      <hr />
      
      <div style={{ display: 'flex' }}>
        {/* ② 子コンポーネントにそれぞれ関数を渡す */}
        <ChildButton name="❌ useCallbackなし" onClick={handleBadClick} />
        <ChildButton name="⭕ useCallbackあり" onClick={handleGoodClick} />
      </div>
    </div>
  );
}
  

2. どう動くのか?(コンソールのログ)

「カウントアップ(親を再描画)」ボタンをポチポチと押したときの裏側の動き(コンソールログ)は以下のようになります。

【ボタンを押した結果】
[描画されました] ❌ useCallbackなし
[描画されました] ❌ useCallbackなし
[描画されました] ❌ useCallbackなし

「⭕ useCallbackあり」のほうは、一切ログに出ません(=再描画が完全に防がれています)。


3. なぜ「なし」の方は再描画されてしまったのか?

子コンポーネントである ChildButton は、memo というバリアを張っているため、「前回もらったPropsと、今回もらったPropsが完全に一致しているか?」を毎回チェックします。


まとめ

THB-JPY Currency Converter (タイバーツ・日本円 為替計算アプリ)

タイバーツ(THB)と日本円(JPY)のリアルタイムな為替レートを取得し、相互に金額を計算できるReactアプリケーションです。

✨ 主な機能

  • リアルタイム為替レート取得ExchangeRate-API を利用し、最新のレートで計算します。
  • 双方向の計算:「THB → JPY」「JPY → THB」の計算モードをボタンで簡単に切り替えられます。
  • 直感的なUI
    • 入力金額の3桁カンマ区切り表示(フォーカス時は数値のみ)
    • 計算結果の自動フォーマット(小数点以下2桁、カンマ区切り、通貨単位の自動付与)
  • ワンタッチ入力:よく使う金額(5,000, 10,000など)をボタン一つで入力可能。

🛠 使用技術

  • Frontend:React, TypeScript, Vite
  • HTTP Client:Axios
  • API:ExchangeRate-API(Open Endpoint)
  • Hosting:Cloudflare Pages

🚀 ローカル環境での動かし方

このプロジェクトをご自身のPCで動かすための手順です。Node.jsがインストールされていることを前提としています。

1. プロジェクトのクローン

git clone https://github.com/kakku-momonga/thb-jpy.git
cd YOUR_REPOSITORY

2. パッケージのインストール

npm install

3. 開発サーバーの起動

npm run dev

起動後、ブラウザで http://localhost:5173(ポート番号は環境により異なる場合があります)にアクセスしてください。

📦 ビルドとデプロイ

本番環境(Cloudflare Pagesなど)用に最適化されたファイルを生成するには、以下のコマンドを実行します。

npm run build

dist フォルダが生成されます。Cloudflare PagesとGitHubを連携させている場合は、 メインブランチにプッシュするだけで自動的にデプロイが行われます。

THB入力 JPY入力 レート : 5,000 10,000 20,000 30,000 計算する クリア 0

Vanilla JavaScript版


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>THB/JPY変換</title>
    <style>
        html {
            background-color: #f3efe6; 
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Hiragino Kaku Gothic ProN", "Hiragino Sans", "Yu Gothic UI", Meiryo, sans-serif;
            -webkit-font-smoothing: antialiased;
            -moz-osx-font-smoothing: grayscale;
            
            background-color: #faf7f0; 
            max-width: 400px; 
            margin: 50px auto; 
            padding: 30px 24px; 
            box-sizing: border-box;
            border-radius: 16px; 
            box-shadow: 0 8px 20px rgba(0,0,0,0.05); 
            border: 4px solid #a89f8e; 
        }

        @media (max-width: 480px) {
            html {
                background-color: #faf7f0; 
            }
            body {
                margin: 0; 
                max-width: 100%; 
                min-height: 100vh; 
                border-radius: 0; 
                box-shadow: none; 
                padding: 20px 10px; 
                border: none; 
            }
        }

        .thb-jpy {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }

        .thb-jpy button {
            appearance: none;
            border: none;
            outline: none;
            font-family: inherit;

            flex: 1;
            padding: 12px 0;
            text-align: center;
            background-color: #7b90a6; 
            color: #ffffff; 
            font-weight: bold;
            cursor: pointer;
            transition: all 0.3s ease;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            border-radius: 30px 6px 30px 6px; 
        }

        .thb-jpy button.active {
            background-color: #3b587a;
            box-shadow: 0 4px 8px rgba(59, 88, 122, 0.3);
        }

        .rate-div {
            margin-top: 30px; 
            font-size: 14px;  
            color: #5c6b7a;
        }

        .amount-div {
            margin-top: 15px; 
        }

        .amount-input {
            width: 100%; 
            box-sizing: border-box; 
            font-size: 28px; 
            padding: 12px 16px; 
            text-align: right; 
            background-color: transparent;
            border: 2px solid #a0b0c0;
            border-radius: 8px; 
            outline: none; 
            transition: border-color 0.3s ease; 
            color: #334155; 
        }

        .amount-input:focus {
            border-color: #3b587a; 
        }

        .simple-input-div {
            margin-top: 15px; 
            display: flex;    
            gap: 8px;         
        }

        .simple-input-div button {
            appearance: none;
            border: none;
            outline: none;
            font-family: inherit;

            flex: 1; 
            padding: 14px 0; 
            font-size: 14px; 
            font-weight: bold;
            color: #ffffff; 
            border-radius: 6px; 
            cursor: pointer;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }

        .simple-input-div button:active {
            opacity: 0.8;
        }

        .five-btn    { background-color: #638ecb; } 
        .ten-btn     { background-color: #5177bb; } 
        .twenty-btn  { background-color: #3f55a6; } 
        .thirty-btn  { background-color: #2b308b; } 

        .calc-btn {
            margin-top: 30px;
            width: 100%;
            box-sizing: border-box;
            padding: 16px 0;
            background-color: #c98063; 
            color: #ffffff;
            font-size: 18px;
            font-weight: bold;
            letter-spacing: 0.15em;
            text-align: center;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.2s ease;
            box-shadow: 0 4px 6px rgba(201, 128, 99, 0.3); 
        }

        .calc-btn:active {
            transform: translateY(2px);
            box-shadow: 0 2px 4px rgba(201, 128, 99, 0.3);
            opacity: 0.9;
        }

        .result-area {
            margin-top: 30px;
            padding: 24px 20px; 
            background-color: #e8e4db;
            border-radius: 12px;
            display: flex; 
            align-items: center; 
            justify-content: space-between; 
            box-shadow: 0 2px 4px rgba(0,0,0,0.05); 
        }

        .reset-btn {
            padding: 8px 16px;
            background-color: #8f9496;
            color: #ffffff;
            font-size: 14px;
            font-weight: bold;
            border-radius: 20px; 
            cursor: pointer;
            transition: background-color 0.2s ease;
        }

        .reset-btn:active {
            background-color: #7a7f81; 
        }

        .result-value {
            font-size: 28px; 
            font-weight: bold;
            color: #2c3e50;
        }
    </style>
</head>
<body>
    <div class="thb-jpy">
        <button class="active">THB入力</button>
        <button>JPY入力</button>
    </div>
    <div class="rate-div">
        <span class="rate-title">レート:</span>
        <span class="rate-value"></span>
    </div>
    <div class="amount-div">
        <input type="text" class="amount-input" value="">
    </div>
    <div class="simple-input-div">
        <button class="five-btn">5,000</button>
        <button class="ten-btn">10,000</button>
        <button class="twenty-btn">20,000</button>
        <button class="thirty-btn">30,000</button>
    </div>
    <div class="calc-btn">計算する</div>
    <div class="result-area">
        <div class="reset-btn">クリア</div>
        <div class="result-value">0</div>
    </div>

    <script>
        const currencyButtons = document.querySelectorAll('.thb-jpy button');

        currencyButtons.forEach(button => {
            button.addEventListener('click', function() {
                currencyButtons.forEach(btn => btn.classList.remove('active'));
                this.classList.add('active');
            });
        });

        const amountInput = document.querySelector('.amount-input');
        
        amountInput.addEventListener('input', function() {
            let value = this.value.replace(/[0-9]/g, function(s) {
                return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
            });
            
            let rawValue = value.replace(/[^0-9]/g, '');
            
            if (rawValue) {
                this.value = rawValue.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
            } else {
                this.value = '';
            }
        });

        const simpleButtons = document.querySelectorAll('.simple-input-div button');
        
        simpleButtons.forEach(button => {
            button.addEventListener('click', function() {
                let rawNumber = this.innerText.replace(/,/g, '');
                amountInput.value = rawNumber;
                amountInput.dispatchEvent(new Event('input'));
            });
        });

        const calcBtn = document.querySelector('.calc-btn');
        const resetBtn = document.querySelector('.reset-btn');
        const resultValue = document.querySelector('.result-value');

        resetBtn.addEventListener('click', function() {
            resultValue.innerText = '0';
            amountInput.value = ''; 
        });

        calcBtn.addEventListener('click', function() {
            let rawNumberStr = amountInput.value.replace(/,/g, '');
            
            if (!rawNumberStr) {
                rawNumberStr = '0';
            }

            let formattedResult = rawNumberStr.replace(/\B(?=(\d{3})+(?!\d))/g, ',');

            let unit = '';
            if (currencyButtons[0].classList.contains('active')) {
                unit = ' 円';
            } else {
                unit = ' バーツ';
            }

            resultValue.innerText = formattedResult + unit;
        });
    </script>
</body>
</html>
  

React版


Index.html

<!doctype html>
<html lang="ja"> <!-- 日本語 -->
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>THB/JPY為替</title> <!-- ブラウザのタブに表示されるタイトル --> 
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  

index.css

/* src/index.css
 * ------------------------------------------------------------
 * 役割:
 * - Viteテンプレの初期CSSをリセットし、画面が変な位置に寄るのを防ぐ
 */

:root {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
    Arial, "Noto Sans JP", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color: #111827;
  background-color: #ffffff;
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: #f8fafc;
}

#root {
  min-height: 100%;
}
  

App.tsx

import { useEffect, useState } from 'react'
import axios from 'axios'; // axiosをインポート

import './App.css'

function App() {
  const [amount, setAmount] = useState<number | "">("")
  const [rate, setRate] = useState<number | "">("")
  const [result, setResult] = useState<number | "">("")
  const [activeCurrency, setActiveCurrency] = useState<"THB" | "JPY">("THB")
  const [isAmountFocused, setIsAmountFocused] = useState(false);

  useEffect(() => {
    fetchRate("THB");
  }, []);
  
  // APIからレートを取得する関数
  const fetchRate = async (baseCurrency: "THB" | "JPY") => {
    try {
      // 登録不要の無料APIエンドポイント
      const response = await axios.get(`https://open.er-api.com/v6/latest/${baseCurrency}`);
      
      if (baseCurrency === "THB") {
        // THB基準の場合、JPYのレートを保存
        setRate(response.data.rates.JPY); 
      } else {
        // JPY基準の場合、THBのレートを保存
        setRate(response.data.rates.THB); 
      }
      setResult(""); // レートが変わったら結果をリセット
    } catch (error) {
      console.error("為替レートの取得に失敗しました", error);
    }
  };

  return (
    <>
      {
      //「常に付いている基本のクラス(例:base-btn)」と「条件によって追加されるクラス(例:active)」を組み合わせたい場合は、以下のように書くことができます。
      // 例: className={`base-btn ${activeCurrency === "THB" ? "active class2-name" : ""}`}
      } 
      <div className="thb-jpy">
          <button className={activeCurrency === "THB" ? "active" : ""} 
          onClick={() => {setActiveCurrency("THB"); fetchRate("THB");}}>THB入力</button>
          <button className={activeCurrency === "JPY" ? "active" : ""} 
          onClick={() => {setActiveCurrency("JPY"); fetchRate("JPY");}} >JPY入力</button>
      </div>
      <div className="rate-div">
          <span className="rate-title">レート:</span>
          <span className="rate-value">{rate}</span>
      </div>
      <div className="amount-div">
          <input type="text" className="amount-input" 
          value={isAmountFocused ? amount : amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')} 
          onChange={(e) => {
            // 入力された値から数字以外(カンマなど)を削除してから保存する
            const rawValue = e.target.value.replace(/[^0-9]/g, '');
            setAmount(rawValue === "" ? "" : Number(rawValue));
            }}
          onFocus={() => setIsAmountFocused(true)}
          onBlur={() => setIsAmountFocused(false)}
          ></input>
      </div>
      <div className="simple-input-div">
          <button className="five-btn" onClick={() => {setAmount(5000); setResult("");}}>5,000</button>
          <button className="ten-btn" onClick={() => {setAmount(10000); setResult("");}}>10,000</button>
          <button className="twenty-btn" onClick={() => {setAmount(20000); setResult("");}}>20,000</button>
          <button className="thirty-btn" onClick={() => {setAmount(30000); setResult("");}}>30,000</button>
      </div>
      <div className="calc-btn" 
      onClick={()=> {
        const numRate = Number(rate) || 0;
        // ▼変更点:amountからカンマ(,)を全て空文字('')に置換して削除してから数値にする
        const rawAmountStr = String(amount).replace(/,/g, '');
        const numAmount = Number(rawAmountStr) || 0;  
        // 小数点以下を切り捨てたい場合は Math.floor() 等を使います
        // setResult(Math.floor(numRate * numAmount));
        setResult(numRate * numAmount);
      }}>計算する</div>
      <div className="result-area">
          <div className="reset-btn" 
          onClick={()=>{setAmount(""); setRate(""); setResult("");}}>クリア</div>
          <div className="result-value">
            {result === "" || result === 0
                  ? "0" 
                  : (() => {
                      // 1. まず小数点以下2桁で四捨五入した文字列にする
                      const fixedResult = Number(result).toFixed(2);
                      
                      // 2. 整数部分と小数部分を分ける
                      const parts = fixedResult.split('.');
                      
                      // 3. 整数部分にのみカンマを付ける
                      parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
                      
                      // 4. 整数部分と小数部分を結合して単位を付ける
                      return parts.join('.') + (activeCurrency === 'THB' ? ' 円' : ' バーツ');
                    })()}
          </div>
      </div>
    </>
  )
}

export default App

  

App.css


html {
    /* 全体の背景を目に優しい暖かみのあるベージュに変更 */
    background-color: #f3efe6; 
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Hiragino Kaku Gothic ProN", "Hiragino Sans", "Yu Gothic UI", Meiryo, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  
  background-color: #faf7f0; 
  max-width: 400px; 
  margin: 50px auto; 
  padding: 30px 24px; 
  box-sizing: border-box;
  border-radius: 16px; 
  box-shadow: 0 8px 20px rgba(0,0,0,0.05); 
  /* ▼変更:枠線を太く(4px)、色を少し濃くしてはっきりさせました▼ */
  border: 4px solid #a89f8e; 
  
  /* ▼追加:開発環境のデフォルトCSSによる不要な縦伸びを強制的に防ぐ▼ */
  height: fit-content !important;
  min-height: 0 !important;
}

@media (max-width: 480px) {
  html {
      background-color: #faf7f0; 
  }
  body {
      margin: 0; 
      max-width: 100%; 
      /* ▼追加:ベース側の !important に上書きされないようにこちらにも追加▼ */
      min-height: 100vh !important; 
      border-radius: 0; 
      box-shadow: none; 
      padding: 20px 10px; 
      border: none; 
  }
}

.thb-jpy {
    display: flex;
    gap: 10px;
    margin-bottom: 20px;
}

.thb-jpy button {
    appearance: none;
    border: none;
    outline: none;
    font-family: inherit;

    flex: 1;
    padding: 12px 0;
    text-align: center;
    background-color: #7b90a6; 
    color: #ffffff; 
    font-weight: bold;
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    /* ▼変更:眉毛にならないよう、両方とも同じ向きの葉っぱ型に統一▼ */
    border-radius: 30px 6px 30px 6px; 
}
/* アクティブ状態:深みのあるネイビーブルー */
.thb-jpy button.active {
    background-color: #3b587a;
    box-shadow: 0 4px 8px rgba(59, 88, 122, 0.3);
}

.rate-div {
    margin-top: 30px; 
    font-size: 14px;  
    color: #5c6b7a; /* 文字色も真っ黒ではなく落ち着いた色に */
}

.amount-div {
    margin-top: 15px; 
}

.amount-input {
    width: 100%; 
    box-sizing: border-box; 
    font-size: 28px; 
    padding: 12px 16px; 
    text-align: right; 
    background-color: transparent; /* 背景に馴染ませる */
    border: 2px solid #a0b0c0; /* 枠線もブルーグレー系で統一 */
    border-radius: 8px; 
    outline: none; 
    transition: border-color 0.3s ease; 
    color: #334155; 
}

.amount-input:focus {
    border-color: #3b587a; 
}

.simple-input-div {
    margin-top: 15px; 
    display: flex;    
    gap: 8px;         
}

.simple-input-div button {
    appearance: none;
    border: none;
    outline: none;
    font-family: inherit;

    flex: 1; 
    padding: 14px 0; 
    font-size: 14px; 
    font-weight: bold;
    color: #ffffff; 
    border-radius: 6px; 
    cursor: pointer;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.simple-input-div button:active {
    opacity: 0.8;
}

/* 画像に合わせたブルー系のグラデーション */
.five-btn    { background-color: #638ecb; } 
.ten-btn     { background-color: #5177bb; } 
.twenty-btn  { background-color: #3f55a6; } 
.thirty-btn  { background-color: #2b308b; } 

/* ▼変更:「計算する」の文字色を白にし、字間を空けてスッキリさせました▼ */
.calc-btn {
    margin-top: 30px;
    width: 100%;
    box-sizing: border-box;
    padding: 16px 0;
    background-color: #c98063; 
    color: #ffffff; /* 白に変更して視認性をアップ */
    font-size: 18px;
    font-weight: bold;
    letter-spacing: 0.15em; /* 字間を少し空けて野暮ったさを解消 */
    text-align: center;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.2s ease;
    box-shadow: 0 4px 6px rgba(201, 128, 99, 0.3); 
}
.calc-btn:active {
    transform: translateY(2px);
    box-shadow: 0 2px 4px rgba(201, 128, 99, 0.3);
    opacity: 0.9;
}

.result-area {
    margin-top: 30px;
    padding: 24px 20px; 
    background-color: #e8e4db; /* 少しトーンを落としたグレージュ */
    border-radius: 12px;
    display: flex; 
    align-items: center; 
    justify-content: space-between; 
    box-shadow: 0 2px 4px rgba(0,0,0,0.05); 
}

.reset-btn {
    padding: 8px 16px;
    background-color: #8f9496; /* 落ち着いたグレー */
    color: #ffffff; /* 白文字に変更 */
    font-size: 14px;
    font-weight: bold;
    border-radius: 20px; 
    cursor: pointer;
    transition: background-color 0.2s ease;
}

.reset-btn:active {
    background-color: #7a7f81; 
}

.result-value {
    font-size: 28px; 
    font-weight: bold;
    color: #2c3e50; /* 濃いスレート色で目に優しく */
}

  

ローカルメモアプリ 概要

「ローカルメモ」は、ブラウザ上で安全かつ快適にテキストデータを管理できるローカルファーストなメモアプリケーションです。
外部のデータベースに依存せず、データの暗号化やエクスポート機能を備えることで、プライバシーを重視したセキュアな設計となっています。

主な機能と特徴

インポート エクスポート ログアウト メモ一覧 + 追加 DDLを渡して、フレ... × posqtgresq... × mysql ddl × -- SQLite3... × -- SQLite3 Sample DDL (covers: AUTOINCREMENT, INTEGER/REAL/NUMERIC/TEXT/BLOB, -- DATE/DATETIME (as TEXT with CHECK), BOOLEAN-ish, JSON-ish, DEFAULT, UNIQUE, -- composite UNIQUE, foreign key, CHECK constraints, indexes) PRAGMA foreign_keys = ON; -- 1) Master table: users CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- AUTOINCREMENT email TEXT NOT NULL UNIQUE, -- TEXT + UNIQUE name TEXT NOT NULL, -- TEXT age INTEGER, -- INTEGER height_cm REAL, -- REAL is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0,1)), -- BOOLEAN-ish balance NUMERIC NOT NULL DEFAULT 0, -- NUMERIC (affinity) created_date TEXT NOT NULL DEFAULT (DATE('now')) ...

      npm install react-router
    

htmlをテストするためのpythonサーバー


      python -m http.server 3000 --bind 0.0.0.0
    

TSサーバーを再起動する(最も簡単です)

VS Code上で F1 キー(または Ctrl + Shift + P)を押してコマンドパレットを開きます。 TypeScript: Restart TS server日本語環境なら TypeScript: TS サーバーを再起動する)と入力して選択・実行します。 これでキャッシュがクリアされ、エラーが消えるはずです。


index.css


/* src/index.css
 * ------------------------------------------------------------
 * 役割:
 * - Viteテンプレの初期CSSをリセットし、画面が変な位置に寄るのを防ぐ
 */

:root {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
    Arial, "Noto Sans JP", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color: #111827;
  background-color: #ffffff;
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: #f8fafc;
}

#root {
  min-height: 100%;
} 

index.html


<!doctype html>
<html lang="ja"> <!-- 日本語 -->
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ローカルメモ</title> <!-- ブラウザのタブに表示されるタイトル --> 
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  </code></pre>
  <hr>
  <h4>App.tsx</h4>
  <pre><code class="language-typescript">
  </code></pre>  
</section>
</main>

</body>
</html>

AppContext.tsx


import React, { createContext, useContext, useState } from "react";

/**
 * Contextで共有する値の型
 * -----------------------------------------
 * hashedValue:ログイン後に保持したい値
 * setHashedValue:その値を更新する関数
 */
export type AppContextValue = {
  hashedValue: string;
  setHashedValue: (v: string) => void;
};

/**
 * Context本体を作成
 * -----------------------------------------
 * 初期値は null(Providerの外で使われたらエラーにするため)
 */
const AppContext = createContext<AppContextValue | null>(null);

/**
 * Contextを簡単に使うためのカスタムHook
 * -----------------------------------------
 * useContextを直接使わず、これを使う
 */
export function useAppContext(): AppContextValue {
  const ctx = useContext(AppContext); // Contextから値を取得

  // Providerで囲まれていない場合はエラーにする
  if (!ctx) throw new Error("useAppContext must be used within AppProvider");

  return ctx; // { hashedValue, setHashedValue } が返る
}

/**
 * Provider(値を配る側)
 * -----------------------------------------
 * アプリ全体をこれで囲むと、子コンポーネントから値を使える
 */
export function AppProvider({ children }: { children: React.ReactNode }) {

  // 共有したい状態(ここが一番重要)
  // 初期値は空文字
  const [hashedValue, setHashedValue] = useState("");

  return (
    // Contextに値を渡す
    // 子コンポーネントは useAppContext() で取得できる
    <AppContext.Provider value={{ hashedValue, setHashedValue }}>
      {children}
    </AppContext.Provider>
  );
}

RequireAuth.tsx


// RequireAuth.tsx
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { useAppContext } from "./AppContext";

export function RequireAuth({ children }: { children: React.ReactNode }) {
  const { hashedValue } = useAppContext();
  const navigate = useNavigate();

  useEffect(() => {
    // 未ログイン(hashedValueが空)ならログインへ戻す
    if (!hashedValue) {
      navigate("/", { replace: true });
    }
  }, [hashedValue, navigate]);

  // リダイレクト中は何も描画しない(チラ見え防止)
  if (!hashedValue) return null;

  return <>{children}</>;
}

router.tsx


// router.tsx
import { createBrowserRouter } from "react-router";
import App from "./App";
import Memo from "./Memo";
import { RequireAuth } from "./RequireAuth";

export const router = createBrowserRouter([
  {
    path: '/',
    element: <App />, // 初期アクセス(ログイン画面)
  },
  {
    path: '/memo',
    element: (
      <RequireAuth>
        <Memo />
      </RequireAuth>
    ),
  },
]);

main.tsx


import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router";
import { router } from "./router";
import { AppProvider } from "./AppContext";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <AppProvider>
      <RouterProvider router={router} />
    </AppProvider>
  </React.StrictMode>
);

App.tsx


/* App.tsx */

/* Reactのstate管理フック */
import { useState } from 'react'

/* CSS読み込み(見た目用) */
import './App.css'

/* Contextから値を操作するためのHook */
import { useAppContext } from "./AppContext";

/* 画面遷移用(react-router) */
import { useNavigate } from 'react-router';


/**
 * パスワードをSHA-256でハッシュ化する関数
 * -----------------------------------------
 * 入力された文字列を安全なハッシュ値に変換する
 */
async function hashWord(password:string): Promise<string>  {
    const encoder = new TextEncoder();          // 文字列 → バイト列に変換
    const data = encoder.encode(password);

    // SHA-256でハッシュ化
    const hashBuffer = await crypto.subtle.digest('SHA-256', data);

    // Uint8Arrayに変換
    const hashArray = Array.from(new Uint8Array(hashBuffer));

    // 16進数文字列に変換(最終的なハッシュ)
    const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');

    return hashHex;
}


function App() {

  // 画面遷移用関数(/memo などに移動できる)
  const navigate = useNavigate();

  // 入力値(ユーザーID)
  const [user, setUser] = useState("")

  // 入力値(パスワード)
  const [password, setPassword] = useState("")

  // エラーメッセージ表示用
  const [message, setMessage] = useState("")

  // Contextから「値をセットする関数」を取得
  // これで他画面に値を渡せる
  const { setHashedValue } = useAppContext();

  // 暗号化(難読化)された値を定数として持つ
  const ENCRYPTED_USER_ID = "OGxobj9sPG1pbzk+ajtjP2I/aG1iaWk+OWw/Yjlub2JsaGpiYm5jb2xiY2s7bD4/bGtrbm1qO2k7ajk8aGlrbg==";
  const ENCRYPTED_PASSWORD_HASH = "PGJiaj9raGM8OGppODs+OztvO25vOW04b2toampubW84aDhiOW8+b2o8bWhsPGI5a2o/bmg4Ym9oYjhuPmJpOQ==";
  // ハッシュ値の難読化(F12のカジュアルな解析対策)
  // ===============================
  const decodeHash = (encoded: string): string => {
    try {
      return Array.from(atob(encoded))
        .map((c) => String.fromCharCode(c.charCodeAt(0) ^ 0x5a))
        .join("");
    } catch {
      return "";
    }
  };
     // 起動時にdecodeしてハッシュ値を決定
  const EXPECTED_USER_ID = decodeHash(ENCRYPTED_USER_ID);
  const EXPECTED_PASSWORD_HASH = decodeHash(ENCRYPTED_PASSWORD_HASH);  // ===============================

  /**
   * ログイン処理
   * -----------------------------------------
   * 入力されたID・パスワードをハッシュ化して照合する
   */
  const handleLogin = async () => {

    // 入力値をハッシュ化
    const userHash = await hashWord(user);
    const passwordHash = await hashWord(password);
    
    // 正しい値と一致するかチェック
    if (userHash === EXPECTED_USER_ID && passwordHash === EXPECTED_PASSWORD_HASH) {

      // ✔ Contextに値を保存(これが重要)
      // → 別画面(Memoなど)でも使えるようになる
      setHashedValue(userHash); 

      // ✔ メモ画面へ遷移
      navigate("/memo");

    } else {
      // エラーメッセージ表示
      setMessage("ユーザーIDまたはパスワードが間違っています。");
    }
  };


  return (
    <>
    <form onSubmit={(e) => { e.preventDefault(); handleLogin(); }}>
    <div className="login-wrapper">
      <div className="border">

          {/* タイトル */}
          <div className="login-label">
              <p className="login-label-string">ログイン</p>
          </div>

          <div className="login-form">
              <table className="login-table">
                <tbody>

                  {/* ユーザーID入力 */}
                  <tr>
                      <td>ユーザーID</td>
                      <td>
                        <input
                          type="text" 
                          id="userid"
                          value={user} // stateと連動
                          onChange={(e) => setUser(e.target.value)} // 入力更新
                        />
                      </td>
                  </tr>

                  {/* パスワード入力 */}
                  <tr>
                      <td>パスワード</td>
                      <td>
                        <input
                         type="password" 
                         id="password"
                         value={password} // stateと連動
                         onChange={(e) => setPassword(e.target.value)} // 入力更新
                        />
                      </td>
                  </tr>

                </tbody>
              </table>

              {/* ログインボタン */}
              <button 
                className="login-button"
                id="login-btn"
                type='submit'
              >
                ログイン
              </button>

              {/* エラーメッセージ表示 */}
              <div id="message-box">{message}</div>

          </div>
      </div>
    </div>
    </form>
    </>
  )
}

export default App

App.css


/* 全体の設定(目に優しい深いグリーン) */
body {
  background-color: #2b3a32;
  margin: 0;
}
.login-wrapper {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  width: 100%;
}


/* フォームの外枠 */
.border {
    background-color: #3a4f44; /* 背景より少し明るいグリーン */
    border: 3px solid #8ebd9e; /* 柔らかいトーンの明るい枠線 */
    border-radius: 8px;
    width: 100%;
    max-width: 450px;
    padding: 40px 30px;
    box-sizing: border-box;
    margin: auto !important; /* 追加: 親要素の設定に関わらず、強制的に上下左右の中央へ引き寄せる */
}

/* タイトル部分 */
.login-label {
    text-align: center;
    margin-bottom: 30px;
}

.login-label-string {
    font-size: 28px;
    font-weight: bold;
    color: #f0f4f0;
    margin: 0;
}

/* フォーム全体 */
.login-form {
    display: flex;
    flex-direction: column;
}

/* テーブルのレイアウト */
.login-table {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: 30px;
}

.login-table td {
    padding: 15px 0;
}

/* ラベル(ユーザーID, パスワード) */
.login-table td:first-child {
    width: 35%;
    font-size: 18px;
    font-weight: bold;
    color: #e0e8e0; /* 少し控えめな白 */
    vertical-align: middle;
}

/* 入力欄のセル */
.login-table td:last-child {
    width: 65%;
}

/* 入力フィールド */
/* 眩しさを抑えつつ「入力場所」だとわかるクリーム色を採用 */
.login-table input[type="text"],
.login-table input[type="password"] {
    width: 100%;
    padding: 15px;
    border: 2px solid #2b3a32;
    border-radius: 6px;
    box-sizing: border-box;
    font-size: 18px;
    background-color: #e6eedf; /* 眩しさを抑えたクリームグリーン */
    color: #1a241e; /* 入力文字は濃い色で読みやすく */
}

/* 入力フィールドにフォーカスした時 */
.login-table input[type="text"]:focus,
.login-table input[type="password"]:focus {
    outline: none;
    border-color: #d97b29; /* フォーカス時はオレンジで明確に */
    background-color: #ffffff;
    box-shadow: 0 0 0 3px rgba(217, 123, 41, 0.3);
}

/* ログインボタン */
.login-button {
    background-color: #d97b29; /* 視認性の高い落ち着いたオレンジ */
    color: #ffffff;
    border: 2px solid #b5621b;
    padding: 16px;
    border-radius: 6px;
    font-size: 20px;
    font-weight: bold;
    cursor: pointer;
    width: 100%;
}

/* ボタンにホバーした時 */
.login-button:hover {
    background-color: #b5621b;
}

/* スマートフォン向けレスポンシブ対応 */
@media (max-width: 480px) {
    .border {
        padding: 30px 20px;
        border-width: 2px;
        /* margin: 20px; を削除(外側の余白はbodyのpaddingで安全に処理するため) */
    }
    .login-label-string {
        font-size: 24px; /* タイトルを少し小さく */
    }
    /* テーブルのセルを縦並びに変更 */
    .login-table td {
        display: block;
        width: 100% !important;
        padding: 5px 0;
    }
    .login-table td:first-child {
        padding-top: 10px;
        padding-bottom: 2px;
    }
    .login-table td:last-child {
        padding-bottom: 15px;
    }
}

/* 追加: メッセージ表示用のスタイル */
#message-box {
    margin-top: 15px;
    text-align: center;
    font-weight: bold;
    min-height: 24px;
    color: red; /* エラーメッセージは赤で表示 */
}
.error-msg { color: #ff8e8e; }
.success-msg { color: #8ebd9e; }

Memo.css


  /* memo.css - メモアプリのスタイルシート */
/* ------------------------------------------------------------*/
body {
    background-color: #2b3a32;
    font-family: 'BIZ UDPGothic', 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', Meiryo, sans-serif;
    margin: 0;
    padding: 0;
    color: #f0f4f0;
    /* 修正: React環境ではここに高さを指定すると干渉するため削除し、下のwrapperに移します */
}

/* 追加: メモ画面専用の全画面ラッパー */
.memo-wrapper {
    height: 100vh;
    height: 100dvh; /* スマホのブラウザUIを考慮した正確な高さ */
    width: 100%;
    display: flex;
    flex-direction: column;
}
/* ヘッダー部分 */
.header {
    background-color: #1a241e; /* 少し暗いグリーンで引き締め */
    padding: 15px 20px;
    border-bottom: 3px solid #8ebd9e;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

/* インポート・エクスポートボタン群 */
.header-buttons {
    display: flex;
    gap: 15px;
}

.header-btn {
    background-color: #3a4f44;
    color: #f0f4f0;
    border: 2px solid #8ebd9e;
    padding: 8px 16px;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
    font-size: 16px;
    transition: background-color 0.2s;
}

.header-btn:hover {
    background-color: #4a6356;
}

/* メインの2ペインコンテナ */
.container {
    display: flex;
    flex-grow: 1;
    overflow: hidden; /* コンテナからはみ出さないように */
}

/* 左ペイン(メモ一覧など) */
.left-pane {
    width: 300px; /* 初期幅 */
    min-width: 200px; /* 最小幅 */
    max-width: 50vw; /* 最大幅(画面の半分まで) */
    background-color: #3a4f44;
    border-right: 4px solid #8ebd9e; /* はっきりとした境界線 */
    
    /* ▼ ここがドラッグでサイズ変更するためのCSSです ▼ */
    resize: horizontal; /* 横方向のドラッグリサイズを許可 */
    overflow: auto; /* resizeを使うために必須 */
    /* ▲ ▲ ▲ */
    
    padding: 20px;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
}

/* 右ペイン(メモ入力エリア) */
.right-pane {
    flex-grow: 1; /* 残りの幅をすべて埋める */
    background-color: #2b3a32;
    padding: 20px;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
}

/* --- 左ペインの中身(リストのデザイン) --- */
.left-pane-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 2px solid #8ebd9e;
    padding-bottom: 10px;
    margin-bottom: 20px;
}

.pane-title {
    margin: 0;
    font-size: 20px;
}

.add-btn {
    background-color: #d97b29;
    color: #ffffff;
    border: none;
    padding: 6px 12px;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
    font-size: 14px;
    transition: background-color 0.2s;
}

.add-btn:hover {
    background-color: #b5621b;
}

.memo-list {
    list-style: none;
    padding: 0;
    margin: 0;
}

.memo-list li {
    padding: 15px;
    margin-bottom: 12px;
    background-color: #2b3a32;
    border: 2px solid #8ebd9e;
    border-radius: 6px;
    cursor: pointer;
    font-size: 18px;
    font-weight: bold;
    color: #e0e8e0;
    display: flex; /* 削除ボタンと並べるために追加 */
    justify-content: space-between; /* 左右に配置 */
    align-items: center;
}

/* リストにホバーした時 */
.memo-list li:hover {
    background-color: #4a6356;
}

/* 選択中のリスト項目 */
.memo-list li.active {
    border-color: #d97b29; /* 視認性の高いオレンジを使用 */
    background-color: #1a241e;
}

/* メモのタイトル部分の省略表示用 */
.memo-title-span {
    flex-grow: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    margin-right: 10px;
}

/* 各リストの削除ボタン */
.delete-btn {
    background-color: transparent;
    color: #8ebd9e;
    border: none;
    cursor: pointer;
    font-size: 16px;
    padding: 6px 10px;
    border-radius: 4px;
    transition: all 0.2s;
    line-height: 1;
}

.delete-btn:hover {
    color: #ffffff;
    background-color: #e53e3e; /* ホバー時は警告色 */
}

/* --- 右ペインの中身(エディタのデザイン) --- */
.memo-editor {
    flex-grow: 1;
    width: 100%;
    padding: 20px;
    border: 3px solid #8ebd9e;
    border-radius: 8px;
    background-color: #e6eedf; /* 眩しさを抑えたクリームグリーン */
    color: #1a241e; /* 濃い文字色で読みやすく */
    font-size: 18px;
    line-height: 1.6;
    font-family: inherit;
    box-sizing: border-box;
    resize: none; /* テキストエリア自体の右下リサイズは無効化(ペインで操作するため) */
}

/* エディタにフォーカスした時 */
.memo-editor:focus {
    outline: none;
    border-color: #d97b29; /* フォーカス時はオレンジで明確に */
    background-color: #ffffff;
    box-shadow: 0 0 0 3px rgba(217, 123, 41, 0.3);
}

/* スマートフォン向けレスポンシブ対応 */
@media (max-width: 768px) {
    .container {
        flex-direction: column; /* スマホでは縦並びに変更 */
    }
    .left-pane {
        width: 100%; /* 横幅は最大に */
        max-width: none;
        height: 35%; /* 初期は画面上部35%を割り当て */
        min-height: 150px;
        
        /* スマホでは縦方向にドラッグして高さを変えられるように変更 */
        resize: vertical; 
        
        border-right: none;
        border-bottom: 4px solid #8ebd9e; /* 境界線を下へ移動 */
    }
    .right-pane {
        height: 65%; /* 残りの高さを割り当て */
    }
}

/* --- カスタムモーダル共通設定 --- */
.modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    background-color: rgba(0, 0, 0, 0.6);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 1000;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s;
}
.modal-overlay.active {
    opacity: 1;
    pointer-events: auto;
}
.modal-content {
    background-color: #2b3a32;
    border: 3px solid #8ebd9e;
    padding: 30px;
    border-radius: 8px;
    text-align: center;
    max-width: 400px;
    width: 90%;
    color: #f0f4f0;
}
.modal-actions {
    display: flex;
    justify-content: center;
    gap: 15px;
    margin-top: 25px;
}
.modal-btn {
    padding: 10px 20px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
    font-size: 16px;
}
.btn-cancel {
    background-color: #3a4f44;
    color: #f0f4f0;
}
.btn-cancel:hover { background-color: #4a6356; }
.btn-danger {
    background-color: #e53e3e;
    color: #ffffff;
}
.btn-danger:hover { background-color: #c53030; }

Memo.tsx


import { useEffect, useMemo, useRef, useState } from "react";
import "./Memo.css";
import { useNavigate } from "react-router";
import { useAppContext } from "./AppContext";

// ===============================
// 型定義・定数定義
// ===============================

// メモ1件のデータを表す型定義
type MemoItem = {
  id: number;           // メモの一意なID(作成時のタイムスタンプを使用)
  text: string;         // メモの本文
  timestamp: number;    // 最終更新日時
  hash?: string;        // メモ内容のハッシュ値(インポート時の重複判定用)
};

// LocalStorageでデータを保存・取得するためのキー名
const STORAGE_KEY = "local_memos_list"; // 現在のメモリスト保存用キー
const OLD_STORAGE_KEY = "local_memo_data"; // 旧バージョンのデータ保存用キー(後方互換性のため)
const PWD_STORAGE_KEY = "local_memo_export_password"; // マスターパスワードの保存用キー

// メモの本文からタイトル(最初の1行)を抽出する関数
const getTitle = (text: string): string => {
  if (!text) return "新しいメモ"; // テキストが空の場合はデフォルト値
  // 改行で分割し、空行ではない最初の行を取得
  const line = text.split("\n").find((l) => l.trim() !== "");
  if (!line) return "新しいメモ";
  const trimmed = line.trim();
  // 10文字を超える場合は省略記号を付けて返す
  return trimmed.length > 10 ? trimmed.slice(0, 10) + "..." : trimmed;
};

// ===============================
// 暗号化ユーティリティ(非同期 WebCrypto API 用)
// 主にJSONファイルへのエクスポート・インポート時に、強固な暗号化を行うために使用
// ===============================

// ✅ 指定した長さのランダムなバイト列を生成する(初期化ベクトルやソルトに使用)
function randomBytes(len: number): Uint8Array {
  const u8: Uint8Array = new Uint8Array(len);
  crypto.getRandomValues(u8);
  return u8;
}

// ✅ WebCrypto に渡す時に確実に ArrayBuffer に落とす(TSの BufferSource 判定問題を回避)
function toArrayBuffer(u8: Uint8Array): ArrayBuffer {
  return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
}

// ✅ パスワードとソルトから、AES-GCM用の暗号化キーを生成する(PBKDF2を使用)
async function deriveKey(password: string, salt: Uint8Array, iterations = 210000) {
  const enc = new TextEncoder();

  // パスワード文字列からベースとなる鍵素材をインポート
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    enc.encode(password),
    { name: "PBKDF2" },
    false,
    ["deriveKey"]
  );

  // PBKDF2アルゴリズムで指定回数(iterations)ハッシュ化し、AES-GCM 256bitの鍵を導出
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt: toArrayBuffer(salt), iterations, hash: "SHA-256" }, // ✅ saltもArrayBufferで渡す
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

// ✅ バイナリデータ(ArrayBufferまたはUint8Array)をBase64文字列に変換する(保存・通信用)
function bufferToBase64(buf: ArrayBuffer | Uint8Array): string {
  const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
  let binary = "";
  for (let i = 0; i < u8.byteLength; i++) binary += String.fromCharCode(u8[i]);
  return btoa(binary);
}

// ✅ Base64文字列をバイナリデータ(Uint8Array)に変換する(復号時の読み込み用)
function base64ToU8(base64: string): Uint8Array {
  const binary = atob(base64);
  const u8: Uint8Array = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) u8[i] = binary.charCodeAt(i);
  return u8;
}

// ✅ メモ本文のSHA-256ハッシュ値を計算する
// インポート時に内容が全く同じメモが重複して追加されるのを防ぐための識別子として使用
async function calculateHash(text: string) {
  const encoder = new TextEncoder();
  const data = encoder.encode(text || "");
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}

// ===============================
// LocalStorage用の同期暗号化(XOR + Base64)
// Reactのレンダリングサイクル内で同期的に素早く処理するため、簡易的なXOR暗号を採用
// ===============================

// ===============================
// LocalStorage用の同期暗号化(RC4 + Base64)
// ===============================
function rc4EncryptDecrypt(key: string, data: Uint8Array): Uint8Array {
  const s = new Uint8Array(256);
  for (let i = 0; i < 256; i++) s[i] = i;
  let j = 0;
  for (let i = 0; i < 256; i++) {
    j = (j + s[i] + key.charCodeAt(i % key.length)) % 256;
    const temp = s[i]; s[i] = s[j]; s[j] = temp;
  }
  let i = 0; j = 0;
  const out = new Uint8Array(data.length);
  for (let y = 0; y < data.length; y++) {
    i = (i + 1) % 256;
    j = (j + s[i]) % 256;
    const temp = s[i]; s[i] = s[j]; s[j] = temp;
    out[y] = data[y] ^ s[(s[i] + s[j]) % 256];
  }
  return out;
}

function encryptTextSync(text: string, key: string): string {
  if (!text || !key) return text;
  const textBytes = new TextEncoder().encode(text);
  const encryptedBytes = rc4EncryptDecrypt(key, textBytes);
  let binary = "";
  for (let i = 0; i < encryptedBytes.length; i++) {
    binary += String.fromCharCode(encryptedBytes[i]);
  }
  return btoa(binary);
}

function decryptTextSync(base64: string, key: string): string {
  if (!base64 || !key) return base64;
  try {
    const binary = atob(base64);
    const encryptedBytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
      encryptedBytes[i] = binary.charCodeAt(i);
    }
    const decryptedBytes = rc4EncryptDecrypt(key, encryptedBytes);
    return new TextDecoder().decode(decryptedBytes);
  } catch {
    return base64; // Base64デコード失敗時(過去の平文データ等)のフォールバック
  }
}

export default function Memo() {
  const navigate = useNavigate();
  // AppContextから、LocalStorage暗号化用のハッシュ化されたキーを取得
  const { hashedValue } = useAppContext();

  // ===== メモ状態 =====
  const [memos, setMemos] = useState<MemoItem[]>([]); // すべてのメモの配列
  const [currentMemoId, setCurrentMemoId] = useState<number | null>(null); // 現在選択中のメモID
  const [editorText, setEditorText] = useState(""); // エディタの入力内容(リアルタイム)

  // ===== “2秒後保存”のstale(古い状態の参照)対策 =====
  // setTimeout内のコールバックから最新のstateを参照できるように、useRefで最新値を保持する
  const editorTextRef = useRef("");
  const currentMemoIdRef = useRef<number | null>(null);
  useEffect(() => {
    editorTextRef.current = editorText;
  }, [editorText]);
  useEffect(() => {
    currentMemoIdRef.current = currentMemoId;
  }, [currentMemoId]);

  // 自動保存用のタイマーIDを保持
  const saveTimerRef = useRef<number | null>(null);

  // ===== 削除モーダル =====
  // 削除対象のメモID(nullの場合はモーダル非表示)
  const [memoToDelete, setMemoToDelete] = useState<number | null>(null);

  // ===== パスワードモーダル(export/import共通) =====
  const [pwdOpen, setPwdOpen] = useState(false); // モーダルの表示状態
  const [pwdValue, setPwdValue] = useState(""); // 入力されたパスワード
  const [pwdError, setPwdError] = useState(""); // エラーメッセージ
  const [pwdMode, setPwdMode] = useState<"" | "export" | "import">(""); // どの操作のためのパスワードか
  const importTempRef = useRef<any>(null); // インポート時、パスワード入力待ちの間にデータを一時保持


  // 永続パスワード(あれば入力を省略できる)
  // 初期化時にLocalStorageから取得し、Appの暗号化キー(hashedValue)で復号する
  const [masterPassword, setMasterPassword] = useState<string | null>(() => {
    const stored = localStorage.getItem(PWD_STORAGE_KEY);
    return stored ? decryptTextSync(stored, hashedValue || "") : null;
  });

  // ファイル input を React からクリックするため(インポートダイアログを開くため)の参照
  const importFileRef = useRef<HTMLInputElement | null>(null);

  // 現在のメモ(表示用)※現行コードでは変数としては受けていないが、副作用的に計算させている
  useMemo(() => memos.find((m) => m.id === currentMemoId) ?? null, [memos, currentMemoId]);

  // ✅ LocalStorage保存時の暗号化ラッパー
  // メモの配列を受け取り、テキスト部分を暗号化してからLocalStorageに保存する
  const saveMemosToLocal = (memosArray: MemoItem[]) => {
    const encrypted = memosArray.map((m) => ({
      ...m,
      text: encryptTextSync(m.text, hashedValue || "")
    }));
    localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted));
  };

  // ===== 初期化 =====
  // コンポーネントマウント時、または暗号化キー(hashedValue)変更時にLocalStorageからデータを復元
  useEffect(() => {
    const stored = localStorage.getItem(STORAGE_KEY);
    let initial: MemoItem[] = [];

    if (stored) {
      // 既存のデータがある場合はパースして復号する
      try {
        const parsed = JSON.parse(stored) as MemoItem[];
        // ✅ 読み込み時に復号
        initial = parsed.map((m) => ({
          ...m,
          text: decryptTextSync(m.text, hashedValue || ""),
        }));
      } catch {
        initial = []; // パース失敗時は空配列
      }
    } else {
      // 旧バージョンのデータ(単一のテキストデータ)がある場合のマイグレーション処理
      const oldData = localStorage.getItem(OLD_STORAGE_KEY);
      if (oldData) {
        initial = [{ id: Date.now(), text: oldData, timestamp: Date.now() }];
        localStorage.removeItem(OLD_STORAGE_KEY);
        saveMemosToLocal(initial); // 変更を保存してマイグレーション完了
      }
    }

    // データが1つもない場合は、空の新しいメモを1つ作成する
    if (initial.length === 0) {
      const newMemo: MemoItem = { id: Date.now(), text: "", timestamp: Date.now() };
      initial = [newMemo];
    } else {
      // 更新日時の降順(新しい順)でソート
      initial.sort((a, b) => b.timestamp - a.timestamp);
    }

    // 状態に反映し、最初のメモを選択状態にする
    setMemos(initial);
    setCurrentMemoId(initial[0].id);
    setEditorText(initial[0].text);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [hashedValue]);

  // ===== 保存(即時) =====
  // 現在エディタに入力されているテキストを、該当メモに反映して保存する
  const saveCurrentMemoNow = () => {
    const id = currentMemoIdRef.current;
    const text = editorTextRef.current;
    if (!id) return;

    setMemos((prev) => {
      const next = prev.map((m) => {
        if (m.id !== id) return m; // 他のメモはそのまま
        if (m.text === text) return m; // 変更が無ければそのまま
        // 変更があればテキストとタイムスタンプを更新
        return { ...m, text, timestamp: Date.now() };
      });

      // 更新日時順に並び替え
      next.sort((a, b) => b.timestamp - a.timestamp);
      saveMemosToLocal(next); // 変更をLocalStorageに保存
      return next;
    });
  };

  // ===== メモ切替 =====
  // リストから別のメモをクリックしたときの処理
  const switchMemo = (id: number) => {
    saveCurrentMemoNow(); // 切り替え前に現在の状態を保存
    const target = memos.find((m) => m.id === id);
    setCurrentMemoId(id);
    setEditorText(target ? target.text : "");
  };

  // ===== 新規 =====
  // 「+ 追加」ボタンをクリックしたときの処理
  const createNewMemo = () => {
    saveCurrentMemoNow(); // 現在のメモを保存
    const newMemo: MemoItem = { id: Date.now(), text: "", timestamp: Date.now() };

    // 新しいメモを先頭に追加して保存
    setMemos((prev) => {
      const next = [newMemo, ...prev];
      saveMemosToLocal(next); // 変更
      return next;
    });

    setCurrentMemoId(newMemo.id);
    setEditorText(""); // エディタを空にする
  };

  // ===== 入力 =====
  // エディタのテキストが変更されたときの処理(タイピング中)
  const onEditorChange = (text: string) => {
    setEditorText(text);

    // デバウンス処理:入力があるたびにタイマーをリセットし、入力が2秒止まったら自動保存する
    if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
    saveTimerRef.current = window.setTimeout(() => {
      saveCurrentMemoNow();
    }, 2000);
  };

  // エディタからフォーカスが外れたときの処理
  const onEditorBlur = () => {
    if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
    saveCurrentMemoNow(); // 即座に保存する
  };

  // ===== 削除 =====
  // 削除モーダルを開く
  const openDeleteModal = (id: number) => setMemoToDelete(id);
  // 削除モーダルを閉じる
  const closeDeleteModal = () => setMemoToDelete(null);

  // 削除の実行(モーダルの「削除する」ボタンクリック時)
  const confirmDelete = () => {
    if (!memoToDelete) return;

    setMemos((prev) => {
      // 削除対象のIDを除外した新しい配列を作成
      const next = prev.filter((m) => m.id !== memoToDelete);
      saveMemosToLocal(next); // 変更
      return next;
    });

    // 削除したメモが現在開いているメモだった場合の処理
    if (currentMemoId === memoToDelete) {
      const remaining = memos.filter((m) => m.id !== memoToDelete);
      if (remaining.length === 0) {
        // メモが0件になった場合は、空のメモを新規作成
        const newMemo: MemoItem = { id: Date.now(), text: "", timestamp: Date.now() };
        setMemos([newMemo]);
        setCurrentMemoId(newMemo.id);
        setEditorText("");
        saveMemosToLocal([newMemo]); // 変更
      } else {
        // 他のメモが残っている場合は、一番新しいメモを開く
        remaining.sort((a, b) => b.timestamp - a.timestamp);
        setCurrentMemoId(remaining[0].id);
        setEditorText(remaining[0].text);
      }
    }

    closeDeleteModal();
  };

  // ===== パスワードモーダル操作 =====
  // エクスポート/インポート時にパスワードが未設定の場合に開く
  const openPwdModal = (mode: "export" | "import", importData: any = null) => {
    setPwdMode(mode);
    importTempRef.current = importData; // インポートデータを一時保持
    setPwdValue("");
    setPwdError("");
    setPwdOpen(true);
  };

  // モーダルを閉じて状態をリセット
  const closePwdModalAll = () => {
    setPwdOpen(false);
    setPwdMode("");
    importTempRef.current = null;
    setPwdValue("");
    setPwdError("");
  };

  // パスワードモーダルの「OK」ボタンクリック時
 const confirmPwd = async () => {
    const pwd = pwdValue.trim();
    if (!pwd) {
      setPwdError("パスワードを入力してください");
      return;
    }

    setMasterPassword(pwd);
    // ✅ パスワードも暗号化して保存
    localStorage.setItem(PWD_STORAGE_KEY, encryptTextSync(pwd, hashedValue || ""));

    if (pwdMode === "export") {
      closePwdModalAll();
      await performExport(pwd);
    } else if (pwdMode === "import") {
      await performImport(pwd, importTempRef.current);
    }
  };

  // ===== エクスポート =====
  // データをAES-GCMで強固に暗号化し、JSONファイルとしてダウンロードさせる
  const performExport = async (password: string) => {
    try {
      saveCurrentMemoNow(); // 最新状態を保存

      // すべてのメモを非同期で1件ずつ暗号化する
      const exportData = await Promise.all(
        memos.map(async (memo) => {
          const salt = randomBytes(16); // セキュリティを高めるためのランダムなソルト
          const iv = randomBytes(12);   // AES-GCMに必要な初期化ベクトル
          const iterations = 210000;    // PBKDF2のストレッチング回数

          const key = await deriveKey(password, salt, iterations);
          const enc = new TextEncoder();

          // ✅ iv は ArrayBuffer で渡す(TSの BufferSource 判定を確実に通す)
          const encrypted = await crypto.subtle.encrypt(
            { name: "AES-GCM", iv: toArrayBuffer(iv) },
            key,
            enc.encode(memo.text)
          );

          // 後でインポートする際の重複チェック用にハッシュを計算
          const hashValue = await calculateHash(memo.text);

          // エクスポート用フォーマットに整形
          return {
            id: memo.id,
            timestamp: memo.timestamp,
            hash: hashValue,
            encryption: {
              format: "memo-v1", // フォーマットのバージョン識別子
              kdf: { name: "PBKDF2", hash: "SHA-256", iterations },
              salt_b64: bufferToBase64(salt),
              iv_b64: bufferToBase64(iv),
              ciphertext_b64: bufferToBase64(encrypted),
              createdAt: new Date(memo.timestamp).toISOString(),
            },
          };
        })
      );

      // JSON化してBlobを作成し、擬似的にリンクをクリックさせてダウンロード
      const dataStr = JSON.stringify(exportData, null, 2);
      const blob = new Blob([dataStr], { type: "application/octet-stream" });
      const url = URL.createObjectURL(blob);

      const a = document.createElement("a");
      a.href = url;
      a.download = "local_memos.json";
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url); // メモリ解放
    } catch (e) {
      console.error("エクスポート処理中にエラー", e);
      alert("エクスポート処理中にエラーが発生しました");
    }
  };


  // ===== インポート =====
  // 読み込んだJSONファイルからデータを復号し、現在のデータと統合する
  const performImport = async (password: string | null, parsedData: any) => {
    try {
      let newMemos: MemoItem[] = [];

      // 旧形式:ファイル全体が1つのオブジェクトとして memo-v1 で暗号化されている場合
      if (parsedData?.format === "memo-v1") {
        if (!password) {
          openPwdModal("import", parsedData); // パスワードが無ければ要求
          return;
        }

        const salt = base64ToU8(parsedData.salt_b64);
        const iv = base64ToU8(parsedData.iv_b64);
        const ciphertext = base64ToU8(parsedData.ciphertext_b64);
        const iterations = parsedData.kdf?.iterations || 210000;

        const key = await deriveKey(password, salt, iterations);

        // ✅ iv/ciphertext は ArrayBuffer で渡す
        const decrypted = await crypto.subtle.decrypt(
          { name: "AES-GCM", iv: toArrayBuffer(iv) },
          key,
          toArrayBuffer(ciphertext)
        );

        const dec = new TextDecoder();
        newMemos = JSON.parse(dec.decode(decrypted)) as MemoItem[];
      }
      // 現行形式:配列として保存されており、要素ごとに暗号化されている場合
      else if (Array.isArray(parsedData)) {
        // 1つでも暗号化されたデータが含まれているかチェック
        const needsDecryption = parsedData.some((m: any) => m?.encryption?.format === "memo-v1");
        if (needsDecryption && !password) {
          openPwdModal("import", parsedData); // パスワードが無ければ要求
          return;
        }

        // 要素ごとに復号処理を実行
        newMemos = await Promise.all(
          parsedData.map(async (item: any) => {
            if (item?.encryption?.format === "memo-v1") {
              const encData = item.encryption;

              const salt = base64ToU8(encData.salt_b64);
              const iv = base64ToU8(encData.iv_b64);
              const ciphertext = base64ToU8(encData.ciphertext_b64);
              const iterations = encData.kdf?.iterations || 210000;

              if (!password) throw new Error("Password required but not provided");

              const key = await deriveKey(password, salt, iterations);

              const decrypted = await crypto.subtle.decrypt(
                { name: "AES-GCM", iv: toArrayBuffer(iv) },
                key,
                toArrayBuffer(ciphertext)
              );

              const dec = new TextDecoder();
              const text = dec.decode(decrypted);

              return {
                id: item.id || Date.now(),
                text,
                timestamp: item.timestamp || Date.now(),
                hash: item.hash || "",
              } as MemoItem;
            }

            // 暗号化されていないデータの場合
            return {
              id: item.id || Date.now(),
              text: item.text ?? "",
              timestamp: item.timestamp || Date.now(),
              hash: item.hash || "",
            } as MemoItem;
          })
        );
      } else {
        throw new Error("Unsupported import format"); // 未知のフォーマット
      }

      // ✅ 修正部分:ID、ハッシュ値、タイムスタンプを用いた厳密なマージ処理
      // 既存のメモとインポートしたメモを統合する
      const mergedMap = new Map<number, MemoItem>();
      
      // 1. 既存のメモをMapにセット(IDをキーにする)
      memos.forEach((m) => mergedMap.set(m.id, m));

      // 2. インポートしたメモを条件付きで評価・マージ
      newMemos.forEach((importedMemo) => {
        const existingMemo = mergedMap.get(importedMemo.id);

        if (!existingMemo) {
          // 既存に存在しないIDは新規として追加
          mergedMap.set(importedMemo.id, importedMemo);
        } else {
          // 既存に存在する場合の比較ロジック
          // (※既存メモにハッシュがない場合は、テキストの完全一致で判定を補完)
          const isSameContent = importedMemo.hash && existingMemo.hash
            ? importedMemo.hash === existingMemo.hash
            : importedMemo.text === existingMemo.text;

          if (!isSameContent && importedMemo.timestamp > existingMemo.timestamp) {
            // ハッシュ(内容)が異なり、インポートデータの方が新しい場合のみ上書き更新
            mergedMap.set(importedMemo.id, importedMemo);
          }
          // ※上記以外(完全一致、または既存の方が新しい場合)は既存のデータを維持して何もしない
        }
      });

      // Mapから配列に戻してソート(新しい順)
      const finalMemos = Array.from(mergedMap.values());
      finalMemos.sort((a, b) => b.timestamp - a.timestamp);

      // 状態とLocalStorageに反映
      setMemos(finalMemos);
      saveMemosToLocal(finalMemos); // 変更

      if (finalMemos.length > 0) {
        // 現在開いていたメモの表示を維持する(無ければ最新のものを開く)
        const activeId = currentMemoIdRef.current || finalMemos[0].id;
        const target = finalMemos.find((m) => m.id === activeId) || finalMemos[0];
        setCurrentMemoId(target.id);
        setEditorText(target.text);
      } else {
        // 万が一空になった場合のフェイルセーフ
        const newMemo: MemoItem = { id: Date.now(), text: "", timestamp: Date.now() };
        setMemos([newMemo]);
        setCurrentMemoId(newMemo.id);
        setEditorText("");
        saveMemosToLocal([newMemo]); // 変更
      }

      // 成功したらモーダルを閉じ、input[type="file"]の値をリセットして連続インポート可能にする
      setPwdError("");
      closePwdModalAll();
      if (importFileRef.current) importFileRef.current.value = "";
    } catch (e) {
      console.error(e);
      setPwdError("パスワードが間違っているか、データが破損しています。");

      // パスワードエラー時は記憶しているパスワードを破棄する
      setMasterPassword(null);
      localStorage.removeItem(PWD_STORAGE_KEY);

      // モーダルが開いていなければ開く
      if (!pwdOpen) openPwdModal("import", parsedData);
    }
  };

  // ===== クリックハンドラ =====
  // エクスポートボタンクリック時
  const onExportClick = async () => {
    saveCurrentMemoNow();
    if (!masterPassword) {
      openPwdModal("export"); // パスワード未設定なら要求
      return;
    }
    await performExport(masterPassword);
  };

  // インポートボタンクリック時(非表示の<input type="file">をクリックさせる)
  const onImportClick = () => {
    importFileRef.current?.click();
  };

  // ファイルが選択された時の処理(パースしてフォーマット判定し、必要ならパスワードを要求)
  const onImportFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    try {
      const text = await file.text();
      const parsed = JSON.parse(text);

      if (parsed?.format === "memo-v1") {
        if (!masterPassword) openPwdModal("import", parsed);
        else await performImport(masterPassword, parsed);
        return;
      }

      if (Array.isArray(parsed)) {
        const needsDecryption = parsed.some((m: any) => m?.encryption?.format === "memo-v1");
        if (needsDecryption) {
          if (!masterPassword) openPwdModal("import", parsed);
          else await performImport(masterPassword, parsed);
        } else {
          await performImport(null, parsed); // 平文ファイルの場合はパスワードなしで即インポート
        }
        return;
      }

      alert("対応していない形式のファイルです");
    } catch (err) {
      console.error("インポート失敗", err);
      alert("インポートファイルの読み込みに失敗しました");
    }
  };

  // ログアウト処理(保存してからルーティング変更)
  const logout = () => {
    saveCurrentMemoNow();
    navigate("/");
  };

  // ✅ 表示上のチラつき対策:編集中のメモだけは、State(memos)の更新を待たずに editorText からリアルタイムにタイトルを生成する
  const titleFor = (memo: MemoItem) =>
    memo.id === currentMemoId ? getTitle(editorText) : getTitle(memo.text);

  return (
    <div className="memo-wrapper">
      {/* ヘッダー領域(インポート・エクスポート・ログアウト) */}
      <header className="header">
        <div className="header-buttons">
          <button id="import-btn" className="header-btn" onClick={onImportClick}>
            インポート
          </button>
          <button id="export-btn" className="header-btn" onClick={onExportClick}>
            エクスポート
          </button>

          {/* 非表示のファイル選択用インプット */}
          <input
            ref={importFileRef}
            type="file"
            id="import-file"
            accept=".json"
            style={{ display: "none" }}
            onChange={onImportFileChange}
          />
        </div>

        <button id="logout-btn" className="header-btn" onClick={logout}>
          ログアウト
        </button>
      </header>

      {/* メイン領域 */}
      <div className="container">
        {/* 左ペイン(メモのリスト) */}
        <div className="left-pane">
          <div className="left-pane-header">
            <h2 className="pane-title">メモ一覧</h2>
            <button id="add-memo-btn" className="add-btn" onClick={createNewMemo}>
              + 追加
            </button>
          </div>

          <ul className="memo-list" id="memo-list">
            {memos.map((memo) => (
              <li
                key={memo.id}
                className={memo.id === currentMemoId ? "active" : ""} // 選択中のメモはハイライト
                onClick={() => switchMemo(memo.id)}
              >
                <span className="memo-title-span">{titleFor(memo)}</span>

                <button
                  className="delete-btn"
                  title="メモを削除"
                  onClick={(ev) => {
                    ev.stopPropagation(); // 親のliのonClick(switchMemo)が発火するのを防ぐ
                    openDeleteModal(memo.id);
                  }}
                >
                  ✖
                </button>
              </li>
            ))}
          </ul>
        </div>

        {/* 右ペイン(テキストエディタ) */}
        <div className="right-pane">
          <textarea
            className="memo-editor"
            id="memo-editor"
            placeholder="ここにメモを入力してください..."
            value={editorText}
            onChange={(e) => onEditorChange(e.target.value)}
            onBlur={onEditorBlur}
          />
        </div>
      </div>

      {/* 削除確認モーダル */}
      <div className={`modal-overlay ${memoToDelete ? "active" : ""}`} id="delete-modal">
        <div className="modal-content">
          <p style={{ fontSize: "18px", margin: 0 }}>このメモを削除してもよろしいですか?</p>
          <div className="modal-actions">
            <button className="modal-btn btn-cancel" id="cancel-delete-btn" onClick={closeDeleteModal}>
              キャンセル
            </button>
            <button className="modal-btn btn-danger" id="confirm-delete-btn" onClick={confirmDelete}>
              削除する
            </button>
          </div>
        </div>
      </div>

      {/* パスワード要求モーダル(インポート/エクスポート時) */}
      <div className={`modal-overlay ${pwdOpen ? "active" : ""}`} id="password-modal">
        <div className="modal-content">
          <p id="password-modal-msg" style={{ fontSize: "18px", margin: "0 0 15px 0" }}>
            マスターパスワードを入力してください
          </p>

          <input
            type="password"
            id="crypto-password"
            value={pwdValue}
            onChange={(e) => setPwdValue(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") confirmPwd(); // Enterキーで送信可能に
            }}
            style={{
              width: "100%",
              padding: "12px",
              boxSizing: "border-box",
              border: "2px solid #8ebd9e",
              borderRadius: "4px",
              fontSize: "16px",
              backgroundColor: "#e6eedf",
              color: "#1a241e",
              marginBottom: "10px",
            }}
          />

          <p
            id="password-error"
            style={{
              color: "#ff6b6b",
              fontSize: "14px",
              margin: "0 0 10px 0",
              display: pwdError ? "block" : "none", // エラーがある場合のみ表示
            }}
          >
            {pwdError}
          </p>

          <div className="modal-actions">
            <button className="modal-btn btn-cancel" id="cancel-password-btn" onClick={closePwdModalAll}>
              キャンセル
            </button>
            <button
              className="modal-btn"
              style={{ backgroundColor: "#d97b29", color: "#ffffff" }}
              id="confirm-password-btn"
              onClick={confirmPwd}
            >
              OK
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

Reactで localStorage にアクセスし、データを保存・取得・削除するシンプルなコンポーネントのコードサンプルです。そのままコピー&ペーストして動作確認にお使いいただけます。

メソッド

  • データの取得: localStorage.getItem('キー名')
  • データの保存: localStorage.setItem('キー名', '保存する値')
  • データの削除: localStorage.removeItem('キー名')


    import React, { useState, useEffect } from 'react';

const LocalStorageExample = () => {
  // 1. 初期値の設定: localStorageからデータを取得(なければ空文字)
  const [name, setName] = useState(() => {
    const savedName = localStorage.getItem('username');
    return savedName || '';
  });

  // 2. データの保存: nameの値が変更されるたびにlocalStorageを更新
  useEffect(() => {
    localStorage.setItem('username', name);
  }, [name]);

  // 入力時のハンドラー
  const handleChange = (e) => {
    setName(e.target.value);
  };

  // データの削除ハンドラー
  const handleClear = () => {
    localStorage.removeItem('username');
    setName('');
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h2>LocalStorage アクセスサンプル</h2>
      
      <div style={{ marginBottom: '10px' }}>
        <input
          type="text"
          value={name}
          onChange={handleChange}
          placeholder="名前を入力してください"
          style={{ padding: '8px', fontSize: '16px' }}
        />
      </div>

      <p>現在の保存データ: <strong>{name ? name : 'データなし'}</strong></p>

      <button 
        onClick={handleClear}
        style={{ padding: '8px 16px', cursor: 'pointer' }}
      >
        データをクリア
      </button>
    </div>
  );
};

export default LocalStorageExample;
  

ポイント

  • 遅延初期化(Lazy Initialization): useState の初期値に関数を渡すことで、初回レンダリング時のみ localStorage.getItem を実行し、パフォーマンスの低下を防いでいます。
  • 副作用による保存: useEffect の依存配列に [name] を指定することで、入力値(name)が変更されたタイミングで自動的に localStorage.setItem が走るようにしています。
  • データの削除: handleClear 関数で localStorage.removeItem を呼び出し、保存されたデータを削除しています。


カスタムフック(useLocalStorage)のコードサンプルです。 このフックを使うと、Reactの標準フックである useState と全く同じ感覚で localStorage を扱えるようになります。また、文字列だけでなくオブジェクトや配列も保存できるように JSON.parse と JSON.stringify を組み込んでいます。 以下の2つのコードをコピーして動作を確認してみてください。


    import { useState, useEffect } from 'react';

export const useLocalStorage = (key, initialValue) => {
  // 1. 初期値の取得と設定
  const [value, setValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      // 保存されたデータがあればパースして返し、なければ初期値を返す
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // 2. 値が更新されたらlocalStorageに保存
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, value]);

  return [value, setValue];
};
  

2. カスタムフックの使用例 (App.jsx) 作成したフックをコンポーネントで呼び出します。複数の異なるデータ(名前、テーマ設定など)を個別に管理するのが非常に簡単になります。


    import React from 'react';
// 先ほど作成したファイルのパスに合わせてインポートしてください
import { useLocalStorage } from './useLocalStorage';

const App = () => {
  // useStateと全く同じ書き方で使える
  const [name, setName] = useLocalStorage('username', '');
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <div style={{ 
      padding: '20px', 
      fontFamily: 'sans-serif',
      backgroundColor: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#000' : '#fff',
      minHeight: '100vh'
    }}>
      <h2>カスタムフック利用サンプル</h2>
      
      <div style={{ marginBottom: '20px' }}>
        <label>
          名前: 
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            style={{ marginLeft: '10px', padding: '4px' }}
          />
        </label>
      </div>

      <div style={{ marginBottom: '20px' }}>
        <p>現在のテーマ: {theme}</p>
        <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
          テーマを切り替え
        </button>
      </div>
    </div>
  );
};

export default App;
  

このアプローチのメリット

  • 再利用性: どのコンポーネントからでも const [state, setState] = useLocalStorage('key', '初期値') だけで呼び出せます。
  • データ型の保持: 内部で JSON.stringify/parse を行っているため、配列やオブジェクトをそのまま保存・取得できます。
  • 安全なエラーハンドリング: try...catch で囲んでいるため、ブラウザのプライベートモード等で localStorage へのアクセスが制限されている場合でもアプリがクラッシュしません。

CSV->INSERT文変換ツール

CSV形式のテキストを、SQLのINSERT文に変換するツールです。以下のコードをコピーして、ブラウザで動作確認してみてください。

特徴

  • 様々な日付・時刻の表記をSQL標準の形式に正規化
  • 全角数字やスペースを半角に変換
  • MySQL、PostgreSQL、SQLiteなど複数のDBMSに対応したINSERT文を生成
  • シンプルで直感的なUI
テーブル名 users_table MySQL / PostgreSQL / SQLite CSV / TSV をペーストしてください id, name, created_at 1, 田中, 2026年2月26日 15時30分 2, 鈴木, 2026/02/26 変換 ↓ INSERT文 コピー INSERT INTO users_table (id, name, created_at) VALUES (1, '田中', '2026-02-26 15:30:00');

papaparseライブラリを使用しています。


npm install papaparse
npm install -D @types/papaparse
      

index.html


<!doctype html>
<html lang="ja"> <!-- 日本語 -->
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>CSV->SQL変換</title> <!-- ブラウザのタブに表示されるタイトル --> 
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
    

index.css


      /* src/index.css
 * ------------------------------------------------------------
 * 役割:
 * - Viteテンプレの初期CSSをリセットし、画面が変な位置に寄るのを防ぐ
 */

:root {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
    Arial, "Noto Sans JP", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color: #111827;
  background-color: #ffffff;
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: #f8fafc;
}

#root {
  min-height: 100%;
}
    

App.tsx


import { useState, useRef, useEffect } from 'react';
import Papa from 'papaparse'; // CSVパース用ライブラリ
import './App.css';

/**
 * 全角の数字・英字・スペースを半角に変換するユーティリティ
 */
const toHalfWidth = (str: string) => {
  return str.replace(/[A-Za-z0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
            .replace(/ /g, ' '); 
};

/**
 * 様々な日付・時刻の記述を SQL 標準の YYYY-MM-DD HH:MM:SS 形式に整える
 */
const normalizeDateString = (str: string) => {
  let normalized = toHalfWidth(str);
  
  // 正規表現の第1引数(マッチした全文)は使わないため `_` とすることで
  // TypeScript の「未使用変数エラー(ts6133)」を回避しています。
  normalized = normalized.replace(/(\d{4})年(\d{1,2})月(\d{1,2})日?/g, (_, y, m, d) => {
      return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
  });
  normalized = normalized.replace(/(\d{4})\/(\d{1,2})\/(\d{1,2})/g, (_, y, m, d) => {
      return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
  });
  normalized = normalized.replace(/(\d{1,2})時(\d{1,2})分(?:(\d{1,2})秒?)?/g, (_, h, m, s) => {
      return `${h.padStart(2, '0')}:${m.padStart(2, '0')}:${(s || '00').padStart(2, '0')}`;
  });
  
  return normalized.trim();
};

// 日付やタイムスタンプを判定するためのパターン
const isDatePattern = /^\d{4}-\d{2}-\d{2}$/;
const isTimestampPattern = /^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}(:\d{2})?(?:\.\d+)?$/;

/**
 * パースされたデータから、選択されたDBの種類に応じた INSERT 文を組み立てる
 */
const generateSQL = (tableName: string, parsedData: any[], dbType: string) => {
  if (!parsedData || parsedData.length === 0) return "";

  const columns = Object.keys(parsedData[0]);
  const columnsString = columns.join(', ');

  const valuesArray = parsedData.map(row => {
      const formattedValues = columns.map(col => {
          const val = row[col];
          
          // 空データや未定義は SQL の NULL に変換
          if (val === null || val === undefined || val === '') return 'NULL';
          // 数値と真偽値はクォートなしで出力
          if (typeof val === 'number') return val;
          if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE';

          if (typeof val === 'string') {
              const normalizedStr = normalizeDateString(val);

              // タイムスタンプ形式の判定と変換(Oracleの場合は専用関数を適用)
              if (isTimestampPattern.test(normalizedStr)) {
                  const finalStr = normalizedStr.length <= 16 ? `${normalizedStr}:00` : normalizedStr;
                  if (dbType === 'oracle') {
                      return `TO_TIMESTAMP('${finalStr}', 'YYYY-MM-DD HH24:MI:SS')`;
                  }
                  return `'${finalStr}'`;
              }

              // 日付形式の判定と変換
              if (isDatePattern.test(normalizedStr)) {
                  if (dbType === 'oracle') {
                      return `TO_DATE('${normalizedStr}', 'YYYY-MM-DD')`;
                  }
                  return `'${normalizedStr}'`;
              }

              // 通常の文字列はシングルクォートをエスケープして囲む
              const escapedStr = val.replace(/'/g, "''");
              return `'${escapedStr}'`;
          }
          return `'${String(val)}'`;
      });
      return `(${formattedValues.join(', ')})`;
  });

  // 各DBのバルクインサート構文の違いを反映
  if (dbType === 'oracle') {
      let sql = 'INSERT ALL\n';
      valuesArray.forEach(val => {
          sql += `  INTO ${tableName} (${columnsString}) VALUES ${val}\n`;
      });
      sql += 'SELECT 1 FROM DUAL;';
      return sql;
  } else if (dbType === 'sqlserver') {
      const chunkSize = 1000; // SQL Server の制限に合わせて1000行ごとに分割
      let sql = '';
      for (let i = 0; i < valuesArray.length; i += chunkSize) {
          const chunk = valuesArray.slice(i, i + chunkSize);
          sql += `INSERT INTO ${tableName} (${columnsString})\nVALUES\n  ${chunk.join(',\n  ')};\n\n`;
      }
      return sql.trim();
  } else {
      return `INSERT INTO ${tableName} (${columnsString})\nVALUES\n  ${valuesArray.join(',\n  ')};`;
  }
};

function App() {
  // --- ステート管理 ---
  const [tableName, setTableName] = useState('');
  const [dbType, setDbType] = useState('mysql');
  const [csvInput, setCsvInput] = useState('');
  const [sqlResult, setSqlResult] = useState('');
  const [errorMessage, setErrorMessage] = useState('');
  // コピー待機状態を管理するフラグ
  const [isPendingCopy, setIsPendingCopy] = useState(false);

  const resultTextareaRef = useRef<HTMLTextAreaElement>(null);

  /**
   * クリップボードへのコピーを実行する共通関数
   */
  const executeCopy = async (text: string, silent = false) => {
    if (!text) return;
    
    // 1. まずはモダンな API でのコピーを試みる(変数から直接コピーできるため確実)
    if (navigator.clipboard && window.isSecureContext) {
      try {
        await navigator.clipboard.writeText(text);
        if (!silent) alert('全文をコピーしました!');
        return;
      } catch (err) {
        console.error('Clipboard API 失敗:', err);
      }
    }

    // 2. フォールバック:古いブラウザや非HTTPS環境向け(DOMを選択してコピー)
    if (resultTextareaRef.current) {
      resultTextareaRef.current.select();
      try {
        document.execCommand('copy');
        if (!silent) alert('全文をコピーしました!');
      } catch (err) {
        console.error('execCommand 失敗:', err);
      }
      window.getSelection()?.removeAllRanges();
    }
  };

  /**
   * useEffect で sqlResult の更新とコピーフラグを監視
   * ステートが更新され、DOM に反映された後に確実にコピーを実行する仕組み
   */
  useEffect(() => {
    if (isPendingCopy && sqlResult) {
      executeCopy(sqlResult, true); // 自動コピー時はアラートを出さない
      setIsPendingCopy(false);
    }
  }, [sqlResult, isPendingCopy]);

  /**
   * 変換処理
   */
  const handleConvert = () => {
    if (!tableName.trim()) {
      setErrorMessage('エラー:テーブル名が入力されていません');
      return;
    }

    // CSV の解析設定
    const result = Papa.parse(csvInput, {
      header: true,
      dynamicTyping: true,
      skipEmptyLines: true,
      transformHeader: (header) => header.trim(),
      transform: (value) => typeof value === 'string' ? value.trim() : value
    });

    if (result.errors.length > 0) {
      setErrorMessage(`エラー:CSVの形式が正しくありません (${result.errors[0].message})`);
      return;
    }

    if (!result.data || result.data.length === 0) {
      setErrorMessage('エラー:変換するCSVデータがありません');
      return;
    }

    setErrorMessage('');

    if (result.data) {
      const generatedSql = generateSQL(tableName.trim(), result.data, dbType);
      setSqlResult(generatedSql);
      // SQLが生成された後、useEffect でコピーを実行させるためのフラグを立てる
      setIsPendingCopy(true);
    }
  };

  return (
    <>
      <header>
        <div className="table-name-container">
          <label htmlFor="tableNameInput">テーブル名</label>
          <input
            type="text"
            id="tableNameInput"
            placeholder="users_table"
            value={tableName}
            onChange={(e) => setTableName(e.target.value)}
          />
        </div>

        <div className="db-select-container">
          <select id="dbSelect" value={dbType} onChange={(e) => setDbType(e.target.value)}>
            <option value="mysql">MySQL / PostgreSQL / SQLite</option>
            <option value="sqlserver">SQL Server (1000行分割)</option>
            <option value="oracle">Oracle (INSERT ALL &amp; TO_DATE対応)</option>
          </select>
        </div>

        <div className="error-container" style={{ display: errorMessage ? 'flex' : 'none' }}>
          <div className="error-message">{errorMessage}</div>
        </div>
      </header>

      <main>
        <div className="editor-pane">
          <div className="pane-header">CSV / TSV をペーストしてください</div>
          <textarea
            name="csv"
            placeholder={`id, name, created_at\n1, 田中, 2026年2月26日 15時30分\n2, 鈴木, 2026/02/26`}
            value={csvInput}
            onChange={(e) => setCsvInput(e.target.value)}
          ></textarea>
        </div>

        <div className="action-center">
          <button className="btn-convert" onClick={handleConvert}>変換 ⬇</button>
        </div>

        <div className="editor-pane">
          <div className="pane-header">
            INSERT文
            <button className="btn-copy" onClick={() => executeCopy(sqlResult)}>コピー</button>
          </div>
          <textarea
            name="result"
            readOnly
            id="resultTextarea"
            ref={resultTextareaRef}
            placeholder="INSERT INTO users_table (id, name, created_at) VALUES (1, '田中', '2026-02-26 15:30:00');"
            value={sqlResult}
          ></textarea>
        </div>
      </main>
    </>
  );
}

export default App;
      

App.css


        /* src/App.css */
        /* カラーパレット:目に優しく、コントラストの高い非モノクローム */
:root {
    --bg-color: #f0f4f8;
    --panel-bg: #ffffff;
    --text-main: #1e293b;
    --border-color: #94a3b8;
    --primary-color: #2563eb;
    --primary-hover: #1d4ed8;
    --success-color: #059669;
    --success-hover: #047857;
    --error-color: #dc2626;
    --error-bg: #fee2e2;
}
html, body {
  margin: 0;
  padding: 0;
  height: 100vh;
  overflow: hidden; /* 全体のスクロールを無効化 */
}

#root {
  display: flex;
  flex-direction: column;
  height: 100vh;
  width: 100%;
}
/* 画面全体を埋めるレイアウト設定 */
body {
    margin: 0;
    padding: 0;
    height: 100vh;
    display: flex;
    flex-direction: column;
    font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', Meiryo, sans-serif;
    background-color: var(--bg-color);
    color: var(--text-main);
    box-sizing: border-box;
}

*, *::before, *::after {
    box-sizing: inherit;
}

/* 上部ヘッダー部(テーブル名入力とエラー表示) */
header {
    padding: 15px 20px;
    background-color: var(--panel-bg);
    border-bottom: 2px solid var(--border-color);
    display: flex;
    align-items: center;
    box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}

.table-name-container {
    display: flex;
    align-items: center;
    gap: 15px;
}

.table-name-container label {
    font-size: 1.2rem;
    font-weight: bold;
}

.table-name-container input {
    font-size: 1.2rem;
    padding: 8px 12px;
    border: 2px solid var(--border-color);
    border-radius: 6px;
    outline: none;
    width: 300px;
    transition: border-color 0.2s;
}

.table-name-container input:focus {
    border-color: var(--primary-color);
    box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}

/* データベース選択プルダウン用のスタイル */
.db-select-container {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-left: 20px;
}

.db-select-container select {
    font-size: 1.1rem;
    padding: 8px 12px;
    border: 2px solid var(--border-color);
    border-radius: 6px;
    outline: none;
    cursor: pointer;
}

/* エラーメッセージ表示領域 */
.error-container {
    display: none; 
    align-items: center;
    background-color: var(--error-bg);
    color: var(--error-color);
    padding: 8px 16px;
    margin-left: 20px;
    font-weight: bold;
    font-size: 1rem;
    border-radius: 6px;
    border: 1px solid #f87171;
}

/* メインのテキストエリア配置領域 */
main {
    display: flex;
    flex-direction: column;
    flex: 1;
    padding: 20px;
    gap: 15px;
    overflow: hidden;
}

/* 上下のパネル */
.editor-pane {
    flex: 1;
    display: flex;
    flex-direction: column;
    background-color: var(--panel-bg);
    border: 2px solid var(--border-color);
    border-radius: 8px;
    box-shadow: 0 4px 6px rgba(0,0,0,0.05);
    overflow: hidden;
}

.pane-header {
    background-color: #e2e8f0;
    padding: 12px 20px;
    font-size: 1.1rem;
    font-weight: bold;
    border-bottom: 2px solid var(--border-color);
    display: flex;
    justify-content: space-between;
    align-items: center;
}

/* テキストエリア */
textarea {
    flex: 1;
    width: 100%;
    padding: 15px;
    font-size: 1.2rem;
    font-family: 'Consolas', 'Monaco', monospace;
    border: none;
    resize: none;
    outline: none;
    line-height: 1.6;
    color: var(--text-main);
    background-color: #fafaf9;
}

textarea:focus {
    background-color: #ffffff;
    box-shadow: inset 0 0 0 3px var(--primary-color);
}

/* 中央の変換ボタン領域 */
.action-center {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 5px 0;
}

/* ボタンの共通スタイル */
button {
    font-size: 1.2rem;
    font-weight: bold;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.2s;
}

button:active {
    transform: translateY(2px);
}

/* 変換ボタン */
.btn-convert {
    background-color: var(--primary-color);
    padding: 12px 40px;
    box-shadow: 0 4px 6px rgba(37, 99, 235, 0.3);
}

.btn-convert:hover {
    background-color: var(--primary-hover);
}

/* コピーボタン */
.btn-copy {
    background-color: var(--success-color);
    padding: 8px 16px;
    font-size: 1rem;
    box-shadow: 0 4px 6px rgba(5, 150, 105, 0.3);
}

.btn-copy:hover {
    background-color: var(--success-hover);
}
      

JavaScript


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSV to SQL Converter</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
    <style>
        /* カラーパレット:目に優しく、コントラストの高い非モノクローム */
        :root {
            --bg-color: #f0f4f8;
            --panel-bg: #ffffff;
            --text-main: #1e293b;
            --border-color: #94a3b8;
            --primary-color: #2563eb;
            --primary-hover: #1d4ed8;
            --success-color: #059669;
            --success-hover: #047857;
            --error-color: #dc2626;
            --error-bg: #fee2e2;
        }

        /* 画面全体を埋めるレイアウト設定 */
        body {
            margin: 0;
            padding: 0;
            height: 100vh;
            display: flex;
            flex-direction: column;
            font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', Meiryo, sans-serif;
            background-color: var(--bg-color);
            color: var(--text-main);
            box-sizing: border-box;
        }

        *, *::before, *::after {
            box-sizing: inherit;
        }

        /* 上部ヘッダー部(テーブル名入力とエラー表示) */
        header {
            padding: 15px 20px;
            background-color: var(--panel-bg);
            border-bottom: 2px solid var(--border-color);
            display: flex;
            align-items: center;
            box-shadow: 0 2px 4px rgba(0,0,0,0.05);
        }

        .table-name-container {
            display: flex;
            align-items: center;
            gap: 15px;
        }

        .table-name-container label {
            font-size: 1.2rem;
            font-weight: bold;
        }

        .table-name-container input {
            font-size: 1.2rem;
            padding: 8px 12px;
            border: 2px solid var(--border-color);
            border-radius: 6px;
            outline: none;
            width: 300px;
            transition: border-color 0.2s;
        }

        .table-name-container input:focus {
            border-color: var(--primary-color);
            box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
        }

        /* データベース選択プルダウン用のスタイル */
        .db-select-container {
            display: flex;
            align-items: center;
            gap: 10px;
            margin-left: 20px;
        }

        .db-select-container select {
            font-size: 1.1rem;
            padding: 8px 12px;
            border: 2px solid var(--border-color);
            border-radius: 6px;
            outline: none;
            cursor: pointer;
        }

        /* エラーメッセージ表示領域 */
        .error-container {
            display: none; 
            align-items: center;
            background-color: var(--error-bg);
            color: var(--error-color);
            padding: 8px 16px;
            margin-left: 20px;
            font-weight: bold;
            font-size: 1rem;
            border-radius: 6px;
            border: 1px solid #f87171;
        }

        /* メインのテキストエリア配置領域 */
        main {
            display: flex;
            flex-direction: column;
            flex: 1;
            padding: 20px;
            gap: 15px;
            overflow: hidden;
        }

        /* 上下のパネル */
        .editor-pane {
            flex: 1;
            display: flex;
            flex-direction: column;
            background-color: var(--panel-bg);
            border: 2px solid var(--border-color);
            border-radius: 8px;
            box-shadow: 0 4px 6px rgba(0,0,0,0.05);
            overflow: hidden;
        }

        .pane-header {
            background-color: #e2e8f0;
            padding: 12px 20px;
            font-size: 1.1rem;
            font-weight: bold;
            border-bottom: 2px solid var(--border-color);
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        /* テキストエリア */
        textarea {
            flex: 1;
            width: 100%;
            padding: 15px;
            font-size: 1.2rem;
            font-family: 'Consolas', 'Monaco', monospace;
            border: none;
            resize: none;
            outline: none;
            line-height: 1.6;
            color: var(--text-main);
            background-color: #fafaf9;
        }

        textarea:focus {
            background-color: #ffffff;
            box-shadow: inset 0 0 0 3px var(--primary-color);
        }

        /* 中央の変換ボタン領域 */
        .action-center {
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 5px 0;
        }

        /* ボタンの共通スタイル */
        button {
            font-size: 1.2rem;
            font-weight: bold;
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.2s;
        }

        button:active {
            transform: translateY(2px);
        }

        /* 変換ボタン */
        .btn-convert {
            background-color: var(--primary-color);
            padding: 12px 40px;
            box-shadow: 0 4px 6px rgba(37, 99, 235, 0.3);
        }

        .btn-convert:hover {
            background-color: var(--primary-hover);
        }

        /* コピーボタン */
        .btn-copy {
            background-color: var(--success-color);
            padding: 8px 16px;
            font-size: 1rem;
            box-shadow: 0 4px 6px rgba(5, 150, 105, 0.3);
        }

        .btn-copy:hover {
            background-color: var(--success-hover);
        }
    </style>
</head>
<body>

    <header>
        <div class="table-name-container">
            <label for="tableNameInput">テーブル名</label>
            <input type="text" id="tableNameInput" name="table_name" placeholder="users_table">
        </div>

        <div class="db-select-container">
            <select id="dbSelect">
                <option value="mysql">MySQL / PostgreSQL / SQLite</option>
                <option value="sqlserver">SQL Server (1000行分割)</option>
                <option value="oracle">Oracle (INSERT ALL &amp; TO_DATE対応)</option>
            </select>
        </div>

        <div class="error-container" id="errorContainer">
            <div class="error-message">エラー:テーブル名が入力されていません</div>
        </div>
    </header>

    <main>
        <div class="editor-pane">
            <div class="pane-header">CSV / TSV をペーストしてください</div>
            <textarea name="csv" placeholder="id, name, created_at&#10;1, 田中, 2026年2月26日 15時30分&#10;2, 鈴木, 2026/02/26"></textarea>
        </div>

        <div class="action-center">
            <button class="btn-convert" onclick="convert()">変換 ⬇</button>
        </div>

        <div class="editor-pane">
            <div class="pane-header">
                INSERT文
                <button class="btn-copy" onclick="copyResult()">コピー</button>
            </div>
            <textarea name="result" readonly id="resultTextarea" placeholder="INSERT INTO users_table (id, name, created_at) VALUES (1, '田中', '2026-02-26 15:30:00');"></textarea>
        </div>
    </main>
    
    <script>
        function showError(show) {
            const errorContainer = document.getElementById('errorContainer');
            errorContainer.style.display = show ? 'flex' : 'none';
        }

        function convert() {
            const csvString = document.querySelector('textarea[name="csv"]').value;
            const sqlArea = document.querySelector('textarea[name="result"]');
            const tableName = document.getElementById('tableNameInput').value.trim();
            const dbType = document.getElementById('dbSelect').value;

            if (!tableName) {
                showError(true);
                return;
            } else {
                showError(false);
            }

            const parsedData = parseCSV(csvString);
            if (parsedData) {
                const sqlResult = generateSQL(tableName, parsedData, dbType);
                sqlArea.value = sqlResult;
                
                /* --- 修正:ローカルファイルでも確実に動くコピー処理 --- */
                if (sqlResult) {
                    sqlArea.select(); /* テキストエリアを選択状態にする */
                    try {
                        document.execCommand('copy'); /* コピーコマンドを実行 */
                    } catch (err) {
                        console.error('自動コピーに失敗しました', err);
                    }
                    window.getSelection().removeAllRanges(); /* 選択状態を解除して見た目を綺麗に戻す */
                }
            }
        }

        const parseCSV = (csvString) => {
            const result = Papa.parse(csvString, {
                header: true,
                dynamicTyping: true,
                skipEmptyLines: true,
                transformHeader: (header) => header.trim(),
                transform: (value) => typeof value === 'string' ? value.trim() : value
            });

            if (result.errors.length > 0) {
                console.error("パースエラーが発生しました:", result.errors);
                return null;
            }

            return result.data;
        };

        const toHalfWidth = (str) => {
            return str.replace(/[A-Za-z0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
                      .replace(/ /g, ' '); 
        };

        const normalizeDateString = (str) => {
            let normalized = toHalfWidth(str);
            
            normalized = normalized.replace(/(\d{4})年(\d{1,2})月(\d{1,2})日?/g, (match, y, m, d) => {
                return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
            });
            normalized = normalized.replace(/(\d{4})\/(\d{1,2})\/(\d{1,2})/g, (match, y, m, d) => {
                return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
            });
            normalized = normalized.replace(/(\d{1,2})時(\d{1,2})分(?:(\d{1,2})秒?)?/g, (match, h, m, s) => {
                return `${h.padStart(2, '0')}:${m.padStart(2, '0')}:${(s || '00').padStart(2, '0')}`;
            });

            return normalized.trim();
        };

        const isDatePattern = /^\d{4}-\d{2}-\d{2}$/;
        const isTimestampPattern = /^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}(:\d{2})?(?:\.\d+)?$/;

        const generateSQL = (tableName, parsedData, dbType) => {
            if (!parsedData || parsedData.length === 0) return "";

            const columns = Object.keys(parsedData[0]);
            const columnsString = columns.join(', ');

            const valuesArray = parsedData.map(row => {
                const formattedValues = columns.map(col => {
                    const val = row[col];
                    if (val === null || val === undefined || val === '') return 'NULL';
                    if (typeof val === 'number') return val;
                    if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE';

                    if (typeof val === 'string') {
                        const normalizedStr = normalizeDateString(val);

                        if (isTimestampPattern.test(normalizedStr)) {
                            const finalStr = normalizedStr.length <= 16 ? `${normalizedStr}:00` : normalizedStr;
                            if (dbType === 'oracle') {
                                return `TO_TIMESTAMP('${finalStr}', 'YYYY-MM-DD HH24:MI:SS')`;
                            }
                            return `'${finalStr}'`;
                        }

                        if (isDatePattern.test(normalizedStr)) {
                            if (dbType === 'oracle') {
                                return `TO_DATE('${normalizedStr}', 'YYYY-MM-DD')`;
                            }
                            return `'${normalizedStr}'`;
                        }

                        const escapedStr = val.replace(/'/g, "''");
                        return `'${escapedStr}'`;
                    }
                    return `'${String(val)}'`;
                });
                return `(${formattedValues.join(', ')})`;
            });

            if (dbType === 'oracle') {
                let sql = 'INSERT ALL\n';
                valuesArray.forEach(val => {
                    sql += `  INTO ${tableName} (${columnsString}) VALUES ${val}\n`;
                });
                sql += 'SELECT 1 FROM DUAL;';
                return sql;
            } else if (dbType === 'sqlserver') {
                const chunkSize = 1000;
                let sql = '';
                for (let i = 0; i < valuesArray.length; i += chunkSize) {
                    const chunk = valuesArray.slice(i, i + chunkSize);
                    sql += `INSERT INTO ${tableName} (${columnsString})\nVALUES\n  ${chunk.join(',\n  ')};\n\n`;
                }
                return sql.trim();
            } else {
                return `INSERT INTO ${tableName} (${columnsString})\nVALUES\n  ${valuesArray.join(',\n  ')};`;
            }
        };

        function copyResult() {
            const textarea = document.getElementById('resultTextarea');
            if(textarea.value) {
                /* --- 修正:ローカルファイルでも確実に動く手動コピー処理 --- */
                textarea.select();
                try {
                    document.execCommand('copy');
                    alert('全文をコピーしました!');
                } catch (err) {
                    console.error('コピーに失敗しました', err);
                }
                window.getSelection().removeAllRanges();
            }
        }
    </script>
</body>
</html>