Open12

Building React

T-unityT-unity

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 自動化 本番障害ポストモーテムを書く
T-unityT-unity

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/recommendedno-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. 理解を深めるネクストアクション

  1. 公式 TS Handbook – Basic → Advanced まで流し読み

    • 導入部は 30 分で読めるボリューム。後で辞書的にも使えます。
  2. ES2022 以降の“新構文”を 1 つ選んで調査 & 記事化

    • 例:Array.at, ??=, Top-Level await – “React コードでどう活きるか?”を書き出す。
  3. チーム ESLint ルールを読み、「なぜそのルールが必要か」議論メモを残す

    • 暗黙知を言語化する練習 → コードレビュー力向上に直結。
  4. 型エラー 0 → コードレビュー提出 をルーティンに

    • CI で pnpm lint && tsc --noEmit を必ず通す設定にすると習慣化しやすい。

まとめ ― 前提整備フェーズの評価基準

評価指標 合格ライン
コーディング速度 FizzBuzz (1〜100) が 5 分以内に書ける
ESLint / tsc どちらもエラー 0 でコミット
アウトプット 公式 Handbook の “気づき”を自ブログ or 社内 Doc に 1 本
T-unityT-unity

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 で “再レンダーの真実” を覗く

  1. pnpm dev で起動 → React DevTools → Components

  2. TodoApp を開いたまま Todo をクリック

    • TodoItem だけが再レンダー → props が変わった子だけが走る を確認
  3. 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 の選択理由”を口頭で説明可能
T-unityT-unity

2

**ライフサイクル & Side-Effects**

ゴール

  1. 「描画 ➜ エフェクト ➜ クリーンアップ」 のタイミングを言語化できる
  2. useEffect を安全に書く 4 パターン(マウント時 / 依存更新時 / アンマウント時 / 非同期処理キャンセル)を実装できる
  3. 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>;
};
  1. ブラウザ DevTools コンソール を開きクリック
  2. render {a: X, b: X}1回 だけ出力されればバッチ成功
  3. 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 更新 → アンマウント後の警告 → AbortControllerisMounted フラグで対処

6️⃣ ミニドリル(アウトプット課題)

Drill 必須要件 学びポイント
① WebSocket チャットログ useEffect で接続→onmessage 追加、return で close “永続接続” 型エフェクト
② IntersectionObserver 画像遅延読込 画面内に入ったら src を差し替え、return で解除 DOM API と協調
③ 60 秒タイマー setIntervalclearInterval 「クリーンアップ忘れ」体験

7️⃣ フェーズ評価チェックリスト

  • Strict Mode ON でも 警告ゼロ
  • AbortController/removeEventListener/clearInterval いずれかを実装
  • DevTools Profiler で Effect → Commit → Cleanup の流れを説明できる
  • ブログ or 社内 Doc に 「依存配列完全ガイド」 をまとめて共有
T-unityT-unity

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” 記事化 → フィードバックをもらう
T-unityT-unity

4

フェーズ 4 ― **Hooks 応用 & カスタム Hooks**

ゴール

  1. useMemo / useCallback / useRef が「なぜ必要か?」を説明できる
  2. 再レンダー最適化ロジック再利用を“フック化”して実装できる
  3. ESLint rules-of-hooks を破らないカスタム Hook を書ける

0. なぜ “メモ化” が要るのか?

┌ 再レンダー
│   ├ UI 再計算       ← ここが高コストなら遅い
│   └ コールバック再生成 → 子に props で渡ると **再レンダー連鎖**
└ state 変更
  • useMemo高コスト値をキャッシュして再計算を抑制
  • useCallback同じ関数参照を保ち、子の再レンダーを防止
  • useRef … 再レンダーに関係ない“箱”(ミュータブル・DOM node・Timer ID など)を保持

1️⃣ useMemo ― 重い計算をキャッシュ

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])次の描画まで 値を保持
  • 依存を外すと“毎回再評価”→ パフォーマンスどころか逆効果

2️⃣ useCallback ― 関数参照の安定化

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 だけ変えた再レンダーでは子がスキップ

3️⃣ useRef ― “再レンダーしない箱”

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つの鉄則

  1. 必ず use から始まる関数名 – ESLint ルールが検知
  2. フックはトップレベルで呼ぶ – if や loop の中で呼ばない
  3. 依存 & クリーンアップを内包 – “使う側”は副作用を気にしなくて済む

4-2. useDebounce – 型安全実装

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]);

4-3. useIntervaluseRef でハンドラ保持

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 or useRef で不変参照にする

6️⃣ ミニドリル

Drill 要件 ねらい
useToggle boolean ↔︎ boolean を返す簡易 Hook Hook 命名 & 型推論
usePrevious 任意型 T の前回値を返す Hook useRef 応用
useLocalStorageState state と localStorage を同期 useEffect+useMemo 総合

7️⃣ 達成チェック

  • useDebounceuseInterval実案件コードに投入し、PR を通した
  • DevTools ProfileruseCallback による再レンダー削減を定量確認
  • “Hook の3鉄則”をチームメンバーに説明できる
T-unityT-unity

5

フェーズ 5 ― **型安全なコンポーネント API**

狙い

  1. ポリモーフィック as prop」で “任意要素に差し替え” + 型安全を両立
  2. 判別可能ユニオン」で Props の組み合わせ誤りをコンパイル時に封じる
  3. 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.tsenum 的オブジェクトを切り出し keyof typeof で型取得 →

7️⃣ 達成チェック

  • ポリモーフィック Button を 自社コンポーネントライブラリへ導入し、as + 判別ユニオンで型エラーゼロ
  • Storybook で自動生成された Props 表をレビュー → ドキュメント工数削減を確認
  • “コンポーネント API と実装を同じファイルに閉じ込める or 分ける” 設計方針をチーム合意

次フェーズへの橋渡し

  • 型安全 UI を量産すると Context とサーバー State の“型爆発”が見えてきます。
    フェーズ 6「状態管理 & サーバー State」 で「TanStack Query × Zod × React Context」へ接続し、フロントエンド全体をエンドツーエンドに型安全にしていきましょう。

疑問点や “この variant を型で表現できる?” などあれば、遠慮なく投げてください!

T-unityT-unity

6

フェーズ 6 ― **状態管理 & サーバー State**

ゴール

  1. 「ローカル state ↔ グローバル state ↔ サーバー state」切り分け基準を説明できる
  2. Context + reducer でアプリ横断の UI 状態を安全に共有できる
  3. 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 が肥大化したら useReduceruseImmerReducer に替えるとイミュータブル更新が楽

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 に踏み込みましょう。

疑問・具体的な設計レビュー・エラー例など、いつでもお知らせください!

T-unityT-unity

7

フェーズ 7 ― **パフォーマンス最適化**

ゴール

  1. なぜ再レンダーが遅いのか」「どこをカットすべきか」を 測定 → 仮説 → 改善 で説明できる
  2. React.memouseTransitionSuspense など 最小構成 で使いこなす
  3. “大規模リスト 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 が頻発

1-B. 最適化① React.memo + 安定した props 参照

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 はドラッグソートで崩れる)


3️⃣ React.memouseMemouseCallback の使い分け早見表

症状 最適解 理由
同じ JSX を再生成 React.memo(Component) コンポーネント単位のメモ化
重い 値計算 が毎回 useMemo(calc, [deps]) 値キャッシュ
子に渡す 関数 が原因 useCallback(fn, [deps]) 参照同一性維持

落とし穴:メモ化コスト > 計算コスト のケースもある → Profiler で Before/After を測ること


4️⃣ Concurrent Features で“体感”を守る

4-A. useTransition ― タイピング中のリストフィルタを非同期化

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 ○

4-B. useDeferredValue ― 遅延値だけ簡易に

const query = useDeferredValue(text, { timeoutMs: 200 });
  • 実装 1 行ほぼ useTransition 相当
  • 細かい中断制御は不要だが調整幅は狭い

5️⃣ Suspense + Code Splitting で初回描画を削る

import { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./HeavyChart'));

<Suspense fallback={<Spinner />}>
  <Chart />
</Suspense>
  1. Chart別 Bundle に分割(webpack / Vite が自動)
  2. まず Spinner で即描画 → JS 取得後に本体を差し替え
  3. 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 グラフなど、ぜひ投げてください。さらに深掘りしていきましょう!

T-unityT-unity

8

フェーズ 8 ― **テスト & アクセシビリティ** 実践ガイド

ゴール

  1. React Testing Library(RTL)+ jest-dom で “ユーザー目線” テストを書ける
  2. axe-core / jest-axe を組み込み、PR 時点で a11y エラーを機械検出できる
  3. 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(入力遅延)を テスト兼メトリクス に組み込む

  1. Lighthouse CI + GitHub Actions で PR ごと計測
  2. cwv-report ライブラリを埋め込み、本番 Web Vitals を Sentry へ送信
  3. 閾値例: 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 設定ファイルなど、いつでもお送りください!

T-unityT-unity

9

フェーズ 9 ― **サーバー連携 / Next.js 橋渡し**

ゴール

  1. SSR / SSG / ISR を“ユースケースで選択”できる
  2. React 18 Streaming + App Router のデータ取得フローをコードで理解
  3. 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 = NISR(初回 SSG → N 秒後の次リクエストでバックグラウンド再生成)

2️⃣ SSR + Streaming ― 毎回最新データ&早期表示

2-A. ダッシュボードを サーバー Component + loading.tsx で Skeleton

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>;
}
  • パイプライン

    1. <Dashboard>Summary だけ先にストリーム送信
    2. 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 応答

5️⃣ React 18 use API(実験)でネスト非同期を簡潔に

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 移行ハンズオン

  1. npm run next build で生成 HTML サイズ確認
  2. revalidate = 0 へ変更 → PR で Lighthouse LCP 1.2 → 0.7 s 改善を可視化
  3. output: 'export'(Static Export) or next-on-pages で Cloudflare Pages へ配信
  4. 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 buildrun 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依存アップデートとマルチパッケージ管理

―― “運用で守り切る” 仕組みを仕上げていきましょう💪

ご質問・コードレビュー要請・デプロイ設定の悩みなど、いつでもお知らせください!

T-unityT-unity

10

フェーズ 10 ― **プロダクション運用**

ゴール

  1. 「落とさず・腐らせず・遅くしない」 をコードと自動化で担保する
  2. CI → Preview → E2E → デプロイ → 監視 → 自動アップデート のループを 1 クリックで回す
  3. 重大障害が起きたら 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. DependabotRenovate で“枯らさない”

.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.

引き続き伴走いたします 💪