Building React

React マスターへのロードマップ – “作って動かしながら深める”10 ステップ
フェーズ | ゴール (習得できている状態) | 主キーワード / 観点 | 推奨アウトプット例 | 深掘りネクストアクション |
---|---|---|---|---|
0. 前提整備 | ES2015+ が手癖・TS 型注釈に抵抗ゼロ |
let/const , async-await, union / generics, strict mode |
FizzBuzz を TS & ESLint 通過で実装 | 型推論の仕組みを公式ハンドブックで確認 |
1. React 基礎 API | “UI = state の関数” を体得 | 関数コンポーネント, JSX, useState , props |
カウンター & Todo (props で色変更) | DevTools で再レンダー観察 |
2. ライフサイクル & Side-Effects | 「いつ DOM が確定するか」を説明できる |
useEffect , cleanup, batching, Strict Mode 二重実行 |
API フェッチ付き Todo | “Effect と描画順序” 図を自作 |
3. コンポーネント設計パターン | 再利用・組み合わせを意識した API 設計 | Container / Presentational, slots, compound pattern | モーダルライブラリ自作 | “props drilling vs composition” 記事を書く |
4. Hooks 応用 & カスタム Hooks | ロジック共有を hooks に切り出せる |
useMemo , useCallback , useRef , custom hook 規約 |
useDebounce/useInterval 実装 | ESLint-rules-of-hooks を読む |
5. 型安全なコンポーネント API | TS の conditional / overload で型安全 UI | polymorphic as prop, discriminated union |
汎用 Button (icon + loading) | Storybook + Controls で型自動生成 |
6. 状態管理 & サーバー State | ローカル vs グローバルを選定できる | Context, reducer, React Query / SWR, optimistic update | GitHub Issue Viewer (pagination, cache) | TanStack Query DevTools 解析 |
7. パフォーマンス最適化 | “再レンダー原因 → 対策”を即答 | ディフ・Reconciliation, React.memo , Suspense, concurrent features (useTransition , useDeferredValue ) |
大規模リスト 5,000 件描画 | Chrome Profiler flame-chart 解析 |
8. テスト & アクセシビリティ | UI 仕様をテストコードで担保 | RTL queries, jest-dom, axe-core, ARIA roles | SearchBox を a11y 満点+テスト通過 | INP 計測・改善ログを書く |
9. サーバー連携 / Next.js 橋渡し | “React 部”と“Next.js 部”の責務分離が説明可 | SSR/SSG, React 18 streaming, RSC, use client 境界 |
ブログ SPA → SSG 移行 | Route Handlers + Suspense @Edge 検証 |
10. プロダクション運用 | 監視・CI/CD・依存更新を習慣化 | Playwright, Sentry, Web Vitals, Dependabot | E2E + Vercel Preview 自動化 | 本番障害ポストモーテムを書く |

0
フェーズ **0. 前提整備**
目標:ES2015+(モダン JavaScript)と TypeScript の基本構文が「無意識に書ける」状態
── React / Next.js のドキュメントやサンプルを読んだとき、“構文そのもの”で詰まらないようにするためのステップです。
1. まず環境を 5 分で準備
手順 | コマンド / ファイル | 目的 |
---|---|---|
Node 18+ & pnpm をインストール | curl -fsSL https://get.pnpm.io/install.sh | sh - |
ES202x 機能と TypeScript を最新で使う |
プロジェクト作成 | bash<br>mkdir ts-playground && cd $_<br>pnpm init -y<br>pnpm add -D typescript@latest eslint @typescript-eslint/{eslint-plugin,parser} ts-node<br> |
“TS + ESLint” の最小セット |
tsconfig.json | json<br>{ "compilerOptions": { "target":"ES2020","moduleResolution":"node","strict":true,"esModuleInterop":true,"skipLibCheck":true } }<br> |
strict: true – 型安全学習を最初から |
.eslintrc.cjs | js<br>module.exports = { parser: '@typescript-eslint/parser', extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], };<br> |
IDE で即エラーが見える状態 |
💡 学びポイント
pnpm
は ハードリンクによる高速インストール & disk save が売り。実務でも人気です。skipLibCheck: true
は速度改善の定番。型精度が落ちるわけではないので入れておくと吉。
2. “FizzBuzz” を ES2015+ & TS で実装してみる
// FizzBuzz を「関数&型安全」で書くサンプル
/**
* 1 〜 max までを FizzBuzz 変換した配列を返す
* @param max - 1 以上の整数
*/
export const fizzBuzz = (max: number): (number | 'Fizz' | 'Buzz' | 'FizzBuzz')[] => {
if (!Number.isInteger(max) || max < 1) {
throw new Error('max must be a positive integer');
}
// Array.from + map で 1..max を生成
return Array.from({ length: max }, (_, i) => {
const n = i + 1;
const fizz = n % 3 === 0;
const buzz = n % 5 === 0;
return fizz && buzz
? 'FizzBuzz'
: fizz
? 'Fizz'
: buzz
? 'Buzz'
: n; // 型推論で number
});
};
/** CLI 実行: `pnpm ts-node src/fizzbuzz.ts 15` */
if (import.meta.main) {
const max = Number(process.argv[2] ?? '15');
fizzBuzz(max).forEach((v) => console.log(v));
}
例で使っている ES/TS 構文チェックポイント
行 | 構文 / 概念 | ざっくり意図 |
---|---|---|
const fizzBuzz = (max: number): (…)[] |
アロー関数 + 型注釈 | 引数・戻り値を“最も狭い型”で固定 |
number | 'Fizz' … |
ユニオン型 | 値が取りうる選択肢を列挙して型安全 |
Array.from({ length: max }, (_, i) => …) |
スプレッド無しの配列生成 |
for より宣言的・副作用ゼロ |
import.meta.main |
ES Modules の実行判定 | Node18+ で使える“CLI としての自己判定” |
Why this matters
React/Next.js のチュートリアルは async/await, 分割代入, スプレッド, ユニオン型だらけ。
ここで指が止まると React 学習が進みません。
3. ESLint を “赤波線ゼロ” に通す
pnpm exec eslint src --ext .ts
-
@typescript-eslint/recommended
で no-any/-unused-vars など基本ルールが有効。 -
型注釈を付け過ぎると
no-explicit-any
/no-inferrable-types
に怒られる → “型推論を信じる”クセ付けに◎。
4. 「手癖」になるまで回すミニドリル
Drill | 使う新構文 | ヒント |
---|---|---|
1 行で偶数判定 (配列で返す) | destructuring | map(([idx, n]) => …) |
Promise 版 FizzBuzz (100ms wait/step) | async-await | await new Promise(r => setTimeout(r,100)) |
ジェネリック reverse<T>() | generics | function reverse<T>(arr:T[]):T[] |
ポイント:“型注釈を書く場所 < 型推論を信じる場所” を意識すると React の Props 設計がグッと楽になります。
5. 理解を深めるネクストアクション
-
公式 TS Handbook – Basic → Advanced まで流し読み
- 導入部は 30 分で読めるボリューム。後で辞書的にも使えます。
-
ES2022 以降の“新構文”を 1 つ選んで調査 & 記事化
- 例:
Array.at
,??=
, Top-Level await – “React コードでどう活きるか?”を書き出す。
- 例:
-
チーム ESLint ルールを読み、「なぜそのルールが必要か」議論メモを残す
- 暗黙知を言語化する練習 → コードレビュー力向上に直結。
-
型エラー 0 → コードレビュー提出 をルーティンに
- CI で
pnpm lint && tsc --noEmit
を必ず通す設定にすると習慣化しやすい。
- CI で
まとめ ― 前提整備フェーズの評価基準
評価指標 | 合格ライン |
---|---|
コーディング速度 | FizzBuzz (1〜100) が 5 分以内に書ける |
ESLint / tsc | どちらもエラー 0 でコミット |
アウトプット | 公式 Handbook の “気づき”を自ブログ or 社内 Doc に 1 本 |

1
フェーズ 1 ― **React 基礎 API**
ゴール:関数コンポーネント・JSX・
useState
・props の4点を「説明できる+迷わず書ける」レベルへ引き上げる
0. ウォームアップ ─ React を“関数”として眺める
UI = f(state)
- state が変わったら f を再実行し直し、差分だけ DOM へ当て込む
- React が提供するのは「再実行を自動で呼ぶ仕組み」と「差分パッチ」の2つ
- いま学ぶ
useState
と props は “f に渡す入力” をどう用意・受け渡すかに集中している
1. 超最小プロジェクト (5 分)
pnpm create vite@latest react-basics -- --template react-ts
cd react-basics && pnpm install
rm src/App.css src/index.css # デザインは要らない
- Vite =ホットリロード 0.2 秒 → 学習ループ最速
-
TypeScript テンプレを選んだ時点で
tsconfig.json
と@types/react
が完備
2. サンプル① Counter ― “useState だけ”全集中
src/components/Counter.tsx
import { useState } from 'react';
export const Counter = () => {
// ❶ [state, updater] のペアを分割代入
const [count, setCount] = useState(0);
// ❷ updater は「新しい state」を渡す関数
const increment = () => setCount((prev) => prev + 1);
return (
<button
onClick={increment}
style={{ padding: '0.5rem 1rem', fontSize: '1.2rem' }}
>
Clicked {count} times
</button>
);
};
行 | 学びポイント | よくある NG | Why? |
---|---|---|---|
4 |
useState(0) 初期値 = 型
|
useState<number>() と書く |
型推論を殺し冗長 |
7 | 関数型 updater | setCount(count + 1) |
複数バッチ更新で競合発生 |
9 |
JSX = JS:{} 内は完全な JS |
onClick={increment()} |
すぐ実行される |
3. サンプル② TodoApp ― props と再レンダーを観測
3-1. 型と TodoItem
src/components/TodoItem.tsx
export type Todo = { id: number; text: string; completed: boolean };
type Props = {
todo: Todo;
doneColor?: string; // 親から自由に変えられる
};
export const TodoItem = ({ todo, doneColor = 'gray' }: Props) => (
<li
style={{
color: todo.completed ? doneColor : undefined,
textDecoration: todo.completed ? 'line-through' : undefined,
cursor: 'pointer',
}}
>
{todo.text}
</li>
);
3-2. TodoApp 本体
src/components/TodoApp.tsx
import { useState } from 'react';
import { TodoItem, Todo } from './TodoItem';
const seed: Todo[] = [
{ id: 1, text: 'Buy milk', completed: false },
{ id: 2, text: 'Read React docs', completed: true },
];
export const TodoApp = () => {
const [todos, setTodos] = useState<Todo[]>(seed);
const toggle = (id: number) =>
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)),
);
return (
<>
<h2>Todos ({todos.length})</h2>
<ul>
{todos.map((t) => (
<div key={t.id} onClick={() => toggle(t.id)}>
{/* props の例:完了色を上位で一元管理 */}
<TodoItem todo={t} doneColor="green" />
</div>
))}
</ul>
</>
);
};
3-3. App へ組み込む
src/App.tsx
import { Counter } from './components/Counter';
import { TodoApp } from './components/TodoApp';
export default function App() {
return (
<>
<h1>React 基礎 API Demo</h1>
<Counter />
<hr />
<TodoApp />
</>
);
}
4. DevTools で “再レンダーの真実” を覗く
-
pnpm dev
で起動 → React DevTools → Components -
TodoApp
を開いたまま Todo をクリック- TodoItem だけが再レンダー → props が変わった子だけが走る を確認
-
Profiler で録画 → 余計な赤スパイクがないか flame-graph をチェック
単方向データフロー =「親 state が変わる → 子へ新 props → 必要分だけ描画」の仕組みを視覚で確認
5. 5分ドリルで “指” に染み込ませる
Drill | 完成イメージ | 学びポイント | ヒント |
---|---|---|---|
① ダークモード切替 | ボタン1つで App の背景切替 | 状態を最上位へ | const [dark, setDark] = useState(false) |
② フィルタリング | TodoApp に「未完了のみ表示」スイッチ | state 分割 & 再レンダー縮小 | useState<'all' | 'undone'>('all') |
③ キー違反実験 |
key={index} で並べ替え |
Diff アルゴリズムの罠を体感 | 配列を reverse() してみる |
6. よく詰まる FAQ
症状 | 原因 | 処方箋 |
---|---|---|
クリック2回で1増える…? |
setCount(count + 1) を連続呼び |
関数型 updater を使う |
state を直接 todos[0].done = true
|
不変更新違反 | 新オブジェクトを作る (... ) |
再レンダー多すぎ | 親が巨大 & props が毎回新しい参照 |
React.memo や state 位置見直し |
7. フェーズ1の “クリア条件”
指標 | 合格ライン |
---|---|
コンポーネント数 | 5 以上自作(Atomic + Composite を混在) |
DevTools log | flame-graph で「なぜ再レンダー?」を説明できる |
コードレビュー | “props or state の選択理由”を口頭で説明可能 |

2
**ライフサイクル & Side-Effects**
ゴール:
- 「描画 ➜ エフェクト ➜ クリーンアップ」 のタイミングを言語化できる
useEffect
を安全に書く 4 パターン(マウント時 / 依存更新時 / アンマウント時 / 非同期処理キャンセル)を実装できる- React 18 の自動バッチ更新と Strict Mode 二重実行 を説明できる
0. セットアップ(前フェーズの Vite プロジェクトを継続利用)
pnpm add axios # API 呼び出し用(fetch でも可)
pnpm add -D @types/lodash # サンプルで lodash も少し使う
1️⃣ 「マウント時だけ 1 回」: Todo を外部 API から取得
src/components/TodoFetcher.tsx
import { useEffect, useState } from 'react';
import axios from 'axios';
type Todo = { id: number; title: string; completed: boolean };
export const TodoFetcher = () => {
const [todos, setTodos] = useState<Todo[] | null>(null);
const [error, setError] = useState<string | null>(null);
/* ------------------------- ❶ エフェクト本体 ------------------------- */
useEffect(() => {
const controller = new AbortController(); // ← キャンセル用
(async () => {
try {
const res = await axios.get<Todo[]>(
'https://jsonplaceholder.typicode.com/todos?_limit=5',
{ signal: controller.signal },
);
setTodos(res.data);
} catch (e) {
if (axios.isCancel(e)) return; // アンマウント時の中断は無視
setError((e as Error).message);
}
})();
/* ----------- ❷ クリーンアップ: アンマウント時に axios を中断 ----------- */
return () => controller.abort();
}, []); // ← ❸ 依存配列が [] なので「マウント時 1 回だけ」
/* --------------------------- 画面描画 --------------------------- */
if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
if (!todos) return <p>Loading…</p>;
return (
<ul>
{todos.map((t) => (
<li key={t.id}>
{t.title} {t.completed && '✅'}
</li>
))}
</ul>
);
};
チェックポイント | “なぜこう書く?” |
---|---|
AbortController | 遅い通信中にページ遷移すると state 更新 on unmounted 警告発生 → 中断で解決 |
無名 async IIFE |
useEffect コールバック自体は 同期関数しか返せない 規約 |
空依存 []
|
一回だけ 実行を保証(Strict Mode では*開発モード限定で二回*呼ばれる) |
2️⃣ 「依存変数が変わるたび」: ウィンドウ幅を追跡
src/hooks/useWindowWidth.ts
import { useEffect, useState } from 'react';
export const useWindowWidth = () => {
const [width, setWidth] = useState(() => window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
// ★ クリーンアップ ― removeEventListener
return () => window.removeEventListener('resize', handler);
}, []); // ← コールバックは一度だけ登録、handler 内で最新値を set
return width;
};
- 依存が空でも OK:リスナーは一度付けば良い
- setState は自動バッチ対象(React 18 以降:resize が連続しても 1 フレーム内にまとめて再描画)
3️⃣ 「依存リスト付き」: 検索クエリが変わるたびデータ取得
src/components/UserSearch.tsx
import { ChangeEvent, useEffect, useState } from 'react';
import axios from 'axios';
import _debounce from 'lodash/debounce';
type User = { id: number; login: string; avatar_url: string };
export const UserSearch = () => {
const [q, setQ] = useState('react');
const [users, setUsers] = useState<User[]>([]);
/* --------- 入力フィールド変更で q 更新(自動バッチ対象) --------- */
const onChange = (e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value);
/* -- 依存に [q] を渡すと「q が変わった時だけ」 Effect を再評価 -- */
useEffect(() => {
if (!q) return;
const controller = new AbortController();
const fetchUsers = async () => {
const res = await axios.get<{ items: User[] }>(
`https://api.github.com/search/users?per_page=10&q=${encodeURIComponent(
q,
)}`,
{ signal: controller.signal },
);
setUsers(res.data.items);
};
// ❶ 300ms 入力が止まるまで待って API 呼び出し
const debounced = _debounce(fetchUsers, 300);
debounced();
// ❷ クリーンアップ: q が変わる/アンマウント時 → axios 中断 & debounce 解除
return () => {
controller.abort();
debounced.cancel();
};
}, [q]);
return (
<>
<input value={q} onChange={onChange} placeholder="GitHub user" />
<ul>
{users.map((u) => (
<li key={u.id}>
<img src={u.avatar_url} width={32} style={{ verticalAlign: 'middle' }} />{' '}
{u.login}
</li>
))}
</ul>
</>
);
};
依存配列の鉄則:
- 参照する外部変数は 必ず依存に列挙(ESLint
react-hooks/exhaustive-deps
が守ってくれる)- “API キー” や Config を props で受け取る場合、memoized/useRef で不変参照にすると不要な再フェッチを防げる
4️⃣ React 18 自動バッチ更新を目で確認
src/components/BatchedUpdatesDemo.tsx
import { useState } from 'react';
export const BatchedUpdatesDemo = () => {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const asyncInc = () => {
// setTimeout 内でも React18 以降は同一タスクなら自動バッチ!
setTimeout(() => {
setA((v) => v + 1);
setB((v) => v + 1);
}, 0);
};
console.log('render', { a, b }); // DevTools コンソールで回数を観察
return <button onClick={asyncInc}>Async +1</button>;
};
- ブラウザ DevTools コンソール を開きクリック
-
render {a: X, b: X}
が 1回 だけ出力されればバッチ成功 - React 17 以前は 2 回描画 → useEffect で多重 fetch などが問題になりやすかった
5️⃣ Strict Mode 二重実行を体験して「安全なエフェクト」へ
src/main.tsx
(Vite のエントリ)で StrictMode をコメント IN
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-
開発モード限定で 「Mount ➜ Unmount ➜ Mount」 を即座に繰り返す
-
目的:副作用がクリーンアップ前提で正しく書けているか を早期に検出
-
症状が出たら?:
-
useEffect
内で subscribe ↔ unsubscribe が対になっているか確認 -
Promise.then 内で state 更新 → アンマウント後の警告 →
AbortController
やisMounted
フラグで対処
-
6️⃣ ミニドリル(アウトプット課題)
Drill | 必須要件 | 学びポイント |
---|---|---|
① WebSocket チャットログ |
useEffect で接続→onmessage 追加、return で close |
“永続接続” 型エフェクト |
② IntersectionObserver 画像遅延読込 | 画面内に入ったら src を差し替え、return で解除 |
DOM API と協調 |
③ 60 秒タイマー |
setInterval ↔ clearInterval
|
「クリーンアップ忘れ」体験 |
7️⃣ フェーズ評価チェックリスト
- Strict Mode ON でも 警告ゼロ
- AbortController/removeEventListener/clearInterval いずれかを実装
- DevTools Profiler で Effect → Commit → Cleanup の流れを説明できる
- ブログ or 社内 Doc に 「依存配列完全ガイド」 をまとめて共有

3
フェーズ 3 ― **コンポーネント設計パターン**
React で「小さな UI ピースを組み替えて拡張する」ための3大パターンを、写経できるサンプル付きで解説します。
- Container / Presentational(状態と見た目の分離)
- Slot(Render Prop)(“差し込み口”を開ける)
- Compound Components(親子で暗黙に連携する API)
0. デモ題材:Todo 一覧 + フィルタ UI
今回は “同じ UI を3パターンで書き替える” ことで差異を体感します。
共通要件は下記。
type Todo = { id: number; text: string; done: boolean };
const seed: Todo[] = [
{ id: 1, text: 'Buy milk', done: false },
{ id: 2, text: 'Read React docs', done: true },
];
1️⃣ Container / Presentational パターン
目的:ロジックをテストしやすく、UI を差し替えやすくする
1-A. Presentational(純粋 UI)
// src/components/TodoListView.tsx
type ViewProps = {
todos: Todo[];
onToggle: (id: number) => void;
};
export const TodoListView = ({ todos, onToggle }: ViewProps) => (
<ul>
{todos.map((t) => (
<li
key={t.id}
onClick={() => onToggle(t.id)}
style={{
cursor: 'pointer',
textDecoration: t.done ? 'line-through' : undefined,
}}
>
{t.text}
</li>
))}
</ul>
);
- 状態を一切持たない
- Storybook で単体プレビューしやすい
1-B. Container(状態管理 + API 呼び出し)
// src/containers/TodoListContainer.tsx
import { useState } from 'react';
import { TodoListView } from '../components/TodoListView';
export const TodoListContainer = () => {
const [todos, setTodos] = useState(seed);
const toggle = (id: number) =>
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
return <TodoListView todos={todos} onToggle={toggle} />;
};
メリット
- Container を差し替えれば Next.js Server Component でも React Native でも使い回せる
- UI デザイナーは Presentational だけ弄れば OK
2️⃣ Slot / Render Prop パターン
目的:子要素の配置場所・描画方法を“親側で指定”できるようにする
※ 実用では Headless UI(Radix UI など)や MUI のslots
API がこの形
実装
// src/components/Filterable.tsx
import { ReactNode, useState } from 'react';
type SlotProps = {
todos: Todo[];
toggle(id: number): void;
};
type Props = {
/** “描画スロット”を受け取る Render Prop */
children: (p: SlotProps) => ReactNode;
};
export const Filterable = ({ children }: Props) => {
const [todos, setTodos] = useState(seed);
const [showDone, setShowDone] = useState(false);
const toggle = (id: number) =>
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
const visible = todos.filter((t) => (showDone ? t.done : true));
return (
<>
<label>
<input
type="checkbox"
checked={showDone}
onChange={(e) => setShowDone(e.target.checked)}
/>{' '}
show done only
</label>
{/* ここが“Slot” */}
{children({ todos: visible, toggle })}
</>
);
};
使い方
<Filterable>
{({ todos, toggle }) => (
<TodoListView todos={todos} onToggle={toggle} />
)}
</Filterable>
ポイント
- 子が関数なので どんな UI でも差し込める
- 依存が増えすぎると props が肥大化する → 複数 slot が要るなら Compound を検討
3️⃣ Compound Components パターン
目的:複数の子コンポーネント間で暗黙に状態を共有し、DSL 的に書ける
例)<Tabs><Tabs.List/> <Tabs.Panel/></Tabs>
3-A. 親コンポーネント
// src/components/Tabs/Tabs.tsx
import {
createContext,
ReactElement,
useContext,
useState,
ReactNode,
} from 'react';
type Ctx = { value: string; setValue: (v: string) => void };
const TabCtx = createContext<Ctx | null>(null);
export const Tabs = ({ children, defaultValue }: { children: ReactNode; defaultValue: string }) => {
const [value, setValue] = useState(defaultValue);
return (
<TabCtx.Provider value={{ value, setValue }}>{children}</TabCtx.Provider>
);
};
3-B. 子コンポーネント
// src/components/Tabs/TabList.tsx
export const List = ({ children }: { children: ReactNode }) => (
<div style={{ display: 'flex', gap: 8 }}>{children}</div>
);
// src/components/Tabs/Tab.tsx
export const Tab = ({ value, children }: { value: string; children: ReactNode }) => {
const ctx = useContext(TabCtx)!;
const active = ctx.value === value;
return (
<button
onClick={() => ctx.setValue(value)}
style={{ fontWeight: active ? 'bold' : undefined }}
>
{children}
</button>
);
};
// src/components/Tabs/Panel.tsx
export const Panel = ({ value, children }: { value: string; children: ReactNode }) => {
const ctx = useContext(TabCtx)!;
return ctx.value === value ? <div>{children}</div> : null;
};
3-C. 名前空間っぽく束ねる
export const TabsNamespace = Object.assign(Tabs, {
List,
Tab,
Panel,
});
3-D. 使い方(DSL!)
<TabsNamespace defaultValue="all">
<TabsNamespace.List>
<TabsNamespace.Tab value="all">All</TabsNamespace.Tab>
<TabsNamespace.Tab value="done">Done</TabsNamespace.Tab>
</TabsNamespace.List>
<TabsNamespace.Panel value="all">
<TodoListView todos={seed} onToggle={() => {}} />
</TabsNamespace.Panel>
<TabsNamespace.Panel value="done">
<TodoListView todos={seed.filter((t) => t.done)} onToggle={() => {}} />
</TabsNamespace.Panel>
</TabsNamespace>
メリット
- 親子ノードの位置関係=API なので JSX が読みやすい
- Context で共有 → props drilling 解消
- 追加要素(
<TabsNamespace.Indicator>
など)も 親を触らず拡張可能
デメリット
- Context による再レンダー波及 → 大量パネルではメモ化が必要
- “親に近い子しか配置できない” 制約(
<Tab>
を離れた場所に置けない)
4️⃣ いつどのパターンを選ぶか?
シナリオ | ベストパターン | 理由 |
---|---|---|
API 通信や状態管理を分けたい | Container / Presentational | テスト容易・UI キットと交換自在 |
UI の穴あけ・カスタム描画を許可 | Slot(Render Prop) | “描画ロジックを注入”する柔軟性 |
複数子コンポーネントで 1 つの機能 | Compound | タブ・アコーディオン・フォームなど DSL 的に書ける |
5️⃣ ミニドリルで手を動かす
Drill | パターン | 必須条件 |
---|---|---|
① Pagination 付きリスト | Container/Presentational | Presentational を Storybook に登録 |
② Tooltip コンポーネント | Slot |
children だけ渡す最小 API |
③ Accordion コンポーネント | Compound | 複数パネル開閉 & TypeScript 型安全 |
6️⃣ 達成チェック
- 3パターンを社内 UI ライブラリで1つずつ採用し PR を通した
- コンポーネント設計選定フローを図解してチーム共有
- “props drilling vs composition” 記事化 → フィードバックをもらう

4
フェーズ 4 ― **Hooks 応用 & カスタム Hooks**
ゴール
useMemo
/useCallback
/useRef
が「なぜ必要か?」を説明できる- 再レンダー最適化とロジック再利用を“フック化”して実装できる
- ESLint
rules-of-hooks
を破らないカスタム Hook を書ける
0. なぜ “メモ化” が要るのか?
┌ 再レンダー
│ ├ UI 再計算 ← ここが高コストなら遅い
│ └ コールバック再生成 → 子に props で渡ると **再レンダー連鎖**
└ state 変更
- useMemo … 高コスト値をキャッシュして再計算を抑制
- useCallback … 同じ関数参照を保ち、子の再レンダーを防止
- useRef … 再レンダーに関係ない“箱”(ミュータブル・DOM node・Timer ID など)を保持
useMemo
― 重い計算をキャッシュ
1️⃣ src/components/PrimeChecker.tsx
import { useMemo, useState } from 'react';
// 素朴な O(√n) 素数判定(わざと重め)
const isPrime = (n: number) => {
if (n < 2) return false;
for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) return false;
}
return true;
};
export const PrimeChecker = () => {
const [num, setNum] = useState(1);
/* ❶ num が変わった時だけ再計算。結果は next render までキャッシュ */
const prime = useMemo(() => isPrime(num), [num]);
return (
<>
<input
type="number"
value={num}
onChange={(e) => setNum(Number(e.target.value))}
/>
<p>{num} is {prime ? 'Prime' : 'Composite'}</p>
</>
);
};
要点
useMemo(fn, [deps])
は 次の描画まで 値を保持- 依存を外すと“毎回再評価”→ パフォーマンスどころか逆効果
useCallback
― 関数参照の安定化
2️⃣ 2-1. 子コンポーネント
src/components/ExpensiveList.tsx
import React from 'react';
export const ExpensiveList = React.memo(
({ items, onSelect }: { items: string[]; onSelect: (v: string) => void }) => {
console.log('ExpensiveList render'); // ← 再レンダー回数を観察
return (
<ul>
{items.map((it) => (
<li key={it} onClick={() => onSelect(it)}>
{it}
</li>
))}
</ul>
);
},
);
2-2. 親コンポーネント
import { useCallback, useState } from 'react';
import { ExpensiveList } from './ExpensiveList';
export const CallbackDemo = () => {
const [count, setCount] = useState(0);
const items = ['Apple', 'Banana', 'Cherry'];
/* ❶ 無限に新インスタンス → memo 意味なし */
// const handleSelect = (v: string) => console.log(v);
/* ❷ useCallback で items が不変なら“同じ関数参照”を維持 */
const handleSelect = useCallback((v: string) => console.log(v), []);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>parent +1 ({count})</button>
<ExpensiveList items={items} onSelect={handleSelect} />
</>
);
};
- ❶ を使うと
ExpensiveList
が毎回console.log('render')
- ❷ に替えると
count
だけ変えた再レンダーでは子がスキップ
useRef
― “再レンダーしない箱”
3️⃣ 3-1. 前回の値を覚える
import { useEffect, useRef, useState } from 'react';
export const PreviousValue = () => {
const [value, setValue] = useState('');
const prev = useRef<string | null>(null); // ❶ ミュータブルな箱
useEffect(() => {
prev.current = value; // ❷ 再レンダーしない更新
}, [value]);
return (
<>
<input value={value} onChange={(e) => setValue(e.target.value)} />
<p>prev: {prev.current ?? '---'}</p>
</>
);
};
3-2. DOM に直接アクセス(フォーカス制御)
import { useRef } from 'react';
export const AutoFocusInput = () => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<input
ref={inputRef}
onKeyDown={(e) => {
if (e.key === 'Escape') inputRef.current?.blur();
}}
placeholder="Press Esc to blur"
/>
);
};
4️⃣ カスタム Hook ― 規約と実装パターン
4-1. 3つの鉄則
-
必ず
use
から始まる関数名 – ESLint ルールが検知 - フックはトップレベルで呼ぶ – if や loop の中で呼ばない
- 依存 & クリーンアップを内包 – “使う側”は副作用を気にしなくて済む
useDebounce
– 型安全実装
4-2. src/hooks/useDebounce.ts
import { useEffect, useState } from 'react';
export const useDebounce = <T,>(value: T, delay = 300) => {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // ★ クリーンアップ
}, [value, delay]);
return debounced;
};
利用例
const q = useDebounce(searchText, 500);
useEffect(() => fetchData(q), [q]);
useInterval
– useRef
でハンドラ保持
4-3. src/hooks/useInterval.ts
import { useEffect, useRef } from 'react';
export const useInterval = (callback: () => void, ms: number | null) => {
const saved = useRef<() => void>();
/* 最新の callback を保持(setInterval 外で更新) */
useEffect(() => {
saved.current = callback;
});
useEffect(() => {
if (ms === null) return; // null で停止
const id = setInterval(() => saved.current?.(), ms);
return () => clearInterval(id);
}, [ms]);
};
利用例
useInterval(() => setTick((t) => t + 1), 1000); // 1 秒ごとに+1
5️⃣ ESLint rules-of-hooks を武器にする
-
react-hooks/rules-of-hooks
… 呼び出し順の違反を即検出 -
react-hooks/exhaustive-deps
… 依存配列の漏れを警告-
// eslint-disable-next-line react-hooks/exhaustive-deps
は 最後の手段 - 依存に入れたくない関数は
useCallback
oruseRef
で不変参照にする
-
6️⃣ ミニドリル
Drill | 要件 | ねらい |
---|---|---|
① useToggle |
boolean ↔︎ boolean を返す簡易 Hook |
Hook 命名 & 型推論 |
② usePrevious | 任意型 T の前回値を返す Hook |
useRef 応用 |
③ useLocalStorageState | state と localStorage を同期 |
useEffect +useMemo 総合 |
7️⃣ 達成チェック
-
useDebounce
とuseInterval
を 実案件コードに投入し、PR を通した -
DevTools Profiler で
useCallback
による再レンダー削減を定量確認 - “Hook の3鉄則”をチームメンバーに説明できる

5
フェーズ 5 ― **型安全なコンポーネント API**
狙い
- 「ポリモーフィック
as
prop」で “任意要素に差し替え” + 型安全を両立- 「判別可能ユニオン」で Props の組み合わせ誤りをコンパイル時に封じる
- Storybook など外部ツールでも 自動で Props テーブルが生成される 設計にする
0. 先にゴールが見える完成イメージ
/* 使い方サンプル */
<Button variant="primary" icon={<Save />} onClick={handle} />
<Button as="a" href="/settings" variant="ghost" icon={<Settings />}>
Settings
</Button>
<Button loading iconPosition="right">
Submitting…
</Button>
-
as="a"
に切り替えるとhref
が必須になり、onClick
は警告 -
loading
true ならdisabled
が自動付与 + クリック無効
1️⃣ 型安全ポリモーフィックコンポーネントの雛形
// src/components/Button/Button.tsx
import { ElementType, ComponentPropsWithRef, forwardRef, ReactNode } from 'react';
import clsx from 'clsx';
/* --------------------------- 1-1. ユーティリティ型 --------------------------- */
/** ① 既存要素の props から特定キーを除去して上書きできる型 */
type PropsOf<T extends ElementType> = Omit<
ComponentPropsWithRef<T>,
'as' | 'ref' | 'className' | 'children'
>;
/** ② button / a 共通の自前 props */
type BaseProps = {
variant?: 'primary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
icon?: ReactNode;
iconPosition?: 'left' | 'right';
loading?: boolean;
children?: ReactNode;
/** Tailwind 等の自由追加スタイルは上書き可 */
className?: string;
};
/* --------------------------- 1-2. 判別可能ユニオン --------------------------- */
type ButtonAsButton = {
as?: 'button';
} & BaseProps &
PropsOf<'button'>;
type ButtonAsAnchor = {
as: 'a';
href: string; // href 必須!
} & BaseProps &
PropsOf<'a'>;
type ButtonProps = ButtonAsButton | ButtonAsAnchor;
/* --------------------------- 1-3. 実装 --------------------------- */
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
(
{
as = 'button',
variant = 'primary',
size = 'md',
icon,
iconPosition = 'left',
loading = false,
className,
children,
...rest
},
ref,
) => {
const Comp = as; // ← ElementType
return (
<Comp
ref={ref as never}
/* loading 中は click 無効化 & aria-busy 付与 */
disabled={as === 'button' ? loading || (rest as any).disabled : undefined}
aria-busy={loading || undefined}
className={clsx(
'inline-flex items-center rounded-md font-medium',
{
primary:
'bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60',
ghost: 'bg-transparent text-blue-600 hover:bg-blue-50',
}[variant],
{
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-1.5',
lg: 'px-4 py-2 text-lg',
}[size],
className,
)}
{...rest}
>
{icon && iconPosition === 'left' && (
<span className="mr-1.5">{icon}</span>
)}
{children}
{icon && iconPosition === 'right' && (
<span className="ml-1.5">{icon}</span>
)}
{loading && (
<span className="ml-1.5 animate-spin">
{/* ここに Spinner アイコン */}
</span>
)}
</Comp>
);
},
);
Button.displayName = 'Button';
✨ 型チェック例
誤用 | TypeScript エラー内容 | |
---|---|---|
<Button as="a" onClick={…} /> |
onClick は anchor に存在しない(型に含まれない) |
|
<Button variant="primaryy" /> |
"primaryy" は `"primary" |
"ghost"` に割り当て不可 |
<Button loading onClick={…} /> |
onClick は OK だが runtime で自動無効化(実装で担保) |
2️⃣ 判別可能ユニオンで “排他フラグ” を型にする
type AlertProps =
| { intent: 'info'; onRetry?: never } // retry ボタン不可
| { intent: 'error'; onRetry: () => void }; // 必須
/* 使い方 */
<Alert intent="info">Saved!</Alert>
<Alert intent="error" onRetry={retry}>Failed!</Alert>
-
onRetry
の付け忘れ・付け過ぎを 型で強制 - 実装側は
props.intent === 'error' && …
の 絞り込み (narrowing) が効く → 冗長なキャスト不要
3️⃣ Conditional Types で「アイコン必須 variant」も実現可能
type WithIcon<V extends boolean> = V extends true
? { icon: ReactNode } // true → 必須
: { icon?: ReactNode }; // false → 任意
type ChipProps<V extends boolean = false> = {
elevated?: V;
} & WithIcon<V>;
/* elevated=true のとき “icon 必須” */
export const Chip = <V extends boolean = false>({
elevated,
icon,
}: ChipProps<V>) => (
<span>{icon}</span>
);
/* ✓ OK */ <Chip elevated icon={<Star />} />
/* ✕ Error */ <Chip elevated /> // icon が必須
4️⃣ Storybook / 型ドキュメント自動化 Tips
-
export type { ButtonProps }
を一緒に出す → Storybook Docs autodocs が props テーブル生成 - MUI 互換の
{component: 'a'}
API を導入する場合もas
が最も汎用的 -
react-docgen-typescript-plugin
を使うと Next.js + SWC でも型抽出が楽
5️⃣ ミニドリル
Drill | 要件 | ねらい |
---|---|---|
① IconButton |
size 固定・aria-label 必須 |
“必須 prop” 強制 |
② LinkOrButton |
href なら <a> , それ以外は <button>
|
条件付きレンダリング + 型抽出 |
③ Badge |
count が 0 のとき children 非表示 |
Conditional Types で count?: never
|
6️⃣ チームに導入するときの FAQ
質問 | 回答 / ベストプラクティス |
---|---|
forwardRef が長い… |
type PolymorphicRef<C extends ElementType> = ComponentPropsWithRef<C>['ref']; で簡略化 |
SSR で型がずれる? |
as const によるリテラル保持、import type で循環参照防止 |
ユニオン多すぎで IDE が重い |
variants.ts に enum 的オブジェクトを切り出し keyof typeof で型取得 → |
7️⃣ 達成チェック
-
ポリモーフィック Button を 自社コンポーネントライブラリへ導入し、
as
+ 判別ユニオンで型エラーゼロ - Storybook で自動生成された Props 表をレビュー → ドキュメント工数削減を確認
- “コンポーネント API と実装を同じファイルに閉じ込める or 分ける” 設計方針をチーム合意
次フェーズへの橋渡し
- 型安全 UI を量産すると Context とサーバー State の“型爆発”が見えてきます。
→ フェーズ 6「状態管理 & サーバー State」 で「TanStack Query × Zod × React Context」へ接続し、フロントエンド全体をエンドツーエンドに型安全にしていきましょう。
疑問点や “この variant を型で表現できる?” などあれば、遠慮なく投げてください!

6
フェーズ 6 ― **状態管理 & サーバー State**
ゴール
- 「ローカル state ↔ グローバル state ↔ サーバー state」切り分け基準を説明できる
- Context + reducer でアプリ横断の UI 状態を安全に共有できる
- TanStack Query(React Query)/ SWR で“キャッシュ付き fetch” & 楽観的更新を実装できる
0. “どこに置くのが正しいか?” 早見表
種類 | 具体例 | ベスト手段 | 再レンダー波及 |
---|---|---|---|
ローカル state | 入力フォーム、開閉フラグ | useState |
コンポーネント単体 |
UI グローバル | ダークモード、ログインユーザー | Context + reducer | アプリ全体 or 画面単位 |
サーバー state | API リスト、いいね数、検索結果 | TanStack Query / SWR | キャッシュ共有 & 自動再フェッチ |
アプリ全体の複雑ロジック | 多段フォーム w/ Undo, 大規模カレンダー | Zustand / Jotai / Redux Toolkit 等 | 多岐 |
1️⃣ Context + reducer ― “UI グローバル” を型安全に共有する
1-A. 例題:Theme(Light/Dark) をグローバル化
src/contexts/theme.tsx
import {
createContext,
useContext,
useReducer,
Dispatch,
ReactNode,
} from 'react';
/* ① 型定義 */
type Theme = 'light' | 'dark';
type Action = { type: 'TOGGLE' } | { type: 'SET'; payload: Theme };
type State = { theme: Theme };
/* ② reducer 関数(純粋関数・switch で判別可能ユニオン) */
const reducer = (s: State, a: Action): State => {
switch (a.type) {
case 'TOGGLE':
return { theme: s.theme === 'light' ? 'dark' : 'light' };
case 'SET':
return { theme: a.payload };
}
};
const ThemeCtx = createContext<[State, Dispatch<Action>] | null>(null);
/* ③ Provider コンポーネント */
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const value = useReducer(reducer, { theme: 'light' }); // 初期値
return <ThemeCtx.Provider value={value}>{children}</ThemeCtx.Provider>;
};
/* ④ カスタム Hook で“useContext”隠蔽 */
export const useTheme = () => {
const ctx = useContext(ThemeCtx);
if (!ctx) throw new Error('useTheme must be within <ThemeProvider>');
return ctx;
};
1-B. 使い方
const ToggleThemeButton = () => {
const [{ theme }, dispatch] = useTheme();
return (
<button onClick={() => dispatch({ type: 'TOGGLE' })}>
Switch to {theme === 'light' ? '🌙 Dark' : '☀️ Light'}
</button>
);
};
/* _app.tsx / main.tsx */
<ThemeProvider>
<App />
</ThemeProvider>
ポイント
- Context 値を “タプル [state, dispatch]” にすると Redux 風で直感的
- reducer に 副作用を持ち込まない → テスト容易+再現性
- state が肥大化したら
useReducer
→useImmerReducer
に替えるとイミュータブル更新が楽
2️⃣ サーバー State ― TanStack Query(React Query)で“取得→キャッシュ→自動再フェッチ”
2-A. セットアップ
pnpm add @tanstack/react-query
src/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient();
main.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './queryClient';
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
2-B. 例題:GitHub Issue Viewer
src/hooks/useIssues.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
type Issue = { id: number; title: string; comments: number };
const fetchIssues = async () =>
(
await axios.get<Issue[]>(
'https://api.github.com/repos/facebook/react/issues?per_page=10',
)
).data;
export const useIssues = () =>
useQuery({ queryKey: ['issues'], queryFn: fetchIssues, staleTime: 1000 * 60 });
/* 楽観的更新付き “Add Star” mutation */
export const useAddStar = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
axios.post(`/api/star`, { id }), // ダミー
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: ['issues'] });
const prev = qc.getQueryData<Issue[]>(['issues']);
qc.setQueryData<Issue[]>(['issues'], (old) =>
old!.map((i) => (i.id === id ? { ...i, comments: i.comments + 1 } : i)),
);
return { prev };
},
onError: (_e, _id, ctx) => qc.setQueryData(['issues'], ctx?.prev),
onSettled: () => qc.invalidateQueries({ queryKey: ['issues'] }),
});
};
src/components/IssueList.tsx
import { useIssues, useAddStar } from '../hooks/useIssues';
export const IssueList = () => {
const { data, isLoading, error } = useIssues();
const addStar = useAddStar();
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Error!</p>;
return (
<ul>
{data!.map((i) => (
<li key={i.id}>
{i.title} ⭐{i.comments}
<button
disabled={addStar.isPending}
onClick={() => addStar.mutate(i.id)}
>
+star
</button>
</li>
))}
</ul>
);
};
メリットまとめ
- キャッシュキー
['issues']
で 全画面共通キャッシュinvalidateQueries
で 再フェッチ一括リフレッシュisLoading / isPending / isError
状態が Hook からそのまま UI へ
3️⃣ SWR ― “少コードでシンプル fetch” 派なら
pnpm add swr
import useSWR from 'swr';
import axios from 'axios';
const fetcher = (url: string) => axios.get(url).then((r) => r.data);
const TodoSWR = () => {
const { data, error } = useSWR('/api/todos', fetcher, {
refreshInterval: 10_000, // 10 秒ごと
});
if (!data) return 'Loading…';
if (error) return 'Error';
return data.map((t: any) => <p key={t.id}>{t.text}</p>);
};
-
mutate('/api/todos', newData, false)
で 即 UI 反映 → バックグラウンドで revalidate - 依存が軽いので「Next.js App Router + fetch キャッシュ」と使い分けるプロジェクトも多数
4️⃣ Context vs TanStack Query ― 判断フローチャート
┌────────────────────────────┐
│ UI 一時的?閉じると不要? ──► useState
└───────────┬────────────────┘
▼
┌────────────────────────────┐
│ 画面横断でも API 不要? │
│ (テーマ・言語・認証など) │
└── Yes: Context(+reducer) │
▼ No
┌────────────────────────────┐
│ サーバーソース? │
└── No: 他ライブラリ │
▼ Yes
┌────────────────────────────┐
│ キャッシュ&再フェッチ必須? │
└── Yes: TanStackQ/SWR │
5️⃣ DevTools & 実運用 Tips
ツール | 使いどころ |
---|---|
TanStack Query DevTools (<ReactQueryDevtools /> ) |
キャッシュの中身・stale 状態・retry 回数を GUI で確認 |
React Profiler | Context 更新で 子全部が再レンダーしていないか確認 |
why-did-you-render | 不要再レンダー検出プラグイン ― Context の粒度を見直す参考 |
6️⃣ ミニドリル
Drill | 必須要件 | 学びポイント |
---|---|---|
① AuthContext | ログインユーザーを Context 管理・refreshToken を TanStack Query Mutation |
“UI グローバル + サーバー state” 連携 |
② Paginated List |
page を reducer で管理、useInfiniteQuery で無限スクロール |
クライアントとサーバーの状態分離 |
③ 楽観的コメント投稿 | 投稿直後に即描画 → エラー時はロールバック |
onMutate / onError パターン |
7️⃣ 達成チェック
- Context + reducer で テーマ or 認証状態 を実プロダクトへ導入
- TanStack Query DevTools で キャッシュヒット率 を計測し効果を共有
- “状態種別ごとの所在地” を README or ADR に記述し、チーム合意を取った
次フェーズへの橋渡し
- グローバル state が整うと 再レンダーとメモ化 の課題が浮き彫りになります。
→ フェーズ 7「パフォーマンス最適化」 で Profiler ×React.memo
× Concurrent Features に踏み込みましょう。
疑問・具体的な設計レビュー・エラー例など、いつでもお知らせください!

7
フェーズ 7 ― **パフォーマンス最適化**
ゴール
- 「なぜ再レンダーが遅いのか」「どこをカットすべきか」を 測定 → 仮説 → 改善 で説明できる
React.memo
/useTransition
/Suspense
など 最小構成 で使いこなす- “大規模リスト 5,000 件” を 100 ms 未満で描画する仕組みを実装できる
0. まず“遅さ”を観測する 3 ステップ
手順 | 具体ツール | 観点 |
---|---|---|
1️⃣ React DevTools Profiler で録画 | Flamegraph の 赤スパイク がどのコンポーネントか | “予想外の再レンダー” を特定 |
2️⃣ Performance 面 (Chrome) で INP/LCP | 入力遅延・初回描画 | ゆるい体感改善か数値改善か |
3️⃣ why-did-you-render プラグイン |
React.memo が効いているか |
props 参照の差し替え漏れを検出 |
Tip: Profiler の “Commit” をクリック → 「20 ms 超」は要最適化 と判断して良いライン
1️⃣ リスト 5,000 件描画 ― ナイーブ版 → 改善版 を比較
1-A. ナイーブ実装(遅い)
// src/components/SlowList.tsx
export const SlowList = ({ data }: { data: string[] }) => (
<ul>
{data.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
);
- スクロール 1 ドラッグ毎に 5,000 × diff 処理
- DevTools Flamegraph で 200 ms 以上の Commit が頻発
React.memo
+ 安定した props 参照
1-B. 最適化① type RowProps = { value: string };
const Row = React.memo<RowProps>(({ value }) => <li>{value}</li>);
export const MemoList = ({ data }: { data: string[] }) => (
<ul>
{data.map((t) => (
<Row key={t} value={t} />
))}
</ul>
);
- 親が再レンダーしても Row はスキップ
- それでも 全 5,000 Row を一度に DOM 挿入 → まだ重い
1-C. 最適化② ライブラリで仮想化(react-window)
pnpm add react-window
import { FixedSizeList as List } from 'react-window';
export const VirtualList = ({ data }: { data: string[] }) => (
<List
height={400}
width="100%"
itemCount={data.length}
itemSize={30}
itemData={data}
>
{({ index, style, data }) => (
<div style={style}>{data[index]}</div>
)}
</List>
);
- DOM に存在するのは 常に 10〜20 個 → 初回描画 < 20 ms
- key が勝手に付くので diff コストも最小
2️⃣ Diff / Reconciliation ― “なぜ key が必要?” を 5 行で
旧ツリー 新ツリー
A A
├ B ├ C ← key で “別” と判断
└ C └ B
- key 無し → B↔C を 再描画 して入れ替え = 2 つフル描画
- key 有り → 「B が ↓ へ移動・C が ↑ へ移動」だけ位置変更 → ほぼゼロコスト
原則: key は “配列内で一意” かつ 安定した ID を使う(index はドラッグソートで崩れる)
React.memo
・useMemo
・useCallback
の使い分け早見表
3️⃣ 症状 | 最適解 | 理由 |
---|---|---|
同じ JSX を再生成 | React.memo(Component) |
コンポーネント単位のメモ化 |
重い 値計算 が毎回 | useMemo(calc, [deps]) |
値キャッシュ |
子に渡す 関数 が原因 | useCallback(fn, [deps]) |
参照同一性維持 |
落とし穴:メモ化コスト > 計算コスト のケースもある → Profiler で Before/After を測ること
4️⃣ Concurrent Features で“体感”を守る
useTransition
― タイピング中のリストフィルタを非同期化
4-A. const [text, setText] = useState('');
const [list, setList] = useState(bigArray);
const [isPending, startTransition] = useTransition();
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setText(value); // ① すぐ反映(高優先)
startTransition(() => { // ② 重い計算は低優先
const filtered = bigArray.filter((v) => v.includes(value));
setList(filtered);
});
};
- type → input 更新 は即座に
- リスト絞り込みは ブラウザ余裕あるとき に実行 → INP 大幅削減
-
isPending
で “Loading…” Skeleton を出すと UX ○
useDeferredValue
― 遅延値だけ簡易に
4-B. const query = useDeferredValue(text, { timeoutMs: 200 });
-
実装 1 行 で ほぼ
useTransition
相当 - 細かい中断制御は不要だが調整幅は狭い
Suspense
+ Code Splitting で初回描画を削る
5️⃣ import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./HeavyChart'));
<Suspense fallback={<Spinner />}>
<Chart />
</Suspense>
-
Chart
は 別 Bundle に分割(webpack / Vite が自動) - まず Spinner で即描画 → JS 取得後に本体を差し替え
- React 18 の streaming SSR + Next.js App Router なら HTML 分割 と組み合わせてさらに高速化
6️⃣ 実運用で“効く”その他 Tips
テクニック | 効果 | 実装一言ヒント |
---|---|---|
画像/FONT 最適化 | CLS/FCP 改善 | Next.js next/image /next/font
|
Preact/React-lite on Embed | 体積半減 |
preact/compat alias |
Offscreen (実験) | タブ非表示でコンポーネント休止 |
display: none 相当を React が内部管理 |
Web-worker 切り出し | CPU ブロック回避 | Comlink + useSWR mutation |
Memoized Selectors | Context の 一部 のみ購読 |
useContextSelector or zustand
|
7️⃣ ミニドリル
Drill | 目標指標 | キー技術 |
---|---|---|
① 検索インクリメンタル UI | INP < 200 ms |
useTransition + List 仮想化 |
② 画像ギャラリー | LCP < 2 s |
Suspense + next/image
|
③ コメント欄 1 万件 | 60 FPS スクロール |
react-window + React.memo
|
8️⃣ 達成チェック
- Profiler “赤スパイク” を 50 ms → 10 ms 未満へ削減し PR にグラフ添付
-
useTransition
を導入した箇所で INP/CLS が改善したことを Web Vitals で計測 - “メモ化戦略” ガイドライン(いつ・どこで何を使うか)をチーム Wiki に追加
次フェーズへの橋渡し
-
Re-render 抑制 と データ取得最適化 が揃ったら、
→ フェーズ 8「テスト & アクセシビリティ」 で「高速だけど壊れていない」「速いだけでなく使いやすい」UI を担保する自動テストへ進みましょう。
疑問・Profiler キャプチャ・Before/After グラフなど、ぜひ投げてください。さらに深掘りしていきましょう!

8
フェーズ 8 ― **テスト & アクセシビリティ** 実践ガイド
ゴール
- React Testing Library(RTL)+ jest-dom で “ユーザー目線” テストを書ける
- axe-core / jest-axe を組み込み、PR 時点で a11y エラーを機械検出できる
- Web Vitals(INP など)を CI + レポート で継続測定するフローを回せる
0. セットアップ 5 分レシピ
pnpm add -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event \
jest-environment-jsdom jest-axe axe-core
# ts-jest を使う場合
pnpm add -D ts-jest @types/jest
jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }] }, // TS の場合
};
jest.setup.ts
import '@testing-library/jest-dom/extend-expect';
1️⃣ “検索ボックス SearchBox” を題材に
src/components/SearchBox.tsx
import { FormEvent, useState } from 'react';
type Props = { onSearch: (q: string) => void };
export const SearchBox = ({ onSearch }: Props) => {
const [q, setQ] = useState('');
const submit = (e: FormEvent) => {
e.preventDefault();
onSearch(q);
};
return (
<form onSubmit={submit}>
<label htmlFor="search-input" className="sr-only">
Search
</label>
<input
id="search-input"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search…"
aria-label="Search"
/>
<button type="submit">Go</button>
</form>
);
};
2️⃣ RTL 基本:ユーザー行動ベースでテストを書く
src/components/__tests__/SearchBox.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBox } from '../SearchBox';
test('calls onSearch with input value', async () => {
const user = userEvent.setup();
const handle = vi.fn(); // vitest なら vi.fn、jest なら jest.fn
render(<SearchBox onSearch={handle} />);
await user.type(screen.getByRole('textbox', { name: /search/i }), 'react');
await user.click(screen.getByRole('button', { name: /go/i }));
expect(handle).toHaveBeenCalledWith('react');
});
行 | ポイント |
---|---|
getByRole |
文字列より アクセシビリティ Role を優先 → a11y も担保 |
userEvent |
実ブラウザに近い 実時間の input / click |
toHaveBeenCalledWith |
jest-dom の拡張 matcher |
3️⃣ jest-axe で 自動 a11y チェック を追加
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('has no a11y violations', async () => {
const { container } = render(<SearchBox onSearch={() => {}} />);
const result = await axe(container);
expect(result).toHaveNoViolations();
});
- 赤波線ゼロ × a11y エラーゼロ を CI の最低ラインに
- axe のルール除外は
axe(container, { rules: { 'color-contrast': { enabled:false }}})
のように 狙い撃ち
4️⃣ aria-roles & ランドマーク 最低限チェックリスト
目的 | 適切な Role / 属性 | RTL 取得 API 例 |
---|---|---|
入力欄 |
role="textbox" + aria-label or <label>
|
getByRole('textbox', {name:/search/i}) |
送信ボタン |
role="button" + 説明付きテキスト |
getByRole('button', {name:/go/i}) |
リスト |
role="list" / 子に role="listitem"
|
getByRole('list') |
状態メッセージ |
role="status" or aria-live
|
getByRole('status') |
DevTool: Chrome → Accessibility Pane で Role が正しく解決されるか常時確認
5️⃣ INP(入力遅延)を テスト兼メトリクス に組み込む
- Lighthouse CI + GitHub Actions で PR ごと計測
- cwv-report ライブラリを埋め込み、本番 Web Vitals を Sentry へ送信
- 閾値例: INP < 200 ms / CLS < 0.1 を切ったら CI fail
コード可視化が必要なら Playwright の trace viewer で人間操作を録画し metrics 同時取得する方式も
6️⃣ E2E レイヤ は “Playwright” が最も低コスト
pnpm dlx playwright codegen http://localhost:5173
- コードジェネレータが
await page.getByRole('textbox', {name:'Search'})
を自動生成 - Axe プラグイン(
@axe-core/playwright
)を追加すると E2E + アクセシビリティ 一括検証可
7️⃣ ミニドリル
Drill | 要件 | 学びポイント |
---|---|---|
① Button component |
role="button" + aria-disabled が正しく付与されることを jest-axe で検証 |
カスタム component の ARIA |
② List 仮想化 |
react-window リストに キーボードフォーカス移動 テスト |
ローレベル DOM イベント |
③ モーダル | Escape キーで閉じる・role="dialog" ・フォーカストラップ |
userEvent keyboard API |
8️⃣ チーム導入 Tips
課題 | 解決策 |
---|---|
テスト爆速化 | vitest + jsdom ランタイム or jest + --runInBand false
|
a11y ルールが多すぎ… | axe tags:['wcag2a','wcag2aa'] で優先度を絞る |
UI 変更で snapshot 大量差分 | snapshot より RTL queries + jest-dom を推奨 |
9️⃣ 達成チェック
- jest / RTL / jest-axe で最低 80 % Lines カバレッジ
- Storybook アドオン a11y で “緑チェック” を全コンポーネントに付けた
- CI で INP / CLS / a11y error 0 を自動判定し、失敗 PR をブロック
次フェーズへの橋渡し
- a11y & テストが整うと「RSC / SSR でも壊れていないか?」が気になります。
→ フェーズ 9「サーバー連携 / Next.js 橋渡し」 で Streaming SSR + React 18 Suspense 環境下のテスト戦略を深掘りしましょう。
疑問・具体的なエラーログ・CI 設定ファイルなど、いつでもお送りください!

9
フェーズ 9 ― **サーバー連携 / Next.js 橋渡し**
ゴール
- SSR / SSG / ISR を“ユースケースで選択”できる
- React 18 Streaming + App Router のデータ取得フローをコードで理解
- RSC(Server Components)⇆ Client Components の境界と制約を説明できる
0. ディレクトリ構成(App Router 完全版)
src/
├─ app/
│ ├─ layout.tsx ← 共有レイアウト(RSC)
│ ├─ page.tsx ← "/" ルート (SSG)
│ ├─ dashboard/
│ │ ├─ layout.tsx ← 区画レイアウト (RSC)
│ │ ├─ page.tsx ← SSR(動的データ)
│ │ └─ loading.tsx ← Streaming 用 Skeleton
│ ├─ api/
│ │ └─ hello/route.ts← Route Handler ( Edge )
│ └─ (marketing)/ ← Parallel Routes
└─ lib/
├─ db.ts ← Prisma 等
└─ github.ts ← REST/FETCH ラッパ
1️⃣ SSG / ISR ― 静的生成で“超高速”ページ
1-A. Top ページを SSG でビルド時生成
app/page.tsx
import { fetchPosts } from '@/lib/github';
import Link from 'next/link';
export const revalidate = 60 * 60; // 1 時間ごと ISR
export default async function Home() {
const posts = await fetchPosts(); // ❶ fetch() は自動的に“兼 SSR に最適化”
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((p) => (
<li key={p.id}>
<Link href={`/post/${p.slug}`}>{p.title}</Link>
</li>
))}
</ul>
</main>
);
}
-
revalidate = 0
→ 完全 SSG -
revalidate = N
→ ISR(初回 SSG → N 秒後の次リクエストでバックグラウンド再生成)
2️⃣ SSR + Streaming ― 毎回最新データ&早期表示
loading.tsx
で Skeleton
2-A. ダッシュボードを サーバー Component + app/dashboard/page.tsx
import { fetchStats } from '@/lib/github';
import { Suspense } from 'react';
import RepoTable from './RepoTable'; // ⬅ Client Comp.
export const dynamic = 'force-dynamic'; // ❶ キャッシュ無効 → 常時 SSR
export default async function Dashboard() {
const summary = await fetchStats(); // ❷ await OK(RSC内)
return (
<>
<h2>Summary (stars)</h2>
<p>{summary.totalStars}</p>
{/* ❸ ネットワーク遅い API は子で更に分割 */}
<Suspense fallback={<p>Loading table…</p>}>
{/* @ts-expect-error Async Server Comp. */}
<RepoTable />
</Suspense>
</>
);
}
app/dashboard/RepoTable.tsx
import { fetchRepos } from '@/lib/github';
export default async function RepoTable() {
const repos = await fetchRepos(); // 1〜2 秒かかる想定
return (
<table>
<tbody>
{repos.map((r) => (
<tr key={r.id}>
<td>{r.name}</td>
<td>{r.stargazers_count}</td>
</tr>
))}
</tbody>
</table>
);
}
app/dashboard/loading.tsx
export default function Loading() {
return <p>Generating dashboard…</p>;
}
-
パイプライン
-
<Dashboard>
でSummary
だけ先にストリーム送信 -
RepoTable
完了後に<Suspense>
置換 → ユーザーは即骨組みを見られる
-
3️⃣ RSC ⇆ Client 境界のルール
Server Component (.tsx ) |
Client Component ("use client" ) |
|
---|---|---|
fetch() / DB 直アクセス |
✅ 可能 | ❌ NG(要 API 経由) |
Browser APIs (window ) |
❌ NG | ✅ OK |
React Hooks |
use (実験) のみ |
useState , useEffect , etc. |
透過的インポート | Client → Server へは 不可 | Server → Client へ 可能 |
3-A. Client ファイル宣言
'use client';
import { useState } from 'react';
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
3-B. Server から呼び出す
import { Counter } from '@/components/Counter';
export default function Page() {
// Server 部分でデータ取得
return <Counter />; // 自動的にバンドル分割 & hydration
}
4️⃣ Route Handler / Edge Functions で軽量 API
app/api/hello/route.ts
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge'; // ❶ Cloudflare Worker 相当
export async function GET(_req: NextRequest) {
return NextResponse.json({ msg: 'Hello 👋' });
}
-
ミドルウェアなしで
/api/hello
に即デプロイ -
runtime:'edge'
で 北米でも東京でも < 50 ms 応答
use
API(実験)でネスト非同期を簡潔に
5️⃣ React 18 import { use } from 'react';
import { fetchProfile } from '@/lib/github';
export default function ProfilePage() {
const profile = use(fetchProfile()); // Promise を直接渡す
return <h1>{profile.login}</h1>;
}
- 複数フェッチを
Promise.all([...])
に渡しuse
で await 可能 - 2025-05 現在 Next.js 14.2 で安定化予定、型は
@types/react@canary
が必要
6️⃣ ブログ SPA → SSG 移行ハンズオン
-
npm run next build
で生成 HTML サイズ確認 -
revalidate = 0
へ変更 → PR で Lighthouse LCP 1.2 → 0.7 s 改善を可視化 -
output: 'export'
(Static Export) ornext-on-pages
で Cloudflare Pages へ配信 - Analytics で TTFB が CDN ヒット時 < 100 ms を確認
7️⃣ Edge × Suspense 組み合わせ “@Edge Streaming”
export const runtime = 'edge';
import { Suspense } from 'react';
export default function EdgePage() {
return (
<Suspense fallback={<p>Edge loading…</p>}>
{/* @ts-expect-error Async Server Comp. */}
<SlowComp />
</Suspense>
);
}
- HTML チャンクを Edge Worker から即 Flush
- Core Web Vitals の Time-to-First-Byte が劇的改善
8️⃣ ミニドリル
Drill | 目標 | キー技術 |
---|---|---|
① Product Catalog SSG |
getStaticParams で 500 SKU を静的化 |
ISR 30 min |
② Auth-protected Dashboard SSR | Cookie 判定 → redirect()
|
headers() × RSC |
③ Edge AB テスト | Header -A/B 分岐 & Streaming | Route Handler + conditional parallel route |
9️⃣ 達成チェック
- RSC / Client 境界ドキュメントを Notion にまとめチーム共有
-
next build
のrun time
/static data
レポート を見てキャッシュ戦略を最適化 - Lighthouse CI で TTFB < 100 ms / LCP < 1 s を維持するウォッチャーを GitHub Actions に追加
最終フェーズへの橋渡し
ここまで来れば “開発 → 最適化 → テスト → デプロイ” 全工程が揃いました。
フェーズ 10「プロダクション運用」 では、
- CI/CD パイプライン(Playwright + Preview URL + Slack 通知)
- Sentry / Vercel Analytics で ランタイムエラー & Web Vitals を常時モニタリング
- Dependabot × turborepo で 依存アップデートとマルチパッケージ管理
―― “運用で守り切る” 仕組みを仕上げていきましょう💪
ご質問・コードレビュー要請・デプロイ設定の悩みなど、いつでもお知らせください!

10
フェーズ 10 ― **プロダクション運用**
ゴール
- 「落とさず・腐らせず・遅くしない」 をコードと自動化で担保する
- CI → Preview → E2E → デプロイ → 監視 → 自動アップデート のループを 1 クリックで回す
- 重大障害が起きたら Sentry で即検知 → ポストモーテム までを 30 分以内に完遂できる
1. GitHub Actions ― “1ファイル完結” パイプライン
.github/workflows/ci.yml
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
test:
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- run: pnpm i --frozen-lockfile
# Lint & unit
- run: pnpm lint && pnpm test
# Playwright E2E (headless + report)
- run: pnpm exec playwright install --with-deps
- run: pnpm e2e
- uses: actions/upload-artifact@v4 # 失敗時に trace.zip を残す
if: failure()
with:
name: pw-trace
path: test-results
preview-deploy:
needs: test
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: amondnet/vercel-action@v25
with: # Vercel “Preview URL” が PR に自動コメント
vercel-token: ${{ secrets.VERCEL_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT }}
2. Playwright ― ブラウザ & モバイルの“本番同等 E2E”
playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
trace: 'retain-on-failure',
},
projects: [
{ name: 'Chromium', use: { browserName: 'chromium' } },
{ name: 'MobileSafari', use: { browserName: 'webkit', viewport: { width: 390, height: 844 } } },
],
});
e2e/search.spec.ts
import { test, expect } from '@playwright/test';
test('global search returns results', async ({ page }) => {
await page.goto('/');
await page.getByRole('textbox', { name: /search/i }).fill('react');
await page.keyboard.press('Enter');
await expect(page.getByRole('heading', { level: 2, name: /results/i })).toBeVisible();
await expect(page.getByRole('article').first()).toHaveScreenshot(); // visual regression
});
- Trace Viewer で失敗の DOM / network / video を即再現
- Visual diff は
--update-snapshots
がレビュー必須の PR だけ通るフローに
3. Sentry ― “誰が何をしたら落ちたか” を 30 秒で把握
pnpm add @sentry/nextjs
pnpm dlx @sentry/wizard -i nextjs
./sentry.client.config.tsx
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.2, // Frontend INP/LCP も自動収集
integrations: [new Sentry.Replay({ maskAllText: false })], // セッションリプレイ
});
./sentry.server.config.ts
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0, // API ルートのパフォも計測
});
-
RSC 例外は
app/global-error.tsx
でキャッチ →Sentry.captureException(error)
- Slack Integration: “blocker” レベルだけ #prod-alert に通知
4. Web Vitals ― 本番ユーザーの体感を継続計測
lib/reportWebVitals.ts
import { ReportHandler } from 'next/dist/compiled/reportWebVitals';
export const reportWebVitals: ReportHandler = ({ name, value }) => {
if (typeof window === 'undefined') return;
// Sentry breadcrumb で時系列分析
import('@sentry/nextjs').then(({ captureEvent }) =>
captureEvent({
message: `WebVital:${name}`,
level: 'info',
tags: { name },
extra: { value },
}),
);
};
pages/_app.tsx
(pages dir の場合)
export { reportWebVitals } from '@/lib/reportWebVitals';
- INP / LCP / CLS をリリースごとに比較できる
- Sentry Dashboard → Discover クエリで “バージョン × LCP p75” 監視
5. Dependabot と Renovate で“枯らさない”
.github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: '/'
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
labels: ['deps']
ignore:
- dependency-name: 'react'
update-types: ['version-update:semver-major']
- E2E & a11y テストが必ず回るので安心してマージ
- Major は Opt-in 運用で破壊的変更を手作業確認
6. Vercel / Cloudflare Pages ― Preview → Prod ワンクリック
環境 | 特徴 | 設定ポイント |
---|---|---|
Preview (PR) | 自動 URL + Password Protect |
VERCEL_ENV == preview で Sentry sampleRate 低め |
Production | GitHub main push → デプロイ |
Git SHA を Sentry release & Playwright baseURL に注入 |
Staging (optional) | vercel --prod --prebuilt |
e2e smoke after deploy → promote tag |
7. 本番障害ポストモーテム テンプレ
項目 | 内容 |
---|---|
概要 | 2025-05-25 14:32–14:45 JST / API 5xx 率 85 % |
検知 | Sentry Issue NEXTJS-123 + Vercel “Health Check failed” |
影響範囲 | 全ユーザー / ダッシュボード表示不可 |
根本原因 | Route Handler の fetch() 無限 retry ⇒ Edge cold-start 激増 |
是正措置 | ① retry 上限 3 回 ② Sentry Performance alert (p95 > 1 s) |
再発防止 | Playwright Synthetic Check 毎 5 min + PagerDuty |
8. ミニドリル
Drill | 目標 | 技術 |
---|---|---|
① Checkout フロー E2E | Playwright trace < 10 MB | test.use({ video:'on' }) |
② レイジーロード崩れ検知 | jest-axe + Storybook a11y green | Chromatic visual diff |
③ 0-downtime Rollback | Vercel “restore to previous” CLI 実行 | vercel --prod --archive |
9. 達成チェック
- CI 失敗率 ≤ 5 %(flake 撲滅)
- Sentry Apdex ≥ 0.95 / 月
- Dependabot PR → マージまで < 24 h(自動 or 人手)
- ポストモーテム を最初の障害で公開 & 再発 0 件
🚀 完走おめでとうございます!
ここまでで “設計 → 実装 → 品質 → パフォ → 運用” のフロントエンド DevOps 環を完成させました。
次に磨きたい観点があればいつでも教えてください。
- 観測指標の深彫り(RUM vs ラボ)
- Conformance ルール(ESLint custom + GraphQL Codegen)
- マルチテナント / Multi-region サーバーレス構成 … etc.
引き続き伴走いたします 💪