0. はじめに(この資料の読み方)
まずは Next.jsの役割(Reactに何が足されるのか)→ フォルダ構成 → ルーティング → サーバー/クライアント境界 の順に理解します。
後半に 業務にありがちな画面(一覧/登録/編集/詳細) を、イメージ(ワイヤーフレーム)と Reactコード例つきで載せています。
1. Next.jsとは(Reactに何を足したもの?)
1-1. 何が嬉しい?(業務視点)
- ページ(URL)単位で作りやすい(業務は画面遷移が多い)
- サーバー側処理も同じプロジェクトで書ける(API/フォーム処理/DBアクセス)
- 画像・フォント・SEOなどが「最初から整っている」
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)をインストールする
LTS(Long Term Support / 安定版) を選びます。
- ブラウザで Node.js 公式サイト を開く
- 「LTS」と表示されているバージョンを選択する
- Windows 用(.msi)インストーラーをダウンロードする
- ダウンロードした .msi を実行し、基本は「Next」を押して進める
2-2-2. インストールできたか確認する
node -v
npm -v
表示されない場合は、ターミナルの再起動または PC 再起動を行ってください。
2-2-2. Git をインストール(推奨)
git --version
2-2-3. 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. デバッグサーバー(開発サーバー)の起動方法
保存すると自動反映(ホットリロード)するので、開発中はこれを使います。
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 時点で固定される
- エラーページの表示内容が異なる
2-8. リリースビルドを配布する方法(現実的な3パターン)
2-8-1. パターンA:Vercelへ配布(最も簡単)
Next.jsのSSRやAPIなども「そのまま」扱いやすいです。
GitHub と連携すると、push のたびに自動で
- 依存関係のインストール
- ビルド
- 公開
Next.js アプリが Vercel 上で実行され続ける という意味です。
- SSR(サーバーでHTMLを生成する処理)
- API(app/api 配下のサーバー処理)
そのため、ログイン画面・管理画面・業務用ページなどもそのまま動かせます。
- 個人開発
- 学習用途
- ポートフォリオ
なお、Vercel の無料プランでは、SQLite3 のような「サーバー内にファイルとして保存するデータベース」や、 サーバー上のファイルを永続的に保存する使い方はできません。 実行中に SQLite ファイルを作成したり、ファイルを書き込むこと自体は一時的には可能ですが、 サーバーの再起動や再デプロイのたびに内容は消えます。 そのため、SQLite3 を業務データの保存先として使ったり、 アップロードしたファイルをサーバー内に保持し続ける用途には向いていません。 Vercel で永続的にデータを扱う場合は、 外部のデータベースサービス(例:クラウドDB)や 外部ストレージサービスを利用する設計が前提になります。
ただし、通常の学習用・検証用・小規模業務システムでは問題になることはほとんどありません。
「GitHub に push するだけで、実行可能な Next.js アプリを無料で公開できる」
ホスティングサービスです。
ログインユーザーごとに内容が変わる画面や、業務システムでは必須になります。
Next.js では app/api 配下にファイルを置くだけで API を作成できます。
Next.js の機能を最も素直に利用できるホスティング環境です。
2-8-2. パターンB:Nodeサーバーとして配布(社内サーバー/VM)
「配布物をzipで渡す」より、Gitで取り込んでビルドする方式が事故が少ないです。
# 本番サーバーで(推奨)
npm ci
npm run build
npm run start
補足:npm ci が必要な理由(初心者が一番混乱する所)
同じ依存関係(ライブラリの組み合わせ)を、毎回まったく同じ状態で再現するためのコマンドです。
業務では「開発PCでは動くのに、本番サーバーで動かない」を防ぐために重要です。
1) node_modules とは何?
中身は数千〜数万ファイルになることもあり、人が手で管理する前提ではありません。
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個だけ渡せばいい」の?
理由:OSやNodeの差で壊れる、サイズが巨大、セキュリティ的にも良くない、など。
6) 本番(リリース先)でも 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:静的サイトとして配布(静的で足りる場合)
ただし「静的で足りる要件」なら、CDN/静的ホスティングに置けて配布が簡単です。
4. フォルダ構成(迷子にならない置き方)
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」はどこに書くのか(ファイルと場所)
代わりに、app フォルダの中にある .tsx ファイルに、 HTML相当の内容を書きます。
ページを書く場所(page.tsx)
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 は “画面”
<div> などを返すと、それが画面として表示されます。「サーバーでHTMLを組み立てる」のではなく、コンポーネントを組み立てる と思うと理解が早いです。
4-2-2. layout.tsx は “枠”
Next.jsでは layout.tsx がその枠です(子ページが {children} に入ります)。
5. ルーティング基礎(page / layout / Link)
このセクションでは「URLがどのように決まり、どうやって画面遷移するのか」を、 静的ページ → 遷移 → 動的ルートの順で説明します。
5-1. 静的ページを追加する(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 による遷移)
<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の一部を変数として扱う)
Next.js では、そのために
[名前] というフォルダ名を使います。
app/
users/
page.tsx // /users
[id]/
page.tsx // /users/123
edit/
page.tsx // /users/123/edit
/users/1/users/abc/users/999/edit
動的ルートの値を page.tsx で受け取る
export default function UserPage(
{ params }: { params: { id: string } }
) {
return <div>ユーザーID: {params.id}</div>;
}
/users/123 にアクセスした場合、params.id には "123" が入ります。
/users/{id} や
Spring の /users/{id} と同じ役割です。
- URL は app フォルダ構成で決まる
- 画面遷移は
Linkを使う [id]フォルダで URL の変数を受け取る
6. レイアウト/共通UI(layout.tsxの考え方)
Next.js(App Router)では、この共通部分を
layout.tsx に集約できます。
6-1. layout.tsx は何をするファイル?(結論)
各ページ(
page.tsx)の内容は {children} の位置に差し込まれます。
6-2. 最小構成:app/layout.tsx(共通枠の基本形)
ポイントは 必ず {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 には何を書く?」(役割分担)
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 特有の最大の難所)
これを理解しないまま書くと、エラーが頻発します。
7-1. まず結論(初心者はここだけ覚える)
7-1. Server Component になる条件(明確な定義)
"use client" が書かれていない場合、
自動的に Server Component として扱われます。
app/配下にある.tsxファイルである- ファイルの先頭に
"use client"が書かれていない
7-2. Server Component とは何か
「完成した HTML をブラウザに返す」ためのコンポーネントです。
- データベースアクセス
- ファイル読み込み
- API 呼び出し
- 画面の生成
-
useState/useEffect(ブラウザ上の状態管理) -
onClickなどのイベント処理 (ユーザー操作への反応) -
window,document(ブラウザ専用オブジェクト)
補足:useState / useEffect とは何か(Reactの基礎)
useState や useEffect は、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 では使えないのか
useState や useEffect は、
「画面が表示された後に、ブラウザで動き続ける処理」です。Server Component はサーバー側で一度だけ実行され、
HTMLを返した時点で役目が終わるため、これらを使うことができません。
useState… 画面の中で変わる値を持つuseEffect… 表示後・変更後に処理をする- どちらも ブラウザで動く仕組み
- そのため Client Component 専用
7-3. Client Component とは何か
ユーザー操作(クリック・入力)に反応する画面は、必ずこちらになります。
- ボタン操作
- 入力フォーム
- 状態管理(useState)
- 画面の動的更新
7-4. Client Component の最小例
"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 にする必要はありません。
表示・データ取得・判定は Server Component
入力・クリックなど操作部分だけを Client Component
と分けて実装するのが基本です。
ボタン、入力欄、一覧行、フォームなどは、それぞれ独立したコンポーネントとして分割できます。
その「コンポーネント単位」で
Server Component か Client Component かを決めます。
- ボタンや入力欄など、ユーザー操作を扱う小さな部品は Client Component
- それらの部品を組み合わせて作られる、画面全体(ページ)は Server Component
Client Component を 「部品として読み込んで配置する側」 になれます。
そのため、画面全体を Client にしなくても、必要な操作だけを Client にできます。
- 一覧データの取得・表示 → Server Component
- 検索入力欄・登録ボタン → Client Component
「画面 = Server Component」
「操作する部品 = Client Component」
という考え方で実装すると、Next.js の設計と自然に噛み合います。
8. データ取得とキャッシュ(fetch の挙動を理解する)
この章では、その データ取得に使う fetch の挙動 について説明します。
8-1. Next.js の fetch は「ただの fetch」ではない
fetch は、
呼び出すたびに毎回データを取得します。
fetch には、
取得結果をキャッシュする仕組み が最初から組み込まれています。
8-2. 初心者が混乱しやすい現象
- データを更新したのに、画面の表示が変わらない
- APIは呼ばれているのに、古い内容が表示される
- 再読み込みすると直ることがある
8-3. なぜ Next.js はキャッシュするのか
- 同じデータを何度も取りに行かない
- サーバー負荷を下げる
- 画面表示を速くする
8-4. 業務システムで重要になる理由
- 登録・更新後は必ず最新データを表示したい
- 「反映されない」は致命的な不具合に見える
- キャッシュする 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 が担うからです。サーバー側(Server Component)は、処理が成功したときにだけ HTML を最後まで生成できます。
しかし例外が発生すると、そのページの HTML は完成しないため、サーバーは「正常な画面」を返せません。
そこで、クライアント側の Next.js(ブラウザ上で動いているJavaScript)が
「この画面は生成に失敗した」と判断し、代わりに error.tsx を表示します。
つまり、
error.tsx は ブラウザ上で描画される必要があるため、ファイル先頭に
"use client" を書いて Client Component にする必要があります。
3) 登録ボタンを押したときの流れ(全体像)
- 画面全体:
page.tsx(Server)…枠と表示を作る - 操作部品:
UserForm.tsx(Client)…入力とボタン、クリック処理 - 登録処理:
actions.ts(Server)…DB更新・業務ルール・例外
- 業務エラー(入力ミス・重複など)→ 画面内にメッセージ表示(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 が出るまで(実際の流れ)
失敗の流れは次の通りです。
- ユーザーがブラウザで「登録」ボタンを押す(
UserForm.tsxが動く) UserForm.tsxがcreateUserAction()を呼ぶ(サーバー側処理を依頼)- サーバー側(
actions.ts)で処理を実行中に throw が発生する(想定外エラー) - サーバーは「正常な結果」を返せず、エラー状態(500相当)になる
- クライアント側の Next.js が「このルートの描画に失敗した」と判断する
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>;
}
業務用に分かりやすいメッセージを出せるのが特徴です。
loading.tsx:待ち時間を可視化するerror.tsx:例外時に真っ白にしないnot-found.tsx:存在しないデータを正しく伝える
補足:Next.js(App Router)の標準関数一覧(画面制御系)
画面の状態(404 / リダイレクト / 再描画など)を制御するための標準関数 が用意されています。
これらは自分で実装するものではなく、Next.js が解釈します。
1. notFound()
対応する
not-found.tsx が自動的に表示されます。
import { notFound } from "next/navigation";
if (!data) {
notFound();
}
- 想定内の「存在しない」状態
- 業務的には「該当データなし」
2. redirect()
import { redirect } from "next/navigation";
redirect("/login");
- 未ログイン時にログイン画面へ
- 登録完了後に一覧へ戻す
3. permanentRedirect()
SEOやURL変更時に使用します。
import { permanentRedirect } from "next/navigation";
permanentRedirect("/new-path");
4. useRouter()
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
router.push("/users");
- ボタンクリックで遷移
- 入力完了後の画面遷移
5. usePathname()
import { usePathname } from "next/navigation";
const pathname = usePathname();
6. useSearchParams()
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()
import { headers } from "next/headers";
const headersList = headers();
const userAgent = headersList.get("user-agent");
9. cookies()
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(フォーム送信を素直にする)
- API Route を別途作らなくてよい
- fetch / JSON / status code を意識しなくてよい
- 業務フォームの流れがシンプルになる
10-1. 従来のやり方(Server Actions なし)
// クライアント側
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 を使ったやり方
// 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. なぜ「フォームを素直にする」のか
- 登録画面
- 更新画面
- 検索フォーム
10-4. 何でも Server Actions にすべきか?
- 単純な登録・更新 → Server Actions 向き
- 複雑な API / 外部連携 → API Route 向き
- SPA的な即時反応 → Client + fetch
return は、
画面(HTML)を返すためのものではありません。
return の使い道は次の通りです:
- 何も返さない → 処理だけ行う
- オブジェクトを返す → 業務エラーや結果を画面に渡す
redirect()→ 別画面へ遷移notFound()→ 404画面へthrow Error→ error.tsx を表示
10-x. Server Actions の return は「画面(HTML)を返す」のではない
return が省略されていると分かりにくい理由は、Server Actions の return は「画面(HTML)を返す return」ではないからです。
ここでは return のパターンを、業務で使う形に寄せて説明します。
- (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 が必要になる具体状況
逆に、登録・更新のように ユーザーが結果を知る必要がある処理では不向きです。
状況1:入力中の「自動保存(下書き保存)」
定期的にサーバーへ保存したいケースです。
この場合、毎回「保存しました」と出すと邪魔なので、画面表示は変えずに裏で保存します。
状況2:アクセスログ/操作ログを記録する(監査ログ)
例:顧客情報閲覧、出荷ステータス変更、在庫調整など。
ログを残すこと自体が目的なので、ユーザーへの表示は不要です。
状況3:メール送信/通知キュー登録(非同期処理)
ユーザーの画面としては「登録完了」として進みたいが、
通知は裏で処理してよい、というケースがあります。
通知だけを (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 };
}
// 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相当)
対応する
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)
これが起きると、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) 何も返さない(処理だけする)
- ① フォームが送信される
- ② サーバー側で処理が実行される
- ③ 画面は 送信前と同じ状態のまま
action 先が何も返さない場合と同じ挙動です。
// app/users/actions.ts
"use server";
export async function createUser(formData: FormData) {
const name = String(formData.get("name") ?? "");
// DB登録などの処理だけ行う
// return は書かない
}
- 成功したのか失敗したのか分からない
- メッセージも表示されない
- 画面遷移もしない
- バッチ処理
- ログ記録
- 副作用だけが目的の処理
(B) 結果オブジェクトを返す(成功 / 業務エラー)
// 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 };
}
// 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) は「結果を返して画面内で制御」
11. Route Handlers(外部公開API・Webhook)
ブラウザに表示されるページではなく、JSON を返す/データを保存する/外部から呼ばれる用途に使います。
11-1. Route Handlers とは何か(結論)
自動的に API の URL が作られます。
ルーティング定義を書く必要はありません。
11-2. フォルダ構成と URL の対応
app/
api/
users/
route.ts
11-3. page.tsx との違い(重要)
| 項目 | page.tsx | route.ts |
|---|---|---|
| 役割 | 画面(HTML) | API(サーバー処理) |
| ブラウザ表示 | される | されない |
| 返すもの | HTML | JSON / ステータス |
| 用途 | 一覧・詳細・入力画面 | 取得・登録・更新・Webhook |
11-4. 最小の Route Handler 例(GET)
// app/api/users/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json([
{ id: 1, name: "山田" },
{ id: 2, name: "佐藤" }
]);
}
/api/users にアクセスすると、JSON データが返ります。
11-5. データを受け取る(POST / 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 });
}
window や document など、ブラウザ専用 API は使えません。
11-6. 業務システムでの典型的な使い方
- 一覧画面 →
GET /api/xxx - 登録フォーム →
POST /api/xxx - 更新処理 →
PUT /api/xxx - 外部サービス通知 → Webhook(POST)
11-7. route.ts の名前は固定なのか?
route.ts(または route.js)で固定です。
「このフォルダは API 用だ」と認識させる合図として
route.ts という名前が使われます。
users.ts や api.ts など、別の名前では Route Handler として認識されません。
- フォルダ名 → URL を決める
route.ts→ 「API ですよ」という宣言
11-8. なぜ GET / POST しか書いていないのか?
GET と POST しか出てこなかったのは、「例として最小限を見せているだけ」です。
使えるメソッド一覧
// 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() {}
自動的に実行されます。
11-9. なぜ「関数名=HTTPメソッド」なのか
「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→ 明示的な追加APIDELETE /api/users/123→ ID指定削除
11-11. 業務システム的な理解(超重要)
- URL → フォルダ構成
- 処理分岐 → HTTPメソッド
@GetMapping@PostMapping[HttpGet]
route.tsは名前固定- GET / POST しか「できない」わけではない
- HTTPメソッド分の関数を書けば全部使える
11-X. route.ts で GET / POST / PUT / DELETE をすべて扱う実務例
一覧取得・新規登録・更新・削除を、
同じ
/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)
設計上のポイント(重要)
- URL は増やさず、意味は HTTP メソッドで分ける
- 入力チェックは API 側で必ず行う
- ステータスコードを正しく返す
- 「何をした API か」がコメントだけで分かる
- DB 処理を try/catch で囲む
- 認証・認可チェックを追加
- ログ出力を行う
route.ts 1ファイルで、実務 CRUD API は十分に書ける
URL はフォルダ、処理は HTTP メソッド
12. Middleware / Edge(リクエストの入口で判定する)
ページや route.ts が動く前に、
通していいか/書き換えるか/別の場所へ送るかを判断します。
12-1. Middleware は何をするための仕組みか
「このリクエストを、先に進ませてよいか?」を入口で決めることです。
12-2. 典型的な用途(業務でよくある)
- ログインしていないユーザーを
/loginにリダイレクト - 管理者以外を
/adminに入れない - API への不正アクセスを事前に遮断
- リクエストに共通ヘッダーを付与
12-3. 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 とは何か(混同しやすい)
Middleware が「どこで実行されるか」を表す言葉です。
- 通常の Node.js サーバー
- または Edge(CDNに近い場所)
- 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. 環境変数と秘密情報(どこで定義し、どこで使うか)
実務では .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" });
}
これらはブラウザに送られません。
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();
}
秘密情報の参照は可能です。
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>
);
}
秘密情報を直接参照できます。
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>
);
}
秘密情報を参照しようとすると ビルドエラーになります。
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)
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 });
}
外部から直接叩かれても、画面のチェックを回避されても、
必ず守れます(業務ではここが重要です)。
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に POSTContent-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
}
この 3 点は必ずセットで理解します。
14-Y-5. なぜ form 送信(application/x-www-form-urlencoded)ではないのか
- 構造化データ
- 配列・ネスト
- 将来の拡張
req.json()は「JSON で送られてくる前提」- Client は
fetch + JSON.stringifyを使う - Content-Type を必ず指定する
- この形が Next.js 業務アプリの基本
14-Z. エラーは「各項目の下」に出すのは一般的か?
ただし実務では、フォーム全体のエラー(上部)も併用することが多いです。
14-Z-1. 実務での基本ルール(表示位置の使い分け)
| エラーの種類 | 例 | 表示位置 |
|---|---|---|
| 項目に紐づくエラー | 必須 / 形式 / 範囲 | 各項目の下 |
| 項目に紐づかないエラー | DB重複 / 権限 / サーバー障害 | フォーム上部(または下部) |
14-Z-2. なぜ「各項目の下」が一般的なのか
そのため 「どの項目が悪いか」を、項目のすぐ近くで伝える(=各項目の下)がよく使われます。
14-Z-3. React 実装の典型(項目別 + 全体)
例:必須や形式は項目の下、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)が返す形(項目別エラーの例)
errors を返すと扱いやすいです。
{
"message": "validation error",
"errors": {
"name": "name は必須です",
"email": "email の形式が不正です"
}
}
- 各項目の下にエラーを出すのは一般的
- 実務では フォーム全体エラーも併用する
- API は
errorsを返すと項目別表示が簡単
14-X. DB の duplicate(重複)も route.ts(API)で処理するのか?
さらに実務では、DB の UNIQUE 制約 + API 側のエラーハンドリングの両方で守ります。
14-X-1. なぜ route.ts でやるのか
- Client 側のチェックは回避できる(信用できない)
- Middleware は入口制御向けで、入力の意味を判定しない
- DB と直接向き合う最後の層が route.ts
14-X-2. 正しい守り方(実務の結論)
- DB に UNIQUE 制約(最重要・最終防衛線)
- 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
);
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. 「事前チェックだけ」ではダメな理由
// ❌ よくある失敗例(レースコンディションで破綻)
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版)
理由は「秘密情報(接続情報)が漏れる」「不正呼び出しを防げない」「設計が破綻しやすい」ためです。
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接続情報など(秘密)
「DBは server 層だけが知る」ルールにします。
15-2. 生SQL版(SQLite例:実務で使える最小CRUD)
例として 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は server/db に閉じ込める(画面に持ち込まない)
- Route Handler は「入力検証」「HTTPの返し方」を担当
- duplicate は DB の UNIQUE + API 側の 409 で守る
15-3. ORM版(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 });
}
}
- モデル(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の混合(現実的な“よくある”構成)
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(集計/特殊処理)だけを切り出す
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でやりにくい処理だけを書く(集計/検索/バルク)
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; // 変更行数
}
乱用すると、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の返却に集中
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 での認可チェック例(最終防衛線)
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" });
}
API は常に単体で安全である必要があります。
16-6. 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/INSERT → JSONで戻して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)
これにより、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-sqlite3DBファイル:
./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)
ここでは 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)
ここでは簡略化として 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)
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行で)
APIは直叩きされる前提なので route.ts でも必ず認可します(ここが一貫性の核)。
17. ファイルアップロード / ダウンロード(React画面 → API → 保存 → 取得)
ここではまず 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)がファイルを返す
↓
ブラウザがダウンロードする(または新規タブで表示)
ファイル送信は通常 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管理しない)
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)
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)
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
成功したら 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等)
画像・動画・大量添付・高負荷が見えてきたら(B)に移行、が現実的です。
18. 画像 / フォント / 静的アセット(“何をどう書けばよいか”)
つまり「どこに置く」「どう書く」「何を守る」を、コード付きで示します。
18-0. まず結論(迷ったらこれ)
- アプリ内の画像(ロゴ等) →
/publicに置き、next/imageで表示する - 外部画像(CDN等) → 許可ドメインを設定し、
next/imageで表示する - フォント →
next/fontを使い、layout.tsx で一括適用する
18-1. 静的アセットの置き場所(/public が基本)
例:
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
業務では「どこにあるか」「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>
);
}
高さが無いと「表示されない」「0pxになる」事故が起きます。
18-2-3. 外部画像を使う場合(許可設定が必要)
// 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にどう適用するか:迷わない運用ルール)
つまり どこに書く / どこで読み込む / どれを使う を決め、コードで固定します。
19-1. 結論(迷ったらこのルール)
- 全体共通(リセット・色・余白・タイポ) →
app/globals.css - 部品単位(ボタン・カード等) → CSS Modules(
*.module.css) - その場だけ(1回しか使わない) →
style={{...}}は最小限 - クラス名衝突を避けたい → CSS Modules を優先
どこが効いているか分からなくなり、修正コストが跳ねます。
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回だけ読み込む)
ここに「全ページ共通」だけを書き、コンポーネント固有の見た目は入れすぎません。
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; }
「特定コンポーネントの見た目」まで入れると、後で地獄になります。
19-4. 部品CSS: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)
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={{...}} を使う場面(使いすぎ禁止)
「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>に反映”する)
重要なのは次の2点です:
(1)metadata = Next.js公式の<head>生成ルート
(2)JSON-LD = <script type="application/ld+json"> を各ページで出す
19-1. metadata とは何か(タイトル/説明/OGPを<head>へ出す仕組み)
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
各ページは、ここに対して「上書き/追加」できます。
// 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(例)
これが「各ページの<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>;
}
openGraph.images は layout.tsx の共通だけでもOKです。
19-2. JSON-LD とは何か(検索エンジンに“構造”で伝える)
Next.js では metadata とは別で、<script type="application/ld+json"> を出力します。
ポートフォリオなら、よく使うのは:
- Person(自分)
- Organization(屋号/チームがあれば)
- WebSite(サイト全体)
- CreativeWork / SoftwareApplication(制作物・ツール)
19-2-1. JSON-LD を各ページの<head>に出す(例:app/about/page.tsx)
<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>
);
}
dangerouslySetInnerHTML で JSON文字列を埋め込みます。これはJSON-LDでは定番です。
19-3. “各ページ共通” の JSON-LD を layout.tsx に入れる例(WebSite)
ページ固有(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 を自分で定義するのか
そのため、次のような書き方はできません。
// ❌ 使えない例
window.location.origin
変数
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 はどこに置くのか
my-app/
public/
ogp.png ← このファイル
app/
layout.tsx
| 実体ファイル | ブラウザから見えるURL |
|---|---|
| public/ogp.png | https://portfolio-ijp.pages.dev/ogp.png |
4. 環境変数で定義する方法(補足)
# .env.local
NEXT_PUBLIC_SITE_URL=https://portfolio-ijp.pages.dev
// app/layout.tsx
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!;
NEXT_PUBLIC_ を付けています。
5. まとめ(重要)
siteUrlは 自分で定義する変数- Next.js の予約語・自動変数ではない
- OGP / canonical / JSON-LD 用の 基準URL
- OGP画像の実体は
public/ogp.png - metadata では 絶対URLを使う
20. ロギング / 監視(最初から“業務用”で入れる)
業務では 最初から「コンソール+ファイルに出る logger」を入れておくと、
障害対応・調査・引き継ぎが圧倒的に楽になります。
ここでは log4j 的な使い方ができる最小構成を示します。
20-1. 結論:Next.js での現実解
- Node.js 実行環境(Route Handler / server層)で使う
- console も file も両方に出す
- ログレベル(INFO / WARN / ERROR)を分ける
- logger は 1ファイルに集約して import する
- winston(最も定番)
- pino(高速・JSONログ向き)
20-2. 導入(winston を入れる)
npm install winston
20-3. logger を1箇所に定義する(log4j的な中心)
必ずこの 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 での使い方(最重要)
// 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層での使い方(例外だけ)
「失敗したときだけ」出すのが業務向きです。
// 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 切り離し
- 「落ちてはいけない処理」だけを守る
テストが壊れやすくなり、結局回らなくなります。
21-2. テスト対象の切り分け(実務目線)
| 層 | テストするか | 理由 |
|---|---|---|
| React UI | △(後回し) | 変更が多く壊れやすい |
| Route Handler | ◯ | 入力と戻り値が安定 |
| 業務ロジック | ◎ | 最重要・壊れると事故 |
| DBアクセス | △ | 最初はモックで代替 |
21-3. テスト環境の準備(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を切り離す)
テストが簡単になります。
// 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("太郎");
});
});
21-6. Route Handler を軽くテストする考え方
- 入力を読む
- サービスを呼ぶ
- レスポンスを返す
// app/api/users/route.ts(イメージ)
POST:
1. JSONを読む
2. validate
3. serviceを呼ぶ
4. 結果を返す
21-7. この章の結論(実務ルール)
- 最初は バリデーション + 業務ロジックだけテスト
- UIテストは後回しでよい
- DBは切り離してテスト
- 「壊れると困る所」だけ守る
補足:Next.js では「どうやってモックに入れ替えるのか」
テスト時に依存を差し替えるという、Node.js / TypeScript の基本手法を使います。
ここでは 実務で破綻しない 3 つの方法を、難易度順に説明します。
結論(先に全体像)
- 依存性注入(DI):関数引数で差し替える(最推奨)
- モジュールモック:import を丸ごと置き換える
- 環境変数で分岐:本番/テストで実装を切り替える
server 側は普通の Node.jsとして扱うのが正解です。
① 依存性注入(DI)で入れ替える(最も安全・実務向き)
DB・外部API・メール送信など、副作用のあるものは必ず DIにします。
Next.jsでのDI(依存性注入)入門:0から「なぜ必要で、どう書くか」
Next.jsのコードをどう切ると、テストや運用が楽になるのかという設計の話です。
Next.jsだから特別なDI機構があるわけではなく、サーバー側は普通のNode.jsとして考えます。
1. DIとは何か(1文)
それを関数の引数・コンストラクタ引数として 外から注入(Inject)します。
2. なぜ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の考え方(役割分担を固定する)
① 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はここに閉じる
4. DIの最小形(関数引数で渡す:Next.jsで一番よく使う)
まずは 関数に 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 });
}
}
5. 「差し替え」が何を意味するか(モックの正体)
本物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が効く“具体例”(副作用=外に逃がす)
- DB
- 外部API(REST)
- メール送信
- ファイル書き込み
- 時刻(now)
- 乱数(token生成)
例:時刻を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 });
}
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");
});
});
② モジュールモック(import を丸ごと差し替える)
ただし構造が複雑になるため、乱用は非推奨です。
// 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();
});
});
大規模になると破綻しやすいのが弱点です。
③ 環境変数で実装を切り替える(最終手段)
ロジック内で分岐しすぎると読めなくなるので注意。
// 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側
Next.js の強みを捨てているのと同じです。
22-2. RSC(React Server Components)とは何か
Next.js(App Router)では、何も書かなければ Server Componentになります。
| 種類 | どこで実行 | ブラウザにJS |
|---|---|---|
| Server Component | サーバー | 送られない |
| Client Component | ブラウザ | 送られる |
Client Component = 画面操作のための部品
22-3. 何が「遅くなる原因」か
悪い例:最初から 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で取得・描画
23. デプロイ判断(SSRが必要か?静的で足りるか?)
「この画面は、サーバーが無いと作れないか?」
それとも
「HTMLを置くだけで成立するか?」
Next.js はどちらも選べるため、
画面ごとに最適なデプロイ方法を選ぶことが重要になります。
1. 静的サイト(SSG / Export)で足りるケース
ビルド時に、あらかじめHTMLを生成しておく方式です。
アクセスが来たときは、作成済みのHTMLファイルをそのまま返すだけなので高速です。
特徴:
- ビルド時にHTMLが確定する
- リクエスト時にサーバー処理は行われない
- 誰が見ても同じ内容のページ向き
Next.js(App Router)では、
特別な設定をしなくても、条件を満たせば自動で「ビルド時HTML生成」になります。
ポイントは次の3つです。
"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 は:
- 各
page.tsxを解析する - 「静的でいけるか?」を判断する
- 可能なら HTML をその場で生成する
すでに完成したHTMLファイルが出力されます。
- 特別なAPIを呼ばなくてもよい
- 普通に
page.tsxを書くだけでよい - 「毎回変わらないページ」なら自動で静的になる
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)が必要なケース
サーバー処理が必要になります。
- ログインしているユーザーごとに表示が変わる
- URLは同じだが、中身は人によって違う
- DBから最新データを読む必要がある
- 権限チェックが必要
// 例:サーバーが必要な画面
- マイページ
- 管理画面
- 受注一覧
- 個人設定画面
- SSR(Server Side Rendering)
- RSC(Server Component)
- API / DB 接続
「I/Oを一切行わなければ自動的に Export になる」わけではありません。
Export は Next.js に対して明示的に「静的書き出しをする」と指定します。
Next.js には、ビルド結果として次の2系統があります。
- Node.js サーバーとして動かす(SSR / RSC を含む)
- 静的ファイルだけを書き出す(Export)
「このプロジェクトはサーバーを持たない」と
ビルド時に宣言する方式です。
App Router の場合、
next.config.js に次を設定します。
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export"
};
module.exports = nextConfig;
npm run build時に- サーバーを前提とした機能を禁止し
- 純粋な静的ファイルだけを出力
Export では、
「I/Oをしていないか」ではなく
「実行時にサーバーが必要か」で判断されます。
- ビルド時に確定するページ(SSG)
- 固定データのみを使うページ
- Client Component だけで完結する画面
- SSR(リクエストごとにHTMLを作る)
- Route Handler(app/api/**)
- DBアクセス
- cookies / headers / 認証
たとえば、次のコードは I/O をしていませんが、
// app/page.tsx
export default function Page() {
return <h1>Hello</h1>;
}
ビルド結果は 「Node.js サーバー前提」になります。
- コードの内容だけでは Export にはならない
- ビルド設定で明示する必要がある
Export を指定すると、Next.js はビルド中に:
- 各ページを解析
- サーバー機能を使っていないか検査
- 使っていたら ビルドエラー
- ❌「I/Oをしなければ自動で Export」ではない
- ✅
output: "export"を 明示的に指定する - Export は「静的ファイルとして配る」という宣言
- サーバーが必要な機能は最初から使えない
3. Next.js の強み:混在できる
同じプロジェクト内で「静的」と「サーバー」を混在できる点です。
// 同一プロジェクト内の例
/app/page.tsx → 静的(SSG)
/app/about/page.tsx → 静的(SSG)
/app/login/page.tsx → サーバー(SSR)
/app/users/page.tsx → サーバー(RSC + DB)
画面ごとに最適解を選ぶのが実務です。
4. 判断基準(これだけ見ればOK)
- この画面は、誰が見ても同じ内容か?
- ログインしていない人にも見せるか?
- DBの最新状態が必要か?
1つでも NO → サーバーが必要
5. 業務アプリでの現実的な結論
- ログイン
- 個人データ
- 権限
完全な静的構成だけで完結することは少ないです。
- ログイン前の説明ページ
- ヘルプ
- 利用規約
高速・低コスト・シンプルに保てます。
「サーバーが必要な画面だけ SSR / RSC」
「それ以外は静的」
これが Next.js での最も現実的なデプロイ判断です。
24. Vercelデプロイ(Next.jsを一番そのまま動かす方法)
「Next.js を、設定で悩まず・壊さず・最短で動かしたいなら Vercel」
Vercel は Next.js の開発元が提供しているため、
SSR / RSC / API / 画像最適化などを“何も考えず”使えます。
1. Vercel とは何か(何をしてくれるのか)
ビルド・デプロイ・サーバー起動まで自動でやってくれるサービスです。
- Node.js サーバーの用意
- SSR / RSC の設定
- API ルートの公開
- HTTPS 証明書
- ビルド設定の調整
2. デプロイまでの最短手順
- Next.js プロジェクトを GitHub に push
- Vercel にログイン
- 「Import Project」でリポジトリを選ぶ
- Deploy ボタンを押す
Next.js プロジェクトだと自動認識されます。
3. Vercel で「そのまま使える」機能
Next.js の機能制限を気にしなくてよい点です。
- SSR(Server Side Rendering)
- RSC(Server Components)
- Route Handler(app/api/**)
- next/image
- next/font
- middleware
- 環境変数(管理画面から設定)
4. 静的サイトとの違い
サーバーが必要な画面と、静的な画面を混在させられます。
// 同じプロジェクト内
/app/page.tsx → 静的
/app/about/page.tsx → 静的
/app/login/page.tsx → SSR
/app/users/page.tsx → RSC + DB
/app/api/users/route.ts → API
という進め方ができます。
5. 注意点(ここだけ知っておく)
- 無料枠には実行時間・回数の制限がある
- DB は別サービス(RDS / Supabase 等)が必要
- ローカルファイル永続保存はできない
6. どういう場合に Vercel を選ぶか
- Next.js を学習・検証したい
- ポートフォリオを作りたい
- SSR / API をすぐ使いたい
- インフラ設定に時間をかけたくない
Vercel は「Next.js を正しく・速く・安全に動かすための基準環境」。
迷ったら、まずここに置くのが最短ルートです。
📌 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 デプロイ(自前サーバーで動かす)
社内サーバー、VPS、オンプレ環境などで運用するケースを想定します。
25-1. Node.js での基本的なビルドと起動
まずは一番シンプルな形を理解します。
# ビルド(HTML生成・最適化)
npm run build
# 本番起動(Node.js サーバーとして起動)
npm run start
- SSR / RSC / API / middleware がすべて使える
- Node.js が常駐プロセスとして動く
- PM2 / systemd などでプロセス管理するのが一般的
25-2. 自前サーバー運用で必要になるもの
- Node.js のバージョン管理
- プロセスの自動再起動
- HTTPS(Nginx / Apache / 証明書)
- ログの保存・ローテーション
- OSアップデート対応
25-3. Docker を使う理由(なぜ業務で強いか)
「このアプリは、この環境で動く」と丸ごと固定できることです。
- Node.js のバージョン差で動かない
- ローカルでは動くが、本番で動かない
- 人によって環境が違う
- Node.js バージョンを固定できる
- 依存関係をすべてイメージに含められる
- 本番・検証・ローカルの差が消える
25-4. Docker での基本的な考え方
【Dockerの役割】
- OS の差を吸収
- Node.js のバージョン固定
- npm install / build / start を再現可能にする
- アプリ用コンテナ(Next.js)
- DB コンテナ(PostgreSQL など)
- リバースプロキシ(Nginx)
25-5. どういう場合に自前サーバーを選ぶか
- 社内ネットワーク内だけで使う
- 外部クラウドを使えない制約がある
- 長時間処理・常駐処理が必要
- インフラを自分で管理できる
👉 手軽さ・速さを取るなら Vercel
という住み分けになります。
(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 がインストールされている
- Docker Desktop は WSL2 backend を使用
- Linux コンテナモードになっている
スタートメニューから Docker Desktop を起動し、
画面左下が 「Docker Desktop is running」 になるのを待ちます。
管理者権限は不要です。
通常の PowerShell / Windows Terminal で問題ありません。
cd C:\work\my-next-app
- Dockerfile
- package.json
- app / public フォルダ
docker build -t my-next-app .
数分かかる場合があります(正常です)。
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
「ホストのプロジェクトルート配下を、
コンテナ内の
/app に丸ごとコピーする」
理由は実務的です。
- npm / next のコマンドはプロジェクトルート前提
- package.json が常に見える
- パスがブレない
例えば:
WORKDIR /srv/app
にすると、プロジェクトは
/srv/app 配下に展開されます。どこでも動きますが、
慣例として
/app が多いだけです。
- WORKDIR が「プロジェクトルートの置き場所」を決める
- この Dockerfile では
/appをルートにしている - ホスト側の構成と感覚を揃えるための設計
26. Cloudflare での公開(Next.js は「そのまま」は動かない)
Cloudflare は「何でも動く場所」ではなく、
Next.js の使い方によって可・不可がはっきり分かれるという点です。
26-1. Cloudflare は何を提供しているのか
- Cloudflare Pages(静的サイト + Edge Functions)
- Cloudflare Workers(Edge 実行環境)
26-2. そのまま使えるケース(問題なし)
- SSG(静的生成)のみ
output: "export"を使った静的書き出し- React SPA としての利用
- 外部 API(別サーバー)を呼ぶだけ
26-3. 制限が出るケース(要注意)
- Node.js 常駐サーバー前提の SSR
- Route Handler(app/api/**)
- DB に直接つなぐ処理
- fs(ファイルシステム)アクセス
Cloudflare は Edge 実行(短時間・軽量)を前提にしているためです。
26-4. 「Cloudflare で SSR したい」場合
通常の Next.js SSR とは別物と考えてください。
- Workers 用にビルドされた Next.js(制約あり)
- API / DB は別サーバーに逃がす
- Cloudflare はフロント配信専用に割り切る
26-5. 実務向けの判断基準
| やりたいこと | おすすめ |
|---|---|
| ポートフォリオ / 静的LP | Cloudflare Pages |
| SPA + 外部API | Cloudflare Pages |
| SSR / DB / 認証 | Vercel / Nodeサーバー |
- Cloudflare は「高速配信」に特化
- Next.js の全機能は前提にしていない
- 静的で割り切れるなら最強
- 業務系・SSR中心なら別の選択肢を取る
27. よくある詰まり集(復帰チェック)
27-1. 画面が真っ白
27-2. useState が使えない
27-3. fetch が更新されない
27-4. 環境変数が読めない
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コード)
Next.jsに移植する場合でも、まずは UIの形(一覧/登録/編集/詳細) と エラーの扱い を理解すると速いです。
30-1. 画面遷移(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. 画面イメージ
| ID | 氏名 | メール | 権限 | 操作 |
|---|---|---|---|---|
| u_xxxx | User 1 | user1@example.com | staff | |
| u_yyyy | User 2 | user2@example.com | admin |
30-2-2. Reactコード(一覧:ページ番号を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-17 | A商事 | 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 | 商品 1 | 16 | 2026-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 | 出荷済 | yamato | TRK-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),
});
サンプル:ログイン時に UserSetting(JSON) を読み込み、以後「全処理」から参照できるようにする(React → API → Service → Repo → DB → DTO/Entity → React)
画面イメージ(HTTPリクエストを出す画面/リダイレクト後の画面)
仕様のポイント(このサンプルが満たすこと)
- UserSetting テーブル:
user_idとsettings(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)
だから「どこからでも参照」を実現するには、リクエスト単位のコンテキストが必要です。
ここでは
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 を保存する(ログイン後も維持)
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 に復元)
ただし 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 での使い方
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>
);
}
使用説明(このサンプルの「流れ」)
- Reactログイン画面が
POST /api/auth/loginを送る(JSON) - Route Handler(Controller)が AuthService を呼ぶ
- AuthService が user_settings を読み、無ければデフォルトを採用
- 採用した settings JSON を session(cookie) に保存
- 以後のリクエスト(dashboard / API)では、最初に
withUserSettingsStore()を通す - すると 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の完成形」に対応させると)
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向けに読み替え)
- View(Blade) → React(app/register/page.tsx)
- Controller → Route Handler(app/api/users/register/route.ts)
- Eloquent → Repository(SQL/ORMどちらでも可)(このサンプルは SQLite + SQL)
- DTO(Blade⇔Controller) → DTO(React⇔API JSON)
- Lang(i18n) → 辞書(ja/en)をクライアント・サーバで共通利用
- Config → config/registration.ts(成功モーダルの自動消滅ON/OFFと秒数)
画面仕様
登録成功時
- 成功モーダル(ダイアログ)表示
- 数秒後に自動で消える(ON/OFF・秒数は設定ファイルで制御)
- (このサンプルでは)モーダルが消えたら /login にリダイレクト
- 該当入力欄の近くに赤文字でメッセージ
- email 重複は email 欄のそばに「すでに登録済みのメールアドレスです」
- 日本語/英語切り替え(このサンプルは
?lang=ja/?lang=en)
バリデーション仕様(業務向け:サーバ側を正、クライアント側は補助)
- 必須(空文字NG)
- 許可:英小文字 + 数字のみ(正規表現:
^[a-z0-9]+$) - 例:taro01 OK / Taro NG / taro_01 NG
- 必須(空文字NG)
- email形式
- 重複NG(論理削除も含めて重複扱い=登録不可)
- 必須(空文字NG)
- 8文字以上
- 許可:英数字 +
_ , - , @ , # , $ , % , & - 正規表現例:
^[A-Za-z0-9_\\-@#$%&]+$
登録処理仕様
- users に INSERT
- password はハッシュ化して
password_hashに保存(生パスワード保存禁止) is_active = true、role = 'user'をデフォルトdeleted_at != nullのユーザーも重複扱い(事故防止)
例外処理仕様(業務レベル)
- DB例外などの詳細はサーバ側ログに出す
- 画面には安全なメッセージ(内部情報を出さない)
- email重複は「業務例外」として扱い、email欄にピンポイントで返す
注意点(Next.jsでの現実)
- クライアントのバリデーションは改ざん可能なので、サーバ側が必須
- 同時登録(レース)対策として、最終的には DBのUNIQUE制約が必要(このサンプルも入れます)
- 「重複チェック→INSERT」は競合し得るので、INSERT失敗時のハンドリングも入れます
- i18n は本来 next-intl 等の導入が一般的だが、ここでは外部依存を増やさず 辞書方式で実装します
画面イメージ(SVG:外部ファイルなし)
コード(この機能に必要な全コード)
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"
}
}
実務サンプル:ユーザー一覧(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. 注意点
Vercel などのサーバレス環境では、サーバー内ファイル(SQLite)を永続的に保持できないことがあります。
本サンプルは「設計例」として SQLite を使いますが、運用では外部DBを検討してください。
app/api/**/route.ts に置くと作れます(ページとは役割が違う)。:contentReference[oaicite:0]{index=0}
APIは安全なメッセージを返し、画面はモーダルで表示する方式です(業務アプリでよくある形)。 ※error.tsx は「例外時の代替画面」で、ブラウザ描画になるため Client Component が必要、という考え方は添付資料の通りです。:contentReference[oaicite:1]{index=1}
3. 画面(SVGワイヤー)
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>
);
}
在庫管理:在庫一覧検索(想定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. 注意点
サーバレス環境ではファイルDB(SQLite)が永続化できない/同時書き込みに弱い場合があります。
本サンプルは「設計例」として SQLite を使用します。実運用では PostgreSQL 等も検討してください。
・クエリを “全部URLに載せる” と共有・再現が簡単(業務で便利)
・ただしクエリが長くなるので、必要に応じて「検索条件をPOSTで送る」方式も検討
ページ番号、並び順、チェック項目などを “小さな検証クラス” に分けると保守しやすいです(本コードで実施)。
3. 画面(SVGワイヤー)
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>
);
}
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サンプル)
/**
* 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イメージ)
コード
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 サンプル)
/**
* 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:値(計算結果)をメモ化する
- useCallback:関数をメモ化する
// useMemo:値を固定
const total = useMemo(() => calcTotal(items), [items]);
// useCallback:関数を固定
const onSave = useCallback(() => {
save(items);
}, [items]);
実は内部的には、
useCallback(fn, deps) は
useMemo(() => fn, deps)
とほぼ同じ意味です。
画面イメージ(SVG)
使い方(基本形)
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>
);
}
注意点(実務で重要)
-
何でも useCallback は逆効果
軽い画面では可読性を下げるだけ -
主用途は「子に関数を渡すとき」
React.memo とセットで効く -
依存配列ミスはバグの元
古い state を参照する「ステイルクロージャ」に注意 -
「最適化は最後」
まず正しく動かし、重い所だけ入れる
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で在庫一覧を取得
- 検索条件が変わったら再検索
ここで問題になるのが、 「前回の検索条件を覚えておきたい」 という業務要件です。
業務で実際に起きる課題
次のような要件は、かなり現実的です。
・検索条件が前回と同じなら API を呼ばない ・ページ遷移時に「検索条件が変わったか」を判定したい ・ログに「どこが変わったか」を残したい
ここで多くの人が最初にやりがちな間違いがあります。
// ❌ よくある失敗(useStateで前回値を持つ)
const [prevKeyword, setPrevKeyword] = useState("");
useEffect(() => {
if (prevKeyword !== keyword) {
fetchInventory();
setPrevKeyword(keyword);
}
}, [keyword, prevKeyword]);
これは一見正しそうですが、 state 更新が原因で再レンダーが増える、 場合によっては 無限ループ の温床になります。
ここで useRef が「業務的に正解」になる
前回の検索条件は、
- 画面に表示しない
- 変わっても UI を更新する必要がない
- ただ「覚えておきたい」だけ
つまりこれは 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:実アプリ文脈)
なぜこれが「サンプルのためのサンプル」ではないのか
- 実在する「在庫検索」という文脈
- API最適化・ログ要件という業務理由
- state にすると壊れる実例
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 は、
アプリ全体(または一部)で共有したい値をまとめて置く場所
を作る仕組みです。
「この情報は、どの画面からでも参照できていい」
典型的には次のような情報を置きます。
- ログインユーザー
- 権限(admin / operator)
- 選択中の拠点・年度・会社
- 共通UI制御(ローディング・エラー)
画面イメージ(SVG:実アプリ文脈)
コード例(在庫管理アプリ)
/**
* 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>
);
}
注意点(実務で重要)
-
何でも Context に入れない
→ 頻繁に変わる値は再レンダー地獄になる -
業務的に「全画面共通」のものだけ
→ ログイン情報・選択中条件など -
state 管理の代替ではない
→ あくまで「受け渡し手段」
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に渡す」
という、ごく自然な構造です。
- props は 読み取り専用
- 子は props を 変更してはいけない
- 変更したい場合は 親に依頼する
具体例:在庫一覧画面
在庫一覧画面では、 「検索結果一覧」と「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>
);
}
ここで重要なのは:
InventoryPageが 業務データを管理InventoryRowは 表示に専念
これが 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}
/>
この段階で検討すべきなのが:
- Context にすべきでは?(useContext)
- コンポーネントを分割しすぎていないか?
props は便利ですが、 「近い親子関係」で使うのが原則です。
実務での 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>
この構造だと、Layout や InventoryPage は
ただ渡すだけの中継所になりがちです。
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(分割して)
- ページ内だけの情報 → 設計見直し(必要な場所に寄せる)
- 検索条件/ページ → URL(searchParams)
- 中間が枠だけ → children(合成)
- propsが多すぎる → カスタムHookで整理
「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 を自由に使っていい」
ここで重要なのは:
- Provider 自体は 何も表示しない
- ただ「値を置く範囲」を決めているだけ
③ 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 に置かれている値を取ってくる」
つまり、
- props として渡されていない
- 親も中間も一切関与していない
- Context から直接取得している
⑤ なぜ「Context が JSX に出てこない」のか
あなたが違和感を覚えた最大の理由はここです。
JSX 上では:
<Layout>
<InventoryPage>
<InventoryTable />
</InventoryPage>
</Layout>
なのに、
「なぜ InventoryTable が user を持っているのか分からない」
これは Context が「見えない依存関係」だからです。
そのため実務では、
useAppContext()のような名前にする- Context 用ファイルを明確に分ける
- Provider の配置を comments で明示する
といった 可読性対策が必須になります。
⑥ 「頻繁に変わる値を入れるな」の本当の意味
Context の値が変わると、
「その Context を使っている全コンポーネントが再レンダー」
されます。
例えば:
value={{ user, warehouse, searchKeyword }}
ここで searchKeyword が1文字入力されるたびに変わると、
- 在庫一覧
- ヘッダ
- サイドバー
すべて再レンダーされます。
だから:
「Context は 業務的に安定した共通情報だけに使う」
最終まとめ(腑に落とす一文)
Context とは、
「props を運ぶ代わりに、ツリーの途中に“共有棚”を置く仕組み」
JSX に Context が直接出てこないのは、 Provider が “環境” を作っているだけだからです。
カスタムHookの作り方(実在する業務アプリの例で理解する)
前提:在庫検索画面という「実際にあり得る画面」
想定するのは、次のような 在庫検索・一覧画面 です。
- 検索キーワードを入力
- ページネーションあり
- API から在庫一覧を取得
- ローディング・エラー表示あり
まずは カスタムHookを使わない状態 を見てみます。
① カスタムHookを使わない場合(現場でよく見る形)
/**
* InventoryPage.tsx(Hookなし)
* ------------------------------------------------------------
* 問題点:
* - useState / useEffect / fetch / エラー処理が画面に密集
* - 画面ロジックと業務ロジックが混ざる
*/
function InventoryPage() {
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]);
}
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,
};
}
ここで重要なのは:
- JSX を一切書かない
- 業務処理に集中している
- useState / useEffect を自由に使ってよい
④ 画面側は「使うだけ」になる
/**
* 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>
);
}
画面は今や、
「どんな業務処理か」を知らなくてよい
状態になっています。
⑤ なぜこれが「実務で効く」のか
-
別画面で再利用できる
→ 「在庫CSV出力」「在庫ダッシュボード」でも同じ Hook を使える -
テストしやすい
→ Hook 単体で振る舞いを確認できる -
仕様変更に強い
→ エラー文言や取得条件を1箇所で変更
⑥ カスタムHook設計の判断基準(覚え方)
- 複数の useState / useEffect がセットで動いている
- 画面が「業務ロジック」を語り始めた
- 別画面でも同じ処理を書きそう
このどれかに当てはまったら、
「それ、カスタムHookにできる」
カスタムHookとは、 React における「業務処理の部品化」。
JSX を綺麗に保ち、設計を長持ちさせるための道具です。
在庫管理システム(React学習用)仕様 & 学べるHooks対応表
1. 目的(学習ゴール)
- React の主要 Hooks(useState / useEffect / useContext / useRef など)を、実在する業務フローの中で学ぶ
- 「ログイン → 一覧 → 新規 → 編集 → 更新」という業務画面の基本構成を体験する
- サーバー側は最小(json-server 等)にして、React(画面・状態管理・イベント・データ取得)を優先する
2. 機能一覧
- ユーザーログイン
- 在庫一覧
- 在庫新規登録
- 在庫編集
- 在庫一覧のページネーション(20件/ページ想定)
3. 画面遷移(業務フロー)
- ユーザーログイン画面
- ログイン成功 → 在庫一覧画面へ遷移
- 在庫一覧画面で「追加」ボタン → 在庫新規登録画面
- 在庫新規登録画面で「追加」ボタン → 登録 → 在庫一覧画面へ戻る
- 在庫一覧画面で任意行の「編集」ボタン → 在庫編集画面
- 在庫編集画面で「更新」ボタン → 更新 → 在庫一覧画面へ戻る
4. データ仕様
4.1 在庫テーブル(inventory)
- id:数値(主キー)
- code:文字列(商品コード・在庫コード)
- name:文字列(名称)
- qty:数値(個数)
- warehouseName:文字列(倉庫名)
4.2 ユーザーテーブル(users)
- email:文字列(ログインID)
- userName:文字列(ユーザー名)
- password:文字列(パスワード)
※ 学習用のため、パスワードの暗号化などは後回し(本番では必須)。
5. 画面仕様
5.1 ログイン画面
- 入力:email / password
- ボタン:ログイン
- ログイン成功時:在庫一覧へ遷移
- ログイン失敗時:画面にエラーメッセージ表示(例「メールアドレスまたはパスワードが違います」)
5.2 在庫一覧画面
- 一覧:id / code / 名称 / 個数 / 倉庫名
- 行ボタン:編集
- 画面ボタン:追加
- ページネーション:20件ごと(例:?page=2)
- エラー時:一覧上部にエラー表示(モーダルまたはメッセージ領域)
5.3 在庫新規登録画面
- 入力:code / 名称 / 個数 / 倉庫名
- ボタン:追加
- 成功時:一覧へ戻る
- 失敗時:エラー表示
5.4 在庫編集画面
- 入力:code / 名称 / 個数 / 倉庫名(初期値は既存データ)
- ボタン:更新
- 成功時:一覧へ戻る
- 失敗時:エラー表示
6. React Hooks 学習マップ(どの画面で何を学べるか)
6.1 useState(状態管理の基本)
- ログイン:email / password / error / loading
- 一覧:rows / page / error / loading
- 新規・編集:フォーム入力値 / バリデーションエラー
6.2 useEffect(初期処理・データ取得)
- ログイン後の状態チェック(例:tokenがあれば自動遷移)
- 一覧:page変更時に再取得
- 編集:id指定で詳細取得
6.3 useContext(propsバケツリレー回避)
- ログインユーザー情報の共有(ユーザー名・権限など)
- 共通エラー表示(エラーモーダル)を全画面で共有(任意)
6.4 useRef(再レンダー不要な保持・DOM操作)
- フォーム初期フォーカス(入力欄へ focus)
- 編集画面:変更前データ(original)保持、差分チェック
- 一覧:前回検索条件保持(必要なら)
6.5 useMemo(重い処理の再計算を防ぐ)
- 一覧:表示用の整形(件数が増えた場合の最適化)
- フィルタ条件がある場合:keywordでフィルタ結果をメモ化
6.6 useCallback(関数参照を安定させる)
- 一覧:編集ボタン / 削除ボタンなどのハンドラを行コンポーネントへ渡す
- React.memo と組み合わせて不要な行再レンダーを抑制
6.7 カスタムHook(業務ロジックの分離)
- useAuth:ログイン処理・ログイン状態管理
- useInventoryList:一覧取得・ページ変更・エラー管理
- useInventoryForm:新規/編集の入力管理・送信・バリデーション
7. 学習の進め方(おすすめ順)
- ログイン画面(useState の基本)
- 在庫一覧(useEffect で一覧取得 + ページネーション)
- 新規登録(フォーム入力 + バリデーション)
- 編集(詳細取得 + 更新)
- useContext(ログインユーザー共有)
- 最後に最適化(useMemo / useCallback / React.memo)
結論: この在庫管理システム1本で、React Hooks の「実務で使う主要部分」を一通り学べます。
ログイン画面で学べる React Hooks 一覧(業務目線・少し深め)
ログイン画面は一見シンプルですが、実は React Hooks の基礎〜設計感覚まで一気に学べる 非常に良い題材です。
| Hook | ログイン画面での役割 | 意味・理解のポイント(深め) |
|---|---|---|
| useState |
・メールアドレス ・パスワード ・エラーメッセージ ・ローディング状態 |
「画面の状態はすべて state」というReactの基本原則を学ぶ。 入力欄は「DOMの値」ではなく「stateの写像」であることを理解するのが重要。 ログイン画面では
|
| useEffect |
・初期表示時の処理 ・既ログイン判定 ・ログイン成功後の副作用 |
「描画とは別のタイミングで起きる処理」を扱うHook。 ログイン画面では、
特に [](初回のみ)と
[isLoggedIn](状態変化時)
の違いが腑に落ちる。
|
| useContext |
・ログインユーザー情報の保持 ・全画面へのログイン状態共有 |
ログイン画面は
「Contextを導入する必然性が最も分かりやすい画面」。 ログイン後、
Contextは propsを運ぶ代わりに「共有棚」を作るという発想だと理解しやすい。 |
| useRef |
・初期フォーカス制御 ・再レンダー不要な値保持 |
useRefは
「状態だが、画面には影響しないもの」
を扱うHook。 ログイン画面では
「これは state にすべきか?」 を考える癖がつくのが最大の学習価値。 |
| カスタムHook (useAuthなど) |
・ログイン処理の共通化 ・API呼び出し隠蔽 |
ログイン処理は
複数画面で再利用される業務ロジック。 そのため
これは 「Reactらしい設計」 を学ぶ重要なステップ。 |
まとめ:
ログイン画面は小さいが、
React Hooks の考え方がすべて詰まっている。
ここを丁寧に作れるようになると、
以降の一覧・登録・編集画面が一気に楽になります。
在庫一覧画面で学べる React Hooks(業務目線・少し深め)
在庫一覧は「業務アプリの中心画面」なので、 React Hooks の学習効率が最も高いです。 一覧・検索・ページネーション・行ボタン(編集)などが揃い、Hooksの使い分けが自然に出ます。
| Hook | 在庫一覧での具体的な役割 | 意味・理解のポイント(深め) |
|---|---|---|
| useState |
・一覧データ(rows) ・検索条件(keyword) ・ページ番号(page) ・ローディング(loading) ・エラー文言(error) |
一覧画面は「状態の集合体」。 画面が変わる=stateが変わるを実地で学べる。 特に、業務画面では
|
| useEffect |
・初期表示で一覧取得 ・page変更で再取得 ・検索条件変更で再取得 |
useEffectは「副作用(データ取得)」の代表。 在庫一覧はまさに 「ある状態が変わったら、APIを呼ぶ」を行う画面。 依存配列( [page, keyword]など)の理解が、実害(呼びすぎ/呼ばなさすぎ)で体感できる。
|
| useRef |
・前回検索条件の保持(比較用) ・スクロール位置の保持(任意) ・「現在のリクエストID」保持(任意) |
useRefは
「保持したいが、画面を再描画させたくない値」に使う。 一覧画面だと、
|
| useContext |
・ログインユーザー表示 ・権限によるボタン制御(例:編集はadminのみ) ・共通エラーモーダル表示(任意) |
在庫一覧はヘッダや操作ボタンなど、共通情報(ユーザー/権限/拠点)を多用する。 propsで渡すと中継が増え、画面が大きくなるほど破綻するため、Contextの必要性が自然に理解できる。 |
| useMemo |
・フィルタ済み一覧 ・表示用整形(数量の表示形式など) ・集計値(合計在庫数など) |
useMemoは
「重い計算結果を、依存が変わらない限り再利用する」。 一覧で件数が増えると、検索入力のたびに filter や map が走り続ける。そこで
|
| useCallback |
・編集ボタンのクリックハンドラ ・削除ボタン(任意) ・ページ変更ハンドラ |
一覧画面では「行コンポーネント」が大量になる。 親が毎回新しい関数を作ると、子(Row)が propsが変わったとみなされ再レンダーしやすい。 useCallbackは 「同一参照の関数を維持して、不要な再レンダーを減らす」という、性能と設計の両方に効く考え方を学べる。 |
| React.memo(Hookではないが重要) | ・Row(1行表示)の再レンダー抑制 |
一覧の行が多い業務画面では効果が出やすい。 useCallback × React.memo をセットで理解すると、 「なぜ再レンダーされるのか」が腑に落ちる。 |
| カスタムHook |
・一覧取得ロジックを分離 ・検索/ページング/エラー管理を共通化 |
在庫一覧の処理は他画面でも使い回されがち。 例えば
これは業務アプリの保守性を上げる設計練習になる。 |
まとめ:
在庫一覧は useState / useEffect を中心に、
規模が増えるほど useContext / useRef / useMemo / useCallback が必要になる。
つまり、この画面を作り込むほど「実務で使うHookの順番」が自然に学べます。
在庫新規登録画面で学べる React Hooks(実務目線・理解を深める)
在庫新規登録画面は
「フォーム処理の王道」です。
業務システムの9割はフォームなので、ここで学んだHookの使い方は
そのまま実務に直結します。
| Hook | 在庫新規登録での役割 | 意味・理解のポイント(深め) |
|---|---|---|
| useState |
・商品コード ・名称 ・個数 ・倉庫名 ・エラーメッセージ ・送信中フラグ |
フォーム画面は
「state管理の集大成」。 入力値・エラー・送信状態を すべて useState で管理することで、
「入力=stateの変更」というReactの思想が ここで完全に定着する。 |
| useEffect |
・初期表示時の処理(任意) ・登録成功後の画面遷移 ・エラー発生時の副作用 |
useEffectは
「処理の結果として起きる動作」
を記述する場所。 在庫登録では
「送信関数の中に全部書かない」設計感覚が身につく。 |
| useRef |
・初期フォーカス制御 ・前回送信データの保持 ・二重送信防止(補助) |
useRefは
「値は持つが、画面は変えない」ためのHook。 在庫登録では
「これは state にする必要があるか?」 を考える力が鍛えられる。 |
| useContext |
・ログインユーザー情報取得 ・倉庫名の初期値設定 ・権限チェック |
新規登録画面では
「ログイン情報を当然のように使う」。 それを props で渡し始めると、 画面構造が一気に崩れる。 Contextを使うことで 画面は業務に集中し、共通情報は外から供給されるという設計思想を理解できる。 |
| useCallback |
・登録ボタンクリック処理 ・キャンセル処理 |
フォーム送信関数は
子コンポーネントに渡されることが多い。 useCallbackを使うことで 関数参照を安定させる意識が身につく。 「今は不要でも、大規模化すると必要になる」 という将来視点の学習ポイント。 |
| カスタムHook |
・登録処理ロジックの分離 ・バリデーション共通化 ・API呼び出し隠蔽 |
在庫登録処理は
一覧・編集でも再利用される。useInventoryForm のような
カスタムHookに切り出すことで、
これは実務で 「書ける人」と「設計できる人」 を分けるポイント。 |
まとめ:
在庫新規登録画面は
フォーム系Hook(useState / useEffect / useRef)
の理解を深める最重要画面。
ここを丁寧に作れるようになると、
編集画面・他業務フォームが一気に楽になります。
在庫編集画面で学べる React Hooks(業務アプリの核心)
在庫編集画面は、
「React Hooks を使った業務画面の完成形」です。
一覧・新規よりも状態が多く、
なぜこのHookが必要なのかを最も深く理解できます。
| Hook | 在庫編集での具体的な役割 | 意味・理解のポイント(深め) |
|---|---|---|
| useState |
・名称 ・個数 ・倉庫名 ・エラーメッセージ ・更新中フラグ |
編集画面では
「初期値が存在する state」
を扱う。 これは新規登録との最大の違い。 既存データを state にコピーして編集することで、
|
| useEffect |
・初期表示で詳細取得 ・ID変更時の再取得 ・更新成功後の画面遷移 |
編集画面は
「表示前に必ず API を呼ぶ」画面。 useEffectを使うことで
副作用をイベント関数に混ぜないのがポイント。 |
| useRef |
・初期データ(original)の保持 ・変更有無の判定 ・二重更新防止(補助) |
useRefは
編集画面で最も重要なHook。 初期取得したデータを originalRef に保存することで、
stateにすると再レンダーが増えるため、 「比較用データは ref」 という設計感覚が身につく。 |
| useContext |
・ログインユーザー取得 ・権限チェック(編集可否) ・共通エラーモーダル |
編集画面では
「誰が編集しているか」
が重要。 Contextを使うことで、
業務アプリではほぼ必須の設計。 |
| useCallback |
・更新ボタン処理 ・リセット処理 ・戻るボタン処理 |
編集画面はボタンが多く、
ハンドラ関数も増える。 useCallbackを使うことで 「意図しない再生成」 を防ぎ、将来の最適化に耐えられる構造になる。 特に子コンポーネント化した場合に効果が出る。 |
| useMemo |
・変更有無フラグの算出 ・差分サマリ生成 |
差分チェックは
オブジェクト比較になりがち。 useMemoを使うことで、
「軽く見える処理ほど積み重なる」 という業務的な視点が身につく。 |
| カスタムHook |
・詳細取得+更新処理 ・バリデーション共通化 ・例外処理集約 |
編集処理は
一覧・新規と共通部分が多い。useInventoryEdit のような
カスタム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 起動までの手順(最短まとめ)
-
作業用フォルダを作成
mkdir react-study cd react-study -
API 用フォルダを作成
mkdir api cd api -
db.json を作成
touch db.json※ db.json は疑似データベース。トップレベルのキーがテーブルになります。
-
db.json に初期データを書く
{ "inventory": [ { "id": 1, "name": "商品A", "stock": 10 }, { "id": 2, "name": "商品B", "stock": 0 } ] } -
json-server を起動(JSONサーバー)
npx json-server@0.17.4 --watch db.json --port 3001※ Node.js が入っていれば、事前インストール不要。
-
起動確認
- ブラウザで
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. 必要なもの(最小)
- Node.js(推奨:LTS)
- VS Code
- ブラウザ(Chrome等)
3. VS Codeで作業フォルダを開く
- VS Codeを起動
- ファイル → フォルダーを開く から
在庫管理reactを開く - 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です:
http://localhost:3001/inventory?_page=1&_limit=10
8. ここまでの「完成状態」チェック
- React:
http://localhost:5173が表示される - API:
http://localhost:3001/inventoryが表示される
次は、ReactからAPIにアクセスします。
その際、CORSを避けるために Vite の proxy を設定するのが最短です(次ステップで実施)。
ログイン画面から始める(React + Vite + json-server)
仕様
- メールアドレス・パスワードを入力してログインする
- 送信中はボタンを無効化し「ログイン中…」表示
- 失敗時は画面上にエラーメッセージ表示
- 成功時は「在庫一覧へ」画面遷移(今回は簡易:画面切り替え)
注意点(学習用)
- json-server で
GET /users?email=...&password=...のように照合します(本番ではNG) - 本番はパスワードハッシュ・トークン・HTTPS・サーバー側認証が必要です
- まずは React の Hooks(useState / useEffect)とフォーム処理を覚えるのが目的です
画面イメージ(SVG)
コード(コピペで動く最小構成)
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 アプリのエントリポイント
起動手順(確認)
- API(別ターミナル):
cd api→npx json-server@0.17.4 --watch db.json --port 3001 - React:
cd web→npm run dev - ブラウザで
http://localhost:5173を開く 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)
仕様
- json-server の
/inventoryから在庫一覧を取得してテーブル表示 _pageと_limitでページング(例:?_page=2&_limit=10)- 総件数はレスポンスヘッダ
X-Total-Countから取得し、総ページ数を算出 - 通信中はローディング表示
- 失敗時は画面上部にエラーメッセージ(業務向け)を表示
注意点
-
json-server のページングは
_page/_limitを使います(page/limitではありません) -
総件数は配列の長さではなく、
X-Total-Countを使います(最後のページが10件未満になるため) -
React18 の StrictMode 開発時は
useEffectが2回走ることがあります(本番は1回)。気になる場合は後述の対策を入れられます
画面イメージ(SVG)
コード
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;
}
動作確認
- json-server を
localhost:3001で起動 - ログイン成功後、在庫一覧が表示される
- 「前へ」「次へ」で
_pageが変わり、API再取得される
もし X-Total-Count が取れない場合は、ブラウザの DevTools → Network → Response Headers を確認してください。
ログインユーザー情報を Context 化して props リレーを減らす
仕様
- ログインユーザー(id/email/userName)をアプリ全体で共有できるようにする
- どの画面からでも
useAuth()でユーザー情報を取得できる - ログアウトも
useAuth()経由で呼べる - 中間コンポーネント(Layoutなど)を挟んでも props を渡さなくてよい
注意点(実務のコツ)
- Context に頻繁に変わる値を詰め込みすぎると、広範囲が再レンダーされます。 まずは「ログインユーザー」「ログアウト関数」など安定した共通情報に限定するのが安全です。
-
Context を安全に使うために、Provider 外で使われたら例外を投げる
カスタムHook(
useAuth())を用意します。
画面イメージ(SVG:propsリレーが消える)
コード(コピペで動く構成)
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 してください。
これで何が嬉しい?
- Layout や Header を挟んでも props を渡さずに user が読める
- どの画面からでも logout できる
- 画面が増えても「App が太らない」
在庫 新規追加画面(React + json-server + Hooks)
仕様
- 在庫テーブル(inventory)に新規レコードを追加する
- 入力項目:コード(code)、名称(name)、個数(qty)、倉庫名(warehouseName)
- 登録ボタンで
POST /inventoryを呼び出す - 成功時:成功メッセージ表示(モーダル)→ 一覧へ戻る(本サンプルではコールバック)
- 失敗時:画面上部にエラー表示(業務向け)
注意点
-
json-server は
POST /inventoryで自動的にidを採番します(Auto Increment 相当) -
バリデーションは「クラス分け」します(UI層とロジック層を分離)
- Validator:入力値を検証し、エラー一覧を返す
- Service:登録処理(API呼び出し)と例外処理
- Page:画面表示と状態管理(Hooks)
- ここでは学習用に json-server を使います。本番ではサーバー側でも必ず同じ検証が必要です。
画面イメージ(SVG)
コード(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)
仕様
- 一覧画面で選択された特定のレコードを更新します。
- 入力項目:「名称(name)」「倉庫名(warehouseName)」「個数(qty)」を変更可能にします。
- 「コード(code)」はユニークな識別子であるため、変更不可(読み取り専用)として表示します。
- 「変更を保存」ボタンで
PUT /inventory/:idを呼び出し、サーバー上のデータを上書きします。 - 成功時:成功メッセージをモーダルで表示し、確認後に一覧画面へ戻ります。
- 失敗時:画面上部に安全なエラーメッセージを表示します。
注意点
-
データの取得と初期化:一覧から渡されたデータを
useStateの初期値としてセットすることで、現在の情報を保持した状態で編集を開始できます。 -
個数の入力制御:個数入力欄で数字をすべて消せるように、状態管理には
number | ""を使用します。 -
json-server の挙動:
PUTメソッドは、指定した ID のリソースをリクエストボディの内容で完全に置き換えます。
画面イメージ(SVG)
コード(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)
仕様
- 「在庫 変更」画面に 削除ボタン を追加する
- 削除ボタンで
DELETE /inventory/:idを呼び出し、在庫レコードを削除する - 成功時:モーダルダイアログで 「在庫を削除しました」 を表示して一覧へ戻る
- 失敗時:画面上部にエラー表示(業務向け)
- 削除中は二重送信防止のため、ボタン・入力を disable する(
loading)
なぜ「変更画面」に削除ボタンを置くのか
一覧画面に削除ボタンを並べると、クリックミスで誤削除しやすくなります。
そのため、実務では「詳細/編集」画面に移動してから削除する設計が多いです。
このサンプルでも 編集画面に削除ボタン を置き、削除前に confirm を出して事故を防ぎます(学習用)。
json-server の挙動(DELETE)
DELETE /inventory/1のように ID を指定して削除します- 成功時のステータスは json-server の実行バージョン等で
200/204のどちらかになり得ます - このサンプルでは
res.ok(200〜299)で成功判定します
実装のポイント(Hooks)
1) loading で二重送信を防止
削除は 1 回しか実行してはいけない操作です。
loading=true の間は削除ボタンも戻るボタンも disable にして、連打・二重送信を防ぎます。
2) エラーは「安全なメッセージ」で表示する
画面には「在庫の削除に失敗しました」のような 安全な文言 を出し、
詳細は console.error で開発者が追えるようにします(実務の基本)。
3) 成功時はモーダルで完了を通知する
更新と削除はユーザーにとって影響が大きい操作なので、成功時はモーダルで確実に通知します。
要件どおり、削除成功時は 「在庫を削除しました」 を表示します。
画面イメージ(削除ボタン追加)
修正が必要なコード(全コード)
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>
);
}
動作確認(手順)
- 一覧 → 行クリックで「在庫 変更」へ
- 右上の 削除 を押す
- 確認ダイアログで OK
- モーダルで 「在庫を削除しました」 が出たら OK を押す
- 一覧に戻り、該当行が消えていることを確認
本来は「削除権限」「監査ログ」「論理削除(deleted フラグ)」などを設けます。
ただし学習用サンプルではまず「DELETE の一連の流れ」と「UI状態管理」を掴むのが目的です。
在庫削除:成功モーダル + 元のページの一覧画面へ戻る処理
本機能の主題は、 削除後に「元のページの一覧画面へ戻る」ことである。
在庫一覧はページングされており、ユーザーは任意のページ(例:3ページ目)を閲覧している。 その状態で変更画面に遷移し、削除を行った場合、 単純に一覧画面へ戻すだけでは どのページへ戻るべきかが定義されない。
この問題を解決するため、
変更画面へ遷移する時点で
returnPage(一覧で表示していたページ番号)を保持し、
削除完了後に onDone(returnPage) として戻すことで、
元のページの一覧画面へ戻る動作を実現する。
削除によってページ構造が変化する場合の挙動
削除はデータ件数を減少させるため、 総ページ数(totalPages)が変化する可能性がある。
例えば以下のケースを考える。
- 削除前:全21件 → 10件/ページ → 3ページ
- ユーザーは3ページ目を表示中(returnPage = 3)
- 3ページ目の最後の1件を削除
- 削除後:全20件 → 2ページ
このとき returnPage=3 をそのまま使用すると、 存在しないページを参照することになる。
この問題は、一覧画面側で以下の処理を行うことで解決する。
- 削除後に totalCount から totalPages を再計算する
- 現在ページ(page)が totalPages を超えている場合、 page を totalPages に補正する
これにより、 元のページが存在しない場合は自動的に前のページへ戻る動作となる。
データが0件になった場合の挙動
削除によってデータが0件になるケースも存在する。
この場合、単純に計算すると totalPages は 0 になるが、 ページ番号 0 は UI として成立しないため、 以下のルールを適用する。
- totalPages = Math.max(1, ceil(totalCount / limit)) とする
つまり、データが0件でも 1ページとして扱う。
その上で、
- returnPage = 3(例)
- totalPages = 1
の場合、
page = min(returnPage, totalPages) = 1
となり、 1ページ目(データ0件表示の一覧画面)へ戻る。
この処理により、
- 通常 → 元のページへ戻る
- ページが減少 → 前のページへ戻る
- データ0件 → 1ページ目へ戻る
のすべてのケースを、 同一ロジックで一貫して処理できる。
修正が必要なファイル
src/App.tsx:一覧ページ番号を保持し、編集へ returnPage を渡すsrc/InventoryPage.tsx:initialPage を受け取り、onEdit(item, returnPage) を呼び出す。ページ丸めも実装src/pages/InventoryEditPage.tsx:returnPage を受け取り、削除成功モーダル「在庫を削除しました」→ onDone(returnPage)
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を網羅した最終形態のコードです。
このコードを動かすには、プロジェクトのターミナルで以下のコマンドを実行し、標準のルーティングライブラリをインストールしてください。
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をインストールします。
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>
);
}
このアーキテクチャの凄さ
- 画面を作る人が「認証」を気にしなくてよくなる:
BusinessPage1の開発者は、ただapi.get()と書くだけです。裏でapi.tsの「行きの門番」が、tokenRefから勝手にトークンを抜き出して通信にくっつけてくれます。 - トークンの有効期限切れ(401エラー)を全画面で一括処理できる:APIから「お前のトークン、もう期限切れだよ(401エラー)」と怒られた場合、画面側にエラーが到達する前に「帰りの門番」がそれを横取りします。そして全自動でアラートを出し、
setUser("未ログイン")を実行して、ユーザーをログイン画面に強制送還します。
インターセプターの仕組みに関する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が完全に一致しているか?」を毎回チェックします。
-
handleBadClick(なし)の場合:
親が再描画されるたびに、関数が新しく作り直されます。C#的に言えば、参照(メモリアドレス)が変わります。
子コンポーネントは「あ!前回もらった関数(0x001)と、今回もらった関数(0x002)は別物だ!親から新しい指示が来たから画面を更新しなきゃ!」と勘違いして、無駄な再描画を走らせてしまいます。 -
handleGoodClick(あり)の場合:
useCallbackが、「前回と同じメモリアドレスの関数」を親の再描画後もキャッシュして渡し続けます。
子コンポーネントは「前回もらった関数(0x00A)と、今回もらった関数(0x00A)は全く同じ参照だね。じゃあ私の見た目は変わらないから、描画をサボろう」と判断し、ピタッと止まります。
まとめ
useCallbackは、「React.memoで守られた子コンポーネントに、関数をPropsとして渡す時」にのみ絶大な威力を発揮します。- 逆に言えば、子コンポーネントが
memoで守られていない場合は、関数を使い回しても結局再描画されてしまうため、useCallbackを書く意味はほとんどありません。
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を連携させている場合は、
メインブランチにプッシュするだけで自動的にデプロイが行われます。
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; /* 濃いスレート色で目に優しく */
}
ローカルメモアプリ 概要
「ローカルメモ」は、ブラウザ上で安全かつ快適にテキストデータを管理できるローカルファーストなメモアプリケーションです。
外部のデータベースに依存せず、データの暗号化やエクスポート機能を備えることで、プライバシーを重視したセキュアな設計となっています。
主な機能と特徴
-
🔒 セキュアな認証と暗号化
Web Crypto API(SHA-256)を用いた堅牢なログイン認証を実装。さらに、マスターパスワードを用いたメモデータの暗号化・復号機能を備え、機密性の高い情報も安全に保管できます。 -
📝 直感的な2ペインレイアウト
左側に「メモ一覧」、右側に「エディタ」を配置したシームレスなUI。境界線はドラッグ操作で自由にサイズ変更(リサイズ)が可能で、自分好みの執筆環境を構築できます。 -
💾 データのインポート / エクスポート
作成したメモデータはローカルに保存されるだけでなく、JSON形式でのエクスポート機能を提供。バックアップや別端末へのデータ移行(インポート)も簡単に行えます。 -
📱 完全レスポンシブ対応
PCブラウザだけでなく、スマートフォンやタブレットからの利用にも最適化。画面サイズが狭い場合は自動的に縦並びのレイアウトに切り替わり、いつでもどこでも快適にメモを記録できます。
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
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 & 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 & 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 1, 田中, 2026年2月26日 15時30分 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>