React / Next.js に慣れてくると、次に悩むのはだいたい「設計レイヤー」まわりですよね。
- コンポーネントが太りやすい
-
useEffectが増えてロジックが迷子になる - 状態をどこに置くか毎回迷う
- Hooks をどこまで分割すべきか分からない
このあたりのモヤモヤさえ事前に言語化しておくと、
- コードの読みやすさ
- 変更時の“怖さ”の小ささ
- チーム開発でのストレス
がけっこう変わります。
この記事では 2024〜2025 時点の React 19 / Next.js 15(App Router) の設計思想を踏まえつつ、
実務で迷わないための “判断軸” を、例外や補足も含めて整理してみます。
※ Next.js 15 は React 19 と組み合わせるのが公式に推奨されていますが、
ここで紹介する考え方は React 18 ベースの既存プロジェクトにも流用できます。
1. 設計は「UI の変化」から逆算すると一気にラクになる
API や DB スキーマから考え始めたくなりますが、ユーザーが実際に触るのは UI の変化 そのものです。
- 何を入力したら
- どの UI が
- どのタイミングで
- どう変わるのか
この UI の状態遷移 を最初にざっと書き出すだけで、
- 必要な
state - コンポーネントの分割
- Hooks の切り方
がかなり自然に決まってきます。
ただし、UI 起点にしない方がいいケースもある
例外として、次のようなときは ドメインロジック側を起点 に設計した方が安全です。
- 金額計算や在庫・ポイントなど、整合性が最優先の EC / 業務 UI
- すでにシステムの業務モデルやテーブル構成がガチガチに決まっているケース
- 「この値の整合性を絶対崩せない」という強い制約がある場合
このあたりは 「UI優先」か「ドメイン優先」かをプロジェクトごとに決めておく と、チーム内での認識ズレが減ります。
❌ よくある混乱例(UI状態とドメイン状態をごちゃ混ぜ)
const UserForm = () => {
const [user, setUser] = useState({ name: "", age: 0 });
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// ...
};
一見シンプルですが、
-
userは「入力中の値」なのか - バリデーション済みの「ドメインとして正しい値」なのか
- API レスポンスをそのまま入れているのか
が曖昧で、だんだんと 「何を信じればいい state なのか」 が分からなくなります。
UI 状態と入力状態を分ける
const [name, setName] = useState("");
const [ageInput, setAgeInput] = useState("");
const [status, setStatus] =
useState<"idle" | "loading" | "success" | "error">("idle");
そして 送信時にだけドメイン型に変換 します。
const handleSubmit = async () => {
const age = Number(ageInput);
if (!Number.isInteger(age) || age < 0) {
setStatus("error");
return;
}
setStatus("loading");
await updateUser({ name, age });
setStatus("success");
};
- 入力値は 「UI が扱いやすい型」(文字列など)で持つ
- サーバーに渡す直前で 「ドメインとして妥当な型」 に変換する
という流れにしておくと、フォームが複雑になっても破綻しにくくなります。
React 19 時代のフォーム設計のポイント
React 19 では
-
<form>と Actions(useActionState/useFormStatusなど) - ネイティブフォーム送信とサーバーアクション
がかなり強化されています。
つまり、設計の選択肢はだいたいこの 2 つになります。
- 「UI 状態として state で全部持つ」
- 「DOM(フォーム)に任せて、アクションの結果だけを状態として持つ」
フォームが増えてきたら、
「これは React の state で持つのか? / DOM に任せるのか?」 を一度立ち止まって考えるだけでも、
useState だらけのフォームから卒業しやすくなります。
2. 状態設計は「3つの軸 + URL 状態 + グローバル状態」でブレなくなる
状態設計で迷子になりがちなポイントを、次の 3 ステップに分解します。
- 状態の “オーナー” を決める
- 状態の 種類 を分類する
- Derived State(派生状態)を増やしすぎない
そこに URL 状態 と グローバル状態 を足すと、だいぶブレなくなります。
(1) 状態の “オーナー” を決める
まずはシンプルに、この 2 つだけを考えます。
- その
stateを 一番自然に扱うコンポーネント はどこか? - その
stateを使うコンポーネントの 「最小共通親」 はどこか?
この 2 点だけでも、
- 「とりあえず親に全部置いて props ドリル…」
- 「どこからでも触りたいからグローバルに…」
といった “逃げの配置” をだいぶ防げます。
(2) 状態の種類で分類する
ざっくりですが、実務ではこのくらい分けておくと頭が整理しやすいです。
| 種類 | 例 |
|---|---|
| UI 状態 | モーダル、タブ、ローディング、トースト |
| サーバー状態 | API データ、キャッシュ、再検証 |
| 派生状態(Derived) | 絞り込み結果、集計、選択中フラグなど |
| フォーム状態 | 入力値、エラー、バリデーション |
| URL / Router 状態 | 検索条件、ページ番号、ソート条件 |
| グローバル / セッション | ログインユーザー、権限、テーマ |
Next.js 15(App Router)だと、特に
-
fetch+ キャッシュ(revalidate) - Server Components / Server Actions
が強力なので、「サーバー状態 = DB の値だけでなく、キャッシュを含めた“読み取りの窓口”」 と捉えると設計しやすいです。
(3) Derived State は「目的のない重複」を避ける
// ❌ 目的のない重複
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
// ...
setFilteredUsers(users.filter((u) => u.active));
こういう「元データの単なるフィルタ結果」を state にすると、
-
usersとfilteredUsersのどちらが正か分からなくなる - 更新漏れのバグを生みやすい
などの問題を呼び込みがちです。
// 計算だけで十分なら state にしない
const filteredUsers = users.filter((u) => u.active);
で済むなら、まずは state にしない方が安全 です。
ただし、以下のようなときは state にしてしまった方が素直な場合もあります。
- 計算コストがかなり重い(かつキャッシュしたい)
- 無限スクロールやページングと強く同期させたい
- メトリクス計測上、「今の表示件数」を state として持っておきたい
useMemo についても、
- 「とにかく高速化のために付ける」ではなく
- 「参照の安定性(
===の変化)を保証したいところ にだけ付ける」
と決めておくと、必要以上に増えなくなります。
3. コンポーネント分割は「責務 + 再レンダリング境界」で切る
昔からある Presentational / Container の考え方は、今でもかなり有効です。
// View(UIだけを担当)
export const UserListView = ({ users }: { users: User[] }) => (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
// Logic(データ取得+フィルタリング)
export const UserList = () => {
const users = useUsers();
const filtered = useFilteredUsers(users);
return <UserListView users={filtered} />;
};
ただし「必ず View と Container に分けるべき」という話ではありません。
むしろ実務では、次の 2 軸で判断した方がしっくりきます。
-
責務
- 再利用したい UI → コンポーネントとして切り出す
- 再利用したいロジック → Hook に切り出す
- ページ固有ロジック → ページ内に閉じる
- 業務ロジック → use-case / service 層などに寄せる
-
再レンダリング境界
- 「ここで state を持つと、この下のコンポーネントツリーが全部再レンダリングされるけど OK?」
- 「ここはあえて分けておいた方が、再レンダリング範囲を狭くできるよね?」
この 2 軸で見ていくと、
- なんとなくコンポーネントを増やしすぎる
- 逆に 1 ファイル 500 行の “なんでも屋コンポーネント” になる
という両極端を避けやすくなります。
4. 副作用は「必要なときだけ Hook へ逃がす」
useEffect まわりは、React 19 になっても引き続きハマりポイントです。
ざっくり分けると次のような方針がおすすめです。
- 再利用したい副作用 → カスタム Hook に切り出す
- そのページ専用の副作用 → 無理に外へ出さずページ内に置いて OK
ただし、次のような「ちゃんとテストしたい副作用」は Hook に逃がしておいた方が後々ラクです。
- ログ送信・トラッキング
- API 呼び出し(ポーリング、Timer、Observer 系)
- イベント購読(
IntersectionObserver/ WebSocket / SSE など)
❌ よくある useEffect の“なんでも置き場”化
React 19 ではレンダリングモデルがよりリッチになったぶん、
「とりあえず useEffect の中でやる」 は、以前にも増して危険になっています。
特に避けたいのはこのあたりです。
- 単なる計算を
useEffectに閉じ込める - URL(クエリ)を
useEffectで別 state に同期させる - フォームのローカル値を
useEffectで別 state に写し替える
これらは
render → effect → state 更新 → render → ...
というループを作りやすく、バグとパフォーマンス問題の温床になりがちです。
「副作用じゃなくて、純粋な計算で書けないか?」を一度立ち止まって考える癖を付けておくと、useEffect の出番はかなり減らせます。
5. Next.js のデータ取得は「まず Server Component」で検討(ただし万能ではない)
App Router の世界観では、
- 初期表示のデータ取得は Server Component でやる
- その上で、どうしてもクライアントで持ちたいものだけ Client Component に逃がす
という流れが基本になります。
初期表示データは Server Component が強い
// app/users/[id]/page.tsx
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params;
const user = await getUser(id);
return <UserPage user={user} />;
}
Server Component によるデータ取得のメリットはざっくりこんな感じです。
- 自動キャッシュ(
revalidate戦略との組み合わせ) - 自動再検証(Stale-While-Revalidate)
- TTFB の改善(クライアントの
useEffectfetch が不要) - 初期表示の「チラつき」を減らしやすい
⚠ ただし、Server Component だけでは済まないケースも多い
例えば次のようなケースでは、素直に Client 側で fetch した方がよかったりします。
- 認証 / Cookie / 権限で出し分ける UI が多い
- 外部 API で、キャッシュ戦略が取りづらい(一度きりのペイロードなど)
- 画面上でガンガンインタラクションするユーザー固有データ(リアルタイム性が高いもの)
「なんでも Server Component に寄せる」のではなく、
- “初期表示に必要な最小限” を Server Component
- その後の細かいインタラクションやポーリングは Client に任せる
くらいのバランスを意識しておくと、設計も読みやすくなります。
6. 更新は Server Actions がかなり強力(ただし制約も理解しておく)
Next.js 15 では Server Actions が安定してきていて、
「フォームから DB 更新までを 1 本の線で書く」 というスタイルがかなり実用的になっています。
// app/users/actions.ts
"use server";
export async function updateUser(prevState: { ok: boolean }, formData: FormData) {
const name = formData.get("name");
await db.user.update({ name });
return { ok: true };
}
// app/users/page.tsx(Client Component 側)
"use client";
import { useActionState } from "react";
import { updateUser } from "./actions";
export function UserForm() {
const [state, action, isPending] = useActionState(updateUser, { ok: false });
return (
<form action={action}>
<input name="name" />
<button disabled={isPending}>更新</button>
{state.ok && <p>更新しました</p>}
</form>
);
}
Server Actions のうまみ
-
pending / error / successがuseActionState/useFormStatusで扱いやすい - Optimistic UI が書きやすい
- クッキーやセッションを使った mutate が安全に書ける
- ネイティブフォームとの相性が良く、JavaScript 未読込でも動く(プログレッシブエンハンスメント)
ただし、制約もそれなりにある
- Action はサーバーバンドルに含まれるので、肥大化しすぎるとビルドサイズに影響 する
- ランタイムは基本的に Node.js(Edge Runtime での利用は制限・注意点が多い)
- 呼び出しは基本的に 1 Action = 1 リクエスト として扱われるイメージ
- モバイルアプリと API をガッツリ共有したい場合は、
BFF や OpenAPI ベースの API を別で用意した方が設計しやすい
ざっくりした使い分けイメージ
-
Server Actions
- Web アプリ内のフォーム送信
- 権限チェックを絡めた安全な mutate
- 画面に強く紐づいた更新(UI と一体)
-
Route Handlers(BFF 的 API)
- モバイルアプリや別サービスとの API 共有
- Web 以外のクライアントからも叩きたい更新系処理
- SSE / WebSocket などリアルタイム性の高い仕組み
-
Client fetch(クライアントからの fetch / SWR / TanStack Query 等)
- クライアント側のキャッシュ戦略を細かく制御したいとき
- 更新頻度が高く、UI がインタラクティブに変化する場面
7. 判断軸を言語化すると迷わなくなる
ここまでの内容を「迷ったときに思い出すチェックリスト」としてまとめると、だいたいこんな感じになります。
-
UI の変化から設計する
- 「何をしたら、どの UI が、どう変わるか?」を書き出してから state を決める
-
状態の“オーナー”を決める
- その state を一番自然に扱うコンポーネントと、最小共通親を意識する
-
状態の種類で分類する(URL / グローバルも含めて考える)
- UI / サーバー / Derived / フォーム / URL / グローバル
-
Derived State は安易に重複しない
- 計算で済むものは state にしない
- ただし計測やパフォーマンスのための「意図ある冗長性」は許容する
-
コンポーネントは「責務 + 再レンダリング境界」で分ける
- 再利用したい UI はコンポーネント化
- 再利用したいロジックは Hook 化
- ページ固有ロジックはページに閉じる
-
副作用は必要なときだけ Hook に逃がす
- 特にテストしたい副作用(ログ・ポーリング・Observer)は Hook に切り出す
-
初期データは Server Component をまず検討
- それでも足りないところだけ Client で fetch する
-
mutate は用途ごとに手段を選ぶ
- Server Actions / Route Handlers / Client fetch を「誰が叩くか」「どこまで共有したいか」で選択する
-
机上の設計だけで完結させず、「計測」で再評価する
-
LCP/TTFB/INPなどのパフォーマンス指標 -
Sentryや各種トラッキングによるエラー・行動ログ
-
おわりに
2024〜2025 年は、React 19 と Next.js 15 の登場で
- UI とロジックの責務分離(Server / Client の役割分担)
- 「初期表示はサーバー主導」がほぼデフォルトになったこと
- state を必要最小限にして、「ソース・オブ・トゥルースを 1 つに絞る」流れ
- mutate 戦略(Server Actions / BFF / Client fetch)の選択肢が整理されてきたこと
など、フロントエンドの設計思想がかなりアップデートされた時期でした。
一方で、「正解の設計」自体はプロジェクトごとに違う のも事実です。
だからこそ、
- UI 起点で状態遷移を整理する
- 状態のオーナーと種類を言語化する
- Server / Client の役割分担をチームで共有する
- 実際の計測結果を見て設計をアップデートする
といった “判断軸” をチームで共有しておくこと が、
結果的にコンポーネント構造やコード品質を安定させてくれます。
誰かが設計で悩んだときに、
「とりあえずこのチェックリストに沿って一緒に整理してみよう」
と言える、共通の土台 を作る上で参考にしてもらえたらうれしいです。