【React完全マスター】コンポーネント設計から useState / useEffect・React Router・Jotai まで、現場で詰まる所だけ潰していく
はじめに
React は入門記事が山ほどありますが、チュートリアルを終えた直後に詰まるポイントはだいたい決まっています。
-
useStateは書けるが、どこに置くべきか分からない -
useEffectを書いたら無限ループした / 2回実行された - ページを増やしたいがルーティングをどう組めばいいか分からない
- props のバケツリレーが 4 階層になって心が折れた
この記事は、その 4 つを順番に潰していく構成です。文法の羅列ではなく「なぜそう書くのか」「書かないとどうなるか」を中心に書きます。
対象読者
- React のチュートリアルは一通り終えた
- 業務レベルの構成をどう組むか知りたい
- サーバーサイド(PHP / Ruby / Go など)の経験はある
前提バージョン(2026年8月時点)
| パッケージ | バージョン | 備考 |
|---|---|---|
| React | 19.2 系 | 19 系が本番標準 |
| React Router | 8.x | 2026年6月リリース。react-router-dom は廃止 |
| Jotai | 2.20 系 | v3 は計画段階 |
| TypeScript | 5.x 系 | コード例はすべて TS |
注意: React Router v8 で
react-router-domパッケージが完全に削除されました。ネット上の記事の大半はまだimport { BrowserRouter } from "react-router-dom"と書いていますが、そのままコピペすると最初の import で落ちます。本記事は v8 準拠で書いています。
1. コンポーネント設計の基本
コンポーネントは「関数」である
React のコンポーネントは、props を受け取って JSX を返すただの関数です。
type Props = {
name: string;
age: number;
isAdmin?: boolean;
};
export function UserCard({ name, age, isAdmin = false }: Props) {
return (
<div className="card">
<h2>{name}</h2>
<p>{age}歳</p>
{isAdmin && <span className="badge">管理者</span>}
</div>
);
}
props は書き換えてはいけません。
function UserCard({ name }: Props) {
name = "改変"; // NG: 親は知らないので、次の再レンダリングで元に戻る
}
React は「親から子へデータが流れる」一方向データフローを前提に組まれています。子が親のデータを勝手に変えられると、値の変更元が追跡不能になり、バグの原因が特定できなくなります。
どこで分割するか
「1ファイル200行を超えたら分割」のような機械的なルールは役に立ちません。実務では次の 3 つのどれかに当てはまったら切ります。
(1) 2箇所以上で使う
言うまでもなく再利用のためです。ただし「いつか使うかも」で切るのは早すぎます。2回目が来てから切って問題ありません。
(2) 独立した状態を持つ
アコーディオンの開閉、モーダルの表示フラグなど、その UI の中だけで完結する state を持つ部分は切り出す価値があります。親に置くと、開閉のたびに親全体が再レンダリングされます。
// 悪い例: 親が開閉状態を持つ
function JobList({ jobs }: { jobs: Job[] }) {
const [openIds, setOpenIds] = useState<number[]>([]); // 全件の開閉を親が管理
// ... 1件開くたびにリスト全体が再レンダリング
}
// 良い例: 各アイテムが自分の開閉状態を持つ
function JobItem({ job }: { job: Job }) {
const [isOpen, setIsOpen] = useState(false);
return (
<li>
<button onClick={() => setIsOpen(!isOpen)}>{job.title}</button>
{isOpen && <JobDetail job={job} />}
</li>
);
}
(3) 関心が違う
「データを取ってくる責務」と「見た目を作る責務」が同じ関数に同居していると、テストが書けません。
// ロジック側: データの取得と加工に専念
function JobListContainer() {
const { jobs, isLoading, error } = useJobs();
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <JobListView jobs={jobs} />;
}
// 表示側: props を受け取って描画するだけ
function JobListView({ jobs }: { jobs: Job[] }) {
return <ul>{jobs.map((j) => <JobItem key={j.id} job={j} />)}</ul>;
}
JobListView は props だけで動くので、Storybook にもテストにもそのまま乗ります。
状態を「持ち上げる」判断
複数のコンポーネントが同じ値を必要とするとき、その値は共通の親に置きます。これを Lifting State Up と呼びます。
function SearchPage() {
const [keyword, setKeyword] = useState(""); // 共通の親が持つ
return (
<>
<SearchInput value={keyword} onChange={setKeyword} />
<ResultCount keyword={keyword} />
<JobList keyword={keyword} />
</>
);
}
ただしこれをやりすぎると、すべての state がトップに集まり、props のバケツリレーが発生します。3階層を超えたら状態管理ライブラリ(後述の Jotai)の出番だと考えてください。
key に index を使わない
リスト描画でよく見るアンチパターンです。
// NG
{jobs.map((job, i) => <JobItem key={i} job={job} />)}
// OK
{jobs.map((job) => <JobItem key={job.id} job={job} />)}
key は React が「前回のどの要素と同じものか」を判定するための ID です。index を使うと、リストの先頭に要素を追加したときに全要素の key がズレ、React は「全部중身が変わった」と判断します。結果、各アイテムが内部に持っていた state(入力途中のテキストなど)が別の行に付いてしまいます。
並び替えも追加削除もしないリストなら index でも動きますが、後から仕様が変わったときに原因不明のバグになるので、最初から ID を使うのが安全です。
2. useState
基本
const [count, setCount] = useState(0);
useState は「値」と「更新関数」のペアを返します。setCount を呼ぶと React はそのコンポーネントを再実行し、新しい JSX を作って画面に反映します。
重要なのは、state の更新は非同期でバッチ処理されるという点です。
function handleClick() {
setCount(count + 1);
console.log(count); // まだ古い値(0のまま)
}
count はその再レンダリング時点でのスナップショットなので、setCount を呼んだ直後に読んでも変わりません。
関数型更新
同じイベント内で複数回更新したい場合、直接値を渡すと最後の1回しか効きません。
// NG: 結果は 1(3回とも count=0 を見ている)
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// OK: 結果は 3(前の値を引数で受け取る)
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
迷ったら関数型で書くというのが実務上の指針です。前の値に依存する更新はすべてこの形にしておけば事故が減ります。
オブジェクト・配列は必ず新しい参照を作る
React は Object.is による参照の比較で変更を検知します。中身を書き換えても参照が同じなら「変わっていない」と判断されます。
const [user, setUser] = useState({ name: "田中", age: 32 });
// NG: 参照が変わらないので再レンダリングされない
user.age = 33;
setUser(user);
// OK: 新しいオブジェクトを作る
setUser({ ...user, age: 33 });
配列も同様です。破壊的メソッドは使えません。
const [jobs, setJobs] = useState<Job[]>([]);
// NG: push / splice / sort / reverse は元の配列を書き換える
jobs.push(newJob);
// OK
setJobs([...jobs, newJob]); // 追加
setJobs(jobs.filter((j) => j.id !== targetId)); // 削除
setJobs(jobs.map((j) => (j.id === id ? { ...j, isFavorite: true } : j))); // 更新
setJobs([...jobs].sort((a, b) => a.id - b.id)); // ソート(コピーしてから)
ネストが深いオブジェクトを毎回スプレッドで組み直すのが辛くなったら、Immer(useImmer)の導入を検討してください。
遅延初期化
初期値の計算が重い場合、関数を渡すと初回のみ実行されます。
// NG: 再レンダリングのたびに localStorage を読む
const [data, setData] = useState(JSON.parse(localStorage.getItem("data") ?? "{}"));
// OK: 初回だけ実行される
const [data, setData] = useState(() => JSON.parse(localStorage.getItem("data") ?? "{}"));
派生値を state にしない
これが useState で最も多い設計ミスです。
// NG: jobs から計算できる値を state にしている
const [jobs, setJobs] = useState<Job[]>([]);
const [count, setCount] = useState(0);
useEffect(() => {
setCount(jobs.length); // 同期のためだけの useEffect
}, [jobs]);
// OK: レンダリング中に計算するだけ
const [jobs, setJobs] = useState<Job[]>([]);
const count = jobs.length;
他の state から計算できる値は、state にしてはいけません。 2つの state を同期させる必要が生まれた時点で、それらがズレるバグの可能性が生まれます。
計算が本当に重い場合だけ useMemo を挟みます。ただし後述の React Compiler を有効にしている場合、手動のメモ化はほぼ不要になります。
3. useEffect
そもそも何のためのフックか
useEffect は「React の外側の世界と同期を取る」ためのものです。
- API を叩く
-
document.titleを書き換える - WebSocket に接続する
-
addEventListenerを登録する - タイマーを仕掛ける
逆に言えば、この用途以外では使わないほうがいいというのが React 公式の立場です。「値が変わったら何かする」という理由だけで書いた useEffect は、たいてい不要か、間違っています。
基本形と依存配列
useEffect(() => {
// 実行したい処理
return () => {
// クリーンアップ(アンマウント時、および次の実行前に呼ばれる)
};
}, [dep1, dep2]); // 依存配列
依存配列の挙動は 3 パターンあります。
| 書き方 | 実行タイミング |
|---|---|
[] |
マウント時に1回だけ |
[value] |
マウント時 + value が変わったとき |
| 省略 | 毎回のレンダリング後(ほぼ使わない) |
クリーンアップを書き忘れない
タイマーやイベントリスナーは、片付けないとメモリリークになります。
useEffect(() => {
const timer = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(timer); // 必須
}, []);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
API 取得と競合状態
useEffect での fetch には落とし穴があります。ユーザーが素早く操作すると、古いリクエストの結果が後から届いて新しい結果を上書きすることがあります。
function JobDetail({ jobId }: { jobId: number }) {
const [job, setJob] = useState<Job | null>(null);
useEffect(() => {
let ignore = false; // このエフェクトが「古く」なったかのフラグ
(async () => {
const res = await fetch(`/api/jobs/${jobId}`);
const data = await res.json();
if (!ignore) setJob(data); // 古い場合は捨てる
})();
return () => {
ignore = true; // jobId が変わったらこのエフェクトは無効
};
}, [jobId]);
return job ? <h1>{job.title}</h1> : <Spinner />;
}
AbortController で通信自体をキャンセルする方法もあります。
useEffect(() => {
const controller = new AbortController();
fetch(`/api/jobs/${jobId}`, { signal: controller.signal })
.then((r) => r.json())
.then(setJob)
.catch((e) => {
if (e.name !== "AbortError") console.error(e);
});
return () => controller.abort();
}, [jobId]);
実務では TanStack Query などのデータ取得ライブラリを使うのが主流です。 キャッシュ、リトライ、競合状態の処理をすべて面倒見てくれるので、自前の useEffect fetch は学習目的以外では減らしていく方向で考えてください。
StrictMode で2回実行される
開発環境で console.log が2回出るのはバグではなく仕様です。React は StrictMode 下で意図的にマウント→アンマウント→再マウントを行い、クリーンアップが正しく書けているかを検査します。
2回実行されて困る処理があるなら、それはクリーンアップが不足しているサインです。本番ビルドでは1回しか走りませんが、その状態のままだと将来必ず壊れます。
useEffect が不要なケース
(1) イベントに応じた処理
// NG: state の変化を監視して送信
useEffect(() => {
if (isSubmitted) submitForm(data);
}, [isSubmitted]);
// OK: イベントハンドラで直接やる
function handleSubmit() {
submitForm(data);
}
「ユーザーが何かしたから起きること」はイベントハンドラに書きます。useEffect は「画面が表示されたから起きること」用です。
(2) 派生値の計算
前述のとおり、レンダリング中に計算すれば済みます。
(3) props が変わったら state をリセットしたい
// NG
useEffect(() => {
setDraft("");
}, [jobId]);
// OK: key を変えてコンポーネントごと作り直す
<CommentForm key={jobId} jobId={jobId} />
key が変わると React はそれを別のコンポーネントとみなし、state を初期化します。エフェクトを書くより宣言的で、実行順の問題も起きません。
4. React Router(v8)
React 本体にルーティング機能はありません。ルーターなしで作ると、アプリ全体が1つの URL に閉じ込められます。
3つのモード
v7 以降、React Router には 3 つの使い方があります。
| モード | 用途 |
|---|---|
| Declarative |
<BrowserRouter> + <Routes>。従来の最小構成 |
| Data |
createBrowserRouter + loader/action。SPA の実質標準 |
| Framework | Vite プラグインでフルスタック。Next.js の対抗 |
既存の SPA に組み込むなら Data モードを選んでおけば間違いありません。以下はそれで書きます。
インストールと import
npm install react-router
// v8: react-router-dom は存在しない
import { createBrowserRouter, Link, Outlet, useParams } from "react-router";
import { RouterProvider } from "react-router/dom";
古い記事の react-router-dom からの import は動きません。ここだけ注意してください。
ルート定義
// router.tsx
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import Layout from "./Layout";
import Home from "./pages/Home";
import JobList from "./pages/JobList";
import JobDetail from "./pages/JobDetail";
import NotFound from "./pages/NotFound";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
errorElement: <NotFound />,
children: [
{ index: true, element: <Home /> },
{ path: "jobs", element: <JobList /> },
{ path: "jobs/:id", element: <JobDetail /> },
],
},
]);
export function App() {
return <RouterProvider router={router} />;
}
ネストと Outlet
children に書いたルートは、親の <Outlet /> の位置に描画されます。ヘッダーやサイドバーを共通化する仕組みです。
// Layout.tsx
import { Outlet, NavLink } from "react-router";
export default function Layout() {
return (
<>
<header>
<NavLink to="/" end>ホーム</NavLink>
<NavLink to="/jobs">求人一覧</NavLink>
</header>
<main>
<Outlet /> {/* ここに子ルートが入る */}
</main>
<footer>© 2026</footer>
</>
);
}
NavLink は現在のパスと一致すると自動で active クラスが付きます。スタイルを関数で切り替えることもできます。
<NavLink
to="/jobs"
className={({ isActive }) => (isActive ? "text-blue-600 font-bold" : "text-gray-600")}
>
求人一覧
</NavLink>
主要なフック
import { useParams, useNavigate, useSearchParams, useLocation } from "react-router";
function JobDetail() {
const { id } = useParams(); // /jobs/:id の :id
const navigate = useNavigate(); // プログラムから遷移
const [searchParams, setSearchParams] = useSearchParams(); // ?key=value
const location = useLocation(); // 現在のパス情報
const page = Number(searchParams.get("page") ?? 1);
return (
<>
<h1>求人ID: {id}</h1>
<button onClick={() => navigate(-1)}>戻る</button>
<button onClick={() => navigate("/jobs", { replace: true })}>一覧へ</button>
<button onClick={() => setSearchParams({ page: String(page + 1) })}>次のページ</button>
</>
);
}
loader でデータを先読みする
Data モードの本領はここです。コンポーネントの描画前にデータ取得を走らせられます。
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
export async function jobLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/api/jobs/${params.id}`);
if (!res.ok) throw new Response("Not Found", { status: 404 });
return res.json();
}
export default function JobDetail() {
const job = useLoaderData() as Job;
return <h1>{job.title}</h1>; // ローディング分岐が不要
}
{ path: "jobs/:id", element: <JobDetail />, loader: jobLoader }
useEffect + useState で書いていたローディング管理が丸ごと消えます。「画面を出してから取りに行く」のではなく「取ってから画面を出す」ので、ウォーターフォール(表示→ローディング→表示のカクつき)も減ります。
認証ルートの保護
import { Navigate, Outlet, useLocation } from "react-router";
function ProtectedRoute() {
const { isLoggedIn } = useAuth();
const location = useLocation();
if (!isLoggedIn) {
// 元いた場所を state に残しておき、ログイン後に戻す
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{
element: <ProtectedRoute />, // path なしのレイアウトルート
children: [
{ path: "mypage", element: <MyPage /> },
{ path: "applications", element: <Applications /> },
],
},
],
},
]);
path を持たないルートで囲むことで、複数のページにまとめて認証をかけられます。
コード分割
import { lazy, Suspense } from "react";
const JobDetail = lazy(() => import("./pages/JobDetail"));
{
path: "jobs/:id",
element: (
<Suspense fallback={<Spinner />}>
<JobDetail />
</Suspense>
),
}
ページ単位で分割しておくと初期バンドルが軽くなります。
5. Jotai
なぜ状態管理ライブラリが必要か
props のバケツリレーが深くなると、こうなります。
App
└─ Layout(user を中継するだけ)
└─ Header(user を中継するだけ)
└─ UserMenu(ここで初めて user を使う)
中間の 2 つは user に興味がないのに、型定義と受け渡しのコードを書かされます。ページを増やすたびにこれが増殖します。
React 標準の useContext でも解決できますが、Context の値が1つでも変われば、そのContextを使っている全コンポーネントが再レンダリングされます。ユーザー情報とテーマ設定を1つの Context に入れると、テーマを変えただけでユーザー情報を使う画面まで再描画されます。
Jotai はこの問題を「状態を atom という最小単位に分割する」ことで解決します。
基本
npm install jotai
// atoms/counter.ts
import { atom } from "jotai";
export const countAtom = atom(0);
import { useAtom } from "jotai";
import { countAtom } from "./atoms/counter";
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
useState とほぼ同じ書き味ですが、atom はモジュールスコープに置かれているので、どのコンポーネントからでも同じ値を読めます。Provider の設置すら(単一ストアなら)不要です。
読み取り専用 / 書き込み専用
再レンダリングの範囲を絞るために、用途を分けたフックが用意されています。
import { useAtomValue, useSetAtom } from "jotai";
// 表示するだけ → 値が変わったときだけ再レンダリング
function CountDisplay() {
const count = useAtomValue(countAtom);
return <p>{count}</p>;
}
// 更新するだけ → 値が変わっても再レンダリングされない
function IncrementButton() {
const setCount = useSetAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}
useSetAtom を使ったボタンは、カウントが何回変わっても再描画されません。この粒度の細かさが Jotai の強みです。
派生 atom
他の atom から計算した値を作れます。Vue の computed に相当します。
export const jobsAtom = atom<Job[]>([]);
export const keywordAtom = atom("");
// 読み取り専用の派生 atom
export const filteredJobsAtom = atom((get) => {
const jobs = get(jobsAtom);
const keyword = get(keywordAtom);
if (!keyword) return jobs;
return jobs.filter((j) => j.title.includes(keyword));
});
export const jobCountAtom = atom((get) => get(filteredJobsAtom).length);
依存関係は get を呼んだ時点で自動的に追跡されます。依存配列を手で書く必要はありません。jobsAtom が変わると filteredJobsAtom と jobCountAtom が連鎖して更新されます。
書き込みロジックを持つ atom
atom(read, write) の形で、更新処理をカプセル化できます。
export const favoriteIdsAtom = atom<number[]>([]);
export const toggleFavoriteAtom = atom(
null, // 読み取りなし
(get, set, jobId: number) => {
const current = get(favoriteIdsAtom);
set(
favoriteIdsAtom,
current.includes(jobId)
? current.filter((id) => id !== jobId)
: [...current, jobId]
);
}
);
function FavoriteButton({ jobId }: { jobId: number }) {
const toggle = useSetAtom(toggleFavoriteAtom);
return <button onClick={() => toggle(jobId)}>♡</button>;
}
「どう更新するか」を atom 側に閉じ込められるので、複数の画面から同じロジックを呼べます。Redux の action / reducer に相当する役割ですが、記述量は大幅に少なくて済みます。
localStorage との同期
import { atomWithStorage } from "jotai/utils";
export const themeAtom = atomWithStorage<"light" | "dark">("theme", "light");
これだけで、値の変更が自動で localStorage に保存され、リロード後も復元されます。useEffect で読み書きするコードが不要になります。
非同期 atom
atom の read 関数は async にできます。
export const userIdAtom = atom<number | null>(null);
export const userAtom = atom(async (get) => {
const id = get(userIdAtom);
if (id === null) return null;
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<User>;
});
function UserProfile() {
const user = useAtomValue(userAtom); // Suspense で解決される
return <h1>{user?.name}</h1>;
}
// 親側で Suspense を張る
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
userIdAtom が変われば自動で再取得されます。ローディング state の管理を自前で書く必要がありません。
他ライブラリとの比較
| Jotai | Zustand | Redux Toolkit | |
|---|---|---|---|
| 発想 | 状態をボトムアップに組む | ストアをトップダウンに定義 | 単一ストア + reducer |
| 記述量 | 最小 | 少ない | 多い |
| 再レンダリング制御 | atom 単位で自動 | セレクタで手動 | セレクタで手動 |
| DevTools | あり(別パッケージ) | あり | 非常に強力 |
| 向いている規模 | 小〜中 | 小〜大 | 大 |
個人開発〜中規模なら Jotai、大規模で厳格な状態遷移管理が必要なら Redux Toolkit、というのが 2026 年時点の相場観です。Zustand はその中間で、どちらの性質も持っています。
使い分けの指針
Jotai を入れても、すべての state を atom にする必要はありません。
-
useStateのまま: そのコンポーネント内で完結する(モーダルの開閉、入力途中の値) -
atomにする: 複数の画面をまたぐ(ログインユーザー、テーマ、カート、お気に入り) - loader / TanStack Query: サーバーから取得したデータ
3つ目が特に重要です。サーバーのデータはクライアントの「状態」ではなく「キャッシュ」です。 これを Jotai や Redux で管理し始めると、更新・無効化・再取得のロジックを全部自前で書くことになります。データ取得専用の仕組みに任せてください。
6. まとめて動かす
ここまでの要素を組み合わせた最小構成です。
// atoms/search.ts
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const keywordAtom = atom("");
export const favoriteIdsAtom = atomWithStorage<number[]>("favorites", []);
// pages/JobList.tsx
import { useAtom, useAtomValue } from "jotai";
import { useLoaderData, Link } from "react-router";
import { keywordAtom, favoriteIdsAtom } from "../atoms/search";
export async function jobListLoader() {
const res = await fetch("/api/jobs");
return res.json();
}
export default function JobList() {
const jobs = useLoaderData() as Job[];
const [keyword, setKeyword] = useAtom(keywordAtom);
const favoriteIds = useAtomValue(favoriteIdsAtom);
const filtered = keyword
? jobs.filter((j) => j.title.includes(keyword))
: jobs;
return (
<>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="キーワード"
/>
<p>{filtered.length}件 / お気に入り {favoriteIds.length}件</p>
<ul>
{filtered.map((job) => (
<li key={job.id}>
<Link to={`/jobs/${job.id}`}>{job.title}</Link>
</li>
))}
</ul>
</>
);
}
- データ取得は loader(
useEffectを書かない) - 画面をまたぐ検索キーワードとお気に入りは atom
- 絞り込み結果はレンダリング中に計算(state にしない)
- 遷移は Link
この4つの原則を守るだけで、コードの見通しは大きく変わります。
7. 2026年時点の補足: React Compiler
2025年10月に React Compiler 1.0 が正式リリースされ、Next.js / Vite / Expo に統合済みです。
これはビルド時にコードを解析して自動でメモ化を挿入するツールで、有効にすると useMemo / useCallback / React.memo を手で書く場面が大幅に減ります。
// Compiler ありなら、これで十分最適化される
function JobList({ jobs, keyword }: Props) {
const filtered = jobs.filter((j) => j.title.includes(keyword));
return <ul>{filtered.map((j) => <JobItem key={j.id} job={j} />)}</ul>;
}
これから React を学ぶなら、手動メモ化の学習優先度は下げて構いません。まず正しく動くコードを書き、パフォーマンス問題が実測で確認できてから対処する、という順序で問題ありません。
なお、Compiler が正しく動く前提として「React のルール」(props を変更しない、レンダリング中に副作用を起こさない)を守っている必要があります。この記事で挙げてきた原則がそのまま効いてきます。
8. チェックリスト
現場で書く前に、これだけ確認しておけば大きな事故は防げます。
コンポーネント設計
- props を書き換えていない
-
keyに index を使っていない - 状態を持ち上げすぎていない(3階層超えたら atom を検討)
useState
- 前の値に依存する更新は関数型で書いている
- オブジェクト・配列は新しい参照を作っている
- 他の state から計算できる値を state にしていない
useEffect
- 「外部との同期」以外の目的で使っていない
- タイマー・リスナーのクリーンアップを書いている
- fetch の競合状態に対処している(または専用ライブラリを使っている)
- StrictMode の2回実行で壊れない
React Router
-
react-router-domから import していない(v8 では削除済み) - データ取得を loader に寄せている
- 認証はレイアウトルートでまとめてかけている
Jotai
- ローカルで完結する state まで atom にしていない
- サーバーデータを atom で抱え込んでいない
-
更新専用の箇所で
useSetAtomを使っている
おわりに
React は「できること」が多い分、正解が見えにくいフレームワークです。ただ、詰まるポイントは今回挙げた範囲にほぼ収まります。
特に効くのは useEffect を減らすという一点です。派生値はレンダリング中に計算し、イベント起因の処理はハンドラに書き、データ取得は loader やライブラリに任せる。これだけで useEffect は「本当に外部と同期するとき」だけになり、コードが読めるようになります。
バージョンの変化が速い領域なので、実装前に公式ドキュメントで最新の API を確認することをおすすめします。
参考
- React 公式ドキュメント: https://ja.react.dev/
- React Router: https://reactrouter.com/
- Jotai: https://jotai.org/