Claudeに技術書を読むサポートをしてもらう

`{{book.pdfのパス、またはbook.pdfをこのディレクトリに置いたことを伝える}}` にある本『{{本のタイトル}}』を、章ごとに理解していきたい。以下の方針で進めること。

### 目的とスタンス

この学習の目的は、ユーザー自身が本の内容を理解することである。Claudeが代わりに内容をまとめたり、代わりに実験(コードを書いて動かす)したりしても意味がない。効率よくタスクを完了させようとする振る舞いは避け、ユーザーの理解を作ることを優先すること。

### 進め方

- 1章分をまとめて提示せず、節・項くらいの小さい単位で説明し、都度質問を受けたり理解度を確認したりしながら進めること。
- 実験できる箇所は実際に動くコードで確認する。ただし、コードを書いて実行するのはユーザーの役割である。前提知識の説明と、「前」のコード(本の題材コードや前節の到達点)の用意はClaudeが担当する。「後」(リファクタリング後・実装後・課題の答え)を書くのはユーザーである。
- 大量の定型的な繰り返し作業(同じパターンをN個書くだけ、など)は、ユーザーが最初の1〜2個で設計判断を示した後であれば、依頼されたときのみClaudeが代行してよい。設計判断そのものを先回りして行わないこと。
- 学んだ内容は`log/`以下に章ごとの`.md`ファイルとして記録する。これはユーザーの理解に基づいて共同で作成するものであり、Claudeが一方的に書いて渡すものではない。節が進むごとに随時追記していく形でよい。
- 進め方に関する取り決めは`AGENT.md`に書き、セッションをまたいで参照できるようにすること。取り決めを変更する必要が生じた場合は、その場で`AGENT.md`を更新すること。
- 章ごとの進捗はTodoWriteで管理すること。

### 最初にやってほしいこと

1. `book.pdf`を読み(暗号化されていてもRead toolでページ指定すれば読めるはずである)、全体の目次を`index.txt`に書き出すこと(章・節とページ番号の対応)。本文ページ番号とPDFの物理ページ番号がズレている場合(表紙や前付けのページ分)、オフセットを計算して`index.txt`か`AGENT.md`にメモしておくこと。
2. `log/`と`experiments/`ディレクトリを作成すること。
3. `AGENT.md`を作成すること(`AGENT.template.md`があればそれをベースに、なければ本プロンプトの内容に基づいて作成する)。
4. 本にcompanion repository(サンプルコード、GitHubリポジトリなど)があるかユーザーに確認すること。あればスクラッチパッドにclone(shallowでなく、タグも取得できる形で)し、章・節ごとに対応するgitタグやコミットがないか確認すること(`git tag`)。あれば、「前」のコードを写経せずそこから正確に取得できる。
5. 実験に使う言語・実行環境を、本の題材に合わせて提案すること(セットアップも担当してよい)。

準備ができたら第1章から開始すること。

TypeScript/Reactを学びなおす(AI生成) #7 useContext/Providerで、prop drillingを飛び越えて値を届ける

コンポーネントツリーの奥深くに値を届けたいだけなのに、間の階層をすべて経由してpropsをバケツリレーする(prop drilling)のは面倒です。ReactのContextはこれを解決する仕組みですが、「途中を飛び越えて値が届く」という挙動は、直感的には少し掴みにくいところがあります。この記事では、実際にコンポーネントを動かしながら、Contextの挙動と、そこに潜む落とし穴を確認していきます。

prop drillingの問題と、Contextによる解決

Contextの使い方は3ステップです。

  1. createContext(デフォルト値)でContextオブジェクトを作る
  2. 上位のコンポーネントで<SomeContext.Provider value={...}>として値を「供給」する
  3. 下位のどのコンポーネントからでもuseContext(SomeContext)で、自分より上にある一番近いProvidervalueを読み取る

実験: 経由するだけのコンポーネントを飛び越えて値が届く

const ThemeContext = createContext("light");

function GrandChild() {
  const theme = useContext(ThemeContext);
  return <p>今のテーマ: {theme}</p>;
}

function Child() {
  // themeを一切知らないし、propsも受け取っていない
  return <GrandChild />;
}

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Child />
    </ThemeContext.Provider>
  );
}

Childthemeを全く知らず、propsとしても受け取っていませんが、GrandChild今のテーマ: darkと表示します。Childを素通りして、Providerの値がGrandChildまで直接届いていることが確認できます。

ここで大事なのは、この仕組みがimportの静的な話ではなく、実際に描画されているツリー上の位置による動的な話だということです。ThemeContextというオブジェクト自体はどこからでもimportできますが、useContext(ThemeContext)が実際にどの値を返すかは、「今そのコンポーネントが、描画されているツリー上のどこに置かれているか」だけで決まります。同じGrandChildProviderの中と外の両方に置くと、中にあるものはProviderの値を、外にあるものはデフォルト値を表示する、というように、置かれた位置ごとに結果が変わります。

Providerが無い場合はデフォルト値にフォールバックする

実験: Providerで囲まなかった場合

function App() {
  return <Child />; // Providerで囲んでいない
}

これはエラーにならず、今のテーマ: light(createContext("light")のデフォルト値)と表示されます。

デフォルト値はProviderの値と性質が違う

ここは少し注意が必要なポイントです。Providerから流れてくる値は「ツリー上の位置」によって動的に決まりますが、デフォルト値はcreateContext(...)を呼んだ瞬間にそのオブジェクトへ静的に焼き付けられる、性質の異なるものです。同じuseContextという窓口から出てくるので混同しがちですが、片方は動的(ツリースコープ)、もう片方は静的(そのオブジェクト固有)という違いがあります。

デフォルト値が存在するのは主に、「テストやStorybookなどで、わざわざ本物のProviderで囲まなくても単体でコンポーネントを描画できるようにする」ための実用上の理由です。「Providerで囲むのを忘れた」という状態を明示的にエラーとして検出したい場合は、デフォルト値をundefinedのような番兵値にしておき、useContextを直接使わせずに自作のフックでラップして、番兵値のままなら例外を投げる、という設計もよく使われます。

Providerは入れ子にでき、それぞれの配下だけに値を供給する

実験: 内側のProviderが自分の配下だけ値を上書きする

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <GrandChild />
      <ThemeContext.Provider value="blue">
        <GrandChild />
      </ThemeContext.Provider>
    </ThemeContext.Provider>
  );
}

外側のProviderの直下にあるGrandChilddark、内側のProviderで入れ子にされたGrandChildblueと表示されます。同じContextを入れ子のProviderで包むと、それぞれの配下(サブツリー)だけに別の値を供給でき、内側のものが自分の配下に対しては外側の値を上書きします。

落とし穴: Providerのvalueを毎回新しいオブジェクトにすると、配下の全consumerが再レンダーされる

以前の記事(useMemo/useCallback)で見た「オブジェクトはレンダーごとに新しい参照になる」という話は、Contextでも同じ形で問題になります。

実験: 無関係なstateの変化でconsumerが再レンダーされてしまう

const CountContext = createContext({ count: 0 });

function ContextConsumer() {
  console.log("ContextConsumerがレンダーされた");
  const { count } = useContext(CountContext);
  return <p>count: {count}</p>;
}

function App() {
  const [count, setCount] = useState(0);
  const [unrelated, setUnrelated] = useState(0);

  return (
    <CountContext.Provider value={{ count }}>
      <ContextConsumer />
      <button onClick={() => setCount((c) => c + 1)}>count を増やす</button>
      <button onClick={() => setUnrelated((u) => u + 1)}>無関係なstateを増やす</button>
    </CountContext.Provider>
  );
}

countとは無関係なunrelatedを変えるボタンを押しただけなのに、ContextConsumerは毎回再レンダーされます。value={{ count }}Appのレンダーのたびに新しいオブジェクトになり、Contextの仕組み上「値が変わった」とみなされて、配下の全consumerに再レンダーが伝播してしまうからです。

useMemoだけでは直らない — memoとの組み合わせが必要

valueuseMemoで安定させてみます。

const value = useMemo(() => ({ count }), [count]);

ところがこれだけでは、ContextConsumerは依然として「無関係なstate」の変化のたびに再レンダーされ続けます。理由は、ContextConsumermemo()で包まれていない、ただの子コンポーネントだからです。Appが(理由を問わず)再レンダーされると、Reactはmemo()されていない子を無条件に再レンダーします。valueuseMemoで安定させたことで防げるのは「Contextの値そのものが変わったと誤判定されること」だけであって、「親の再レンダーにつられて子も再レンダーされること」自体はそれとは別の話で、これはModule 5で見た「useMemo/useCallbackmemoと組み合わせて初めて意味を持つ」という話がそのまま当てはまります。

実験: 両方組み合わせて、不要な再レンダーだけを防ぐ

ContextConsumer自体もmemo()で包みます。

const ContextConsumer = memo(function ContextConsumer() {
  console.log("ContextConsumerがレンダーされた");
  const { count } = useContext(CountContext);
  return <p>count: {count}</p>;
});

これで「無関係なstateを増やす」をクリックしてもContextConsumerは再レンダーされなくなり、一方「countを増やす」をクリックしたときはちゃんと再レンダーされてcountの表示も更新されます。memo()は「親の再レンダーにつられるだけの不要な再レンダー」をブロックしますが、Contextの値が実際に変わったときの更新は、memoのバリアを飛び越えてちゃんと伝わってくる、という2つの性質が両立していることが確認できました。

まとめ

  • useContextは、間のコンポーネントを経由せずに、ツリー上で一番近い祖先のProviderの値を直接読み取れる。この解決は「importによる静的なもの」ではなく「実際に描画されたツリー上の位置による動的なもの」である。
  • Providerで囲まれていない場所でuseContextを呼ぶと、エラーにはならずcreateContextのデフォルト値にフォールバックする。ただしこのデフォルト値は、Providerの値のように動的にツリースコープされたものではなく、Context生成時に静的に焼き付けられた別の性質のものである。
  • 同じContextは複数のProviderで入れ子にでき、それぞれ自分の配下(サブツリー)にだけ値を供給する。内側のProviderは自分の配下に対して外側の値を上書きする。
  • Providervalueに毎回新しいオブジェクトを渡すと、中身が変わっていなくても配下の全consumerに再レンダーが伝播する。useMemovalueを安定させるだけでは、memo()されていない子は親の再レンダーにつられて再レンダーされ続けるので、Provider側のuseMemoと、consumer側のmemo()を両方組み合わせて初めて、不要な再レンダーだけを防げる。

TypeScript/Reactを学びなおす(AI生成) #6 useRefは何のためにあるのか — 「再レンダーを起こさない箱」を実際に確認する

useRefは「DOMへの参照」として紹介されることが多いフックですが、それは2つある使い道のうちの1つに過ぎません。根っこにあるのは1つの性質です。「レンダーをまたいで値を保持できるが、値を変えても再レンダーを起こさない、{ current: 値 }という箱」を作るフックだ、という理解をすると、DOM参照以外の使い道も自然に見えてきます。この記事では、実際にコンポーネントを動かしながらこの性質を確認していきます。

useRefの正体: レンダーをまたいで残るが、変えても再レンダーしない箱

実験: refを書き換えても再レンダーは起きない

useStateuseRefを並べて、それぞれの値を増やすボタンを用意します。

function App() {
  const [stateCount, setStateCount] = useState(0);
  const refCount = useRef(0);

  console.log("レンダーされた", { stateCount, refCountCurrent: refCount.current });

  return (
    <div>
      <p>state: {stateCount}</p>
      <p>ref: {refCount.current}</p>
      <button onClick={() => setStateCount((c) => c + 1)}>state を増やす</button>
      <button onClick={() => {
        refCount.current += 1;
        console.log("refを増やした(が再レンダーはしていない)");
      }}>
        ref を増やす
      </button>
    </div>
  );
}

「ref を増やす」ボタンだけをクリックすると、コンソールにはrefを増やしたというログだけが出て、「レンダーされた」というログは一切出ません.currentを書き換えるだけでは再レンダーが起きないということです。

その後「state を増やす」を押すと、レンダーされたのログにはrefCountCurrent: 1(さっき増やした分)が出てきます。useRefが返すオブジェクトは、他の理由で再レンダーが起きたときも、その時点までの最新の値を保ったまま生き残っている、ということです。

実験: 値は保持されているが、画面には反映されない

「ref を増やす」ボタンだけを3回連続でクリックしてみます。コンソールにはrefを増やしたが3回出ますが、画面に表示されているref: ...の数字は一切変化しません

内部的にはrefCount.current3になっているはずですが、それを画面(DOM)に反映させる仕組み(=再レンダー)が起きていないので、UI上は古い値のまま表示され続けます。これが「useRefの値をそのままUIの表示に使ってはいけない」理由であり、逆に言えば「UIには関係ないが値だけ覚えておきたい」というケースにはぴったりの性質だということです。

DOMノードへの参照として使う

useRefのもう1つの代表的な使い道が、実際のDOM要素への参照です。

function App() {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={() => inputRef.current?.focus()}>フォーカスする</button>
    </div>
  );
}

input要素にref={inputRef}を渡しておくと、Reactはその要素が実際にDOMにコミットされた後、inputRef.currentに本物のDOM要素をセットしてくれます。ボタンをクリックしたときのinputRef.current?.focus()で、実際にそのinputにフォーカスが移ります。これは「レンダーをまたいで持続する箱」という性質を、値ではなくDOMノードの保管に応用したものだと言えます。.focus().scrollIntoView()のような、Reactの外側にある命令的なDOM APIを呼びたいときの数少ない抜け道です。

「コンポーネントインスタンス」の正体 — Fiberというしくみ

useRefの値が「レンダーをまたいで残る」と説明しましたが、これは正確には「そのコンポーネントに対応する何かが生きている間」残るという話です。関数コンポーネントには、クラスのような意味での「インスタンス」は存在しません。newもされず、thisもない、ただの関数です。

その代わりReactは、内部でFiberと呼ばれるデータ構造を、ツリー上の「その位置」ごとに保持しています。useStateの値やuseRefの箱は、実際にはこのFiberが持っている内部的な状態(hookの連結リスト)として保存されています。コンポーネント関数が再レンダーのたびに何度呼ばれても、Reactが「これは同じ位置にある同じコンポーネントだ」と判断している限り、同じFiber・同じhookの状態が使い回されます。

このFiberは、以下のようなタイミングで破棄され、次に同じ場所にコンポーネントが現れたときは全く新しいFiber(=まっさらな初期状態)が作られます。

  • 親が条件分岐でそのコンポーネントを描画しなくなったとき({mounted && <Child />}のようなパターン)
  • keyが変わったとき(Reactが「別物」として扱い、古いものを破棄して新しく作る)
  • アプリ全体がアンマウントされたとき

実験: アンマウント・再マウントで状態がリセットされることを確認する

function Counter() {
  const refCount = useRef(0);
  const [, forceRerender] = useState(0);

  return (
    <div>
      <p>ref count(最後に画面を更新した時点の値): {refCount.current}</p>
      <button onClick={() => { refCount.current += 1; }}>refを増やす(画面はまだ更新しない)</button>
      <button onClick={() => forceRerender((n) => n + 1)}>画面を更新する</button>
    </div>
  );
}

function App() {
  const [mounted, setMounted] = useState(true);
  return (
    <div>
      {mounted && <Counter />}
      <button onClick={() => setMounted((m) => !m)}>toggle mount</button>
    </div>
  );
}

次の手順で試します。

  1. 「refを増やす」を3回クリック
  2. 「画面を更新する」を1回クリック → 画面の数字が3になる
  3. 「toggle mount」を2回クリック(Counterを一度消してもう一度出す)
  4. 「refを増やす」は押さずに、もう一度「画面を更新する」だけをクリック

結果、画面の数字は3のままではなく0に戻ります。toggle mountでコンポーネントを一度取り除いた時点で、対応するFiberとその中のuseRefの状態がまるごと破棄され、再度マウントされたときはまっさらな新しいFiber(=useRef(0)をもう一度呼んだのと同じ状態)から始まっているということです。

まとめ

  • useRef(初期値)が返すオブジェクトは、レンダーをまたいで同じ参照を保つが、.currentを書き換えても再レンダーは起きない。
  • そのため、.currentの変化はそれ単体では画面に反映されない。UIに表示する値には向かないが、「UIとは無関係に覚えておきたい値」には最適。
  • もう1つの代表的な使い道は、ref属性を通じたDOMノードへの参照。Reactがコミット後に実際のDOM要素を.currentにセットしてくれるので、.focus()のような命令的なDOM APIを呼ぶ抜け道として使える。
  • 関数コンポーネントに「インスタンス」という実体は無く、実際に持続しているのはReact内部のFiberという構造(hookの状態を保持する)。コンポーネントが親から取り除かれる(アンマウントされる)と対応するFiberごと破棄され、再度描画されるときはまっさらな新しいFiberとしてuseState/useRefの初期値からやり直しになる。

TypeScript/Reactを学びなおす(AI生成) #5 useMemo/useCallbackは何のためにあるのか、実際の再レンダーを見ながら理解する

useMemouseCallbackは「とりあえずパフォーマンスに良さそうだから」という理由で、あちこちに反射的に付けられがちなフックです。ですが実際にはこの2つ、単体では何の効果も持ちません。この記事では、なぜこれらが必要になる場面があるのか、そしてなぜ「単体では意味がない」のかを、実際にコンポーネントの再レンダーを観察しながら確認していきます。

オブジェクト・配列・関数は、レンダーごとに別の参照になる

話の前提として、まず確認しておきたい事実があります。JavaScriptでは、中身が同じオブジェクト・配列・関数でも、別々に作られたものは===で比較するとfalseになります。

console.log({ label: "hello" } === { label: "hello" }); // false

これはReactコンポーネントの中でも同じです。レンダーのたびにコンポーネント関数が最初から実行し直されるので、その中で作られるオブジェクトや関数リテラルは、たとえ中身が同じでも毎回新しい参照になります。

実験: 中身が同じでも参照は変わる

useRefで前回のレンダーの値を覚えておき、今回の値と比較してみます。

function App() {
  const [count, setCount] = useState(0);
  const obj = { label: "hello" };

  const prevObjRef = useRef(obj);
  console.log("前回と同じ参照か:", prevObjRef.current === obj);
  prevObjRef.current = obj;

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>increment</button>
    </div>
  );
}

ボタンを押すたびに、ログは一貫してfalseになります。{ label: "hello" }という中身は毎回全く同じにも関わらず、です。プリミティブ値(数値・文字列など)は値そのもので比較されるので気にする必要はありませんが、オブジェクト・配列・関数は「中身が同じでも別物」という前提を持っておく必要があります。

なぜこれが問題になるのか — React.memoが効かなくなる

この事実が実害になる典型例が、React.memoで子コンポーネントの再レンダーを抑制しようとしている場面です。memoは「propsが(浅い比較で)前回と同じなら再レンダーをスキップする」仕組みですが、propsにオブジェクト・配列・関数を渡していると、中身が同じでも参照が毎回変わるため、このスキップが効かなくなります。

実験: memoで包んでも子コンポーネントが再レンダーされ続ける

const Child = memo(function Child({ config }: { config: { label: string } }) {
  console.log("Childがレンダーされた:", config.label);
  return <div>{config.label}</div>;
});

function App() {
  const [count, setCount] = useState(0);
  const config = { label: "hello" };

  return (
    <div>
      <p>count: {count}(このstateはChildと無関係)</p>
      <button onClick={() => setCount((c) => c + 1)}>increment</button>
      <Child config={config} />
    </div>
  );
}

Childcountを全く参照していないにも関わらず、incrementをクリックするたびにChildのログが出てしまいます。Appが再レンダーされるたびに新しいconfigオブジェクトが作られ、memoの比較で「propsが変わった」と判定されてしまうからです。

useMemoで参照を安定させる

実験: 参照が安定すると、子の再レンダーが止まる

configuseMemoで包んでみます。

function App() {
  const [count, setCount] = useState(0);
  const config = useMemo(() => ({ label: "hello" }), []);

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>increment</button>
      <Child config={config} />
    </div>
  );
}

これでincrementを何度クリックしても、Childのログは一切出なくなります。configの参照が安定し、memoが「propsは前回と同じ」と判定して再レンダーそのものをスキップできるようになったからです。

依存配列の意味はuseEffectと同じ

useMemoの第2引数(依存配列)は、useEffectと全く同じ意味です。「依存配列の値が変わったときだけ再計算し、新しい参照を作る」という挙動になります。

function App() {
  const [count, setCount] = useState(0);
  const [suffix, setSuffix] = useState("A");
  const config = useMemo(() => ({ label: "hello-" + suffix }), [suffix]);

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>increment(configと無関係)</button>
      <button onClick={() => setSuffix((s) => (s === "A" ? "B" : "A"))}>
        toggle suffix(configの依存)
      </button>
      <Child config={config} />
    </div>
  );
}

increment(configの依存配列に含まれないcountを変える)をクリックしてもChildは再レンダーされませんが、toggle suffix(依存配列に入っているsuffixを変える)をクリックすると、そのときだけChildが新しいconfig(hello-Bなど)で再レンダーされます。useMemoは「依存配列が変わらない限り、前回の参照をそのまま返し続ける」フックだということが、これではっきり確認できます。

useCallback — 関数版のuseMemo

useCallbackは仕組みが全く同じで、対象が「値」ではなく「関数」というだけです。実際useCallback(fn, deps)useMemo(() => fn, deps)とほぼ同じ意味です。

実験: 同じ問題が関数でも起きる、同じ直し方で直る

const Child = memo(function Child({ onClick }: { onClick: () => void }) {
  console.log("Childがレンダーされた");
  return <button onClick={onClick}>click me</button>;
});

function App() {
  const [count, setCount] = useState(0);
  const handleClick = () => console.log("clicked"); // 毎回新しい関数

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>increment</button>
      <Child onClick={handleClick} />
    </div>
  );
}

予想通り、incrementのたびにChildが再レンダーされます。handleClickが毎回新しい関数として作られ、onClickpropの参照が変わってしまうからです。useCallbackで包むと直ります。

const handleClick = useCallback(() => console.log("clicked"), []);

これでincrementをクリックしてもChildは再レンダーされなくなります。オブジェクトのときと全く同じ理屈です。

大事な注意点: useMemo/useCallbackは単体では何もしない

最後に、誤解しやすい重要なポイントがあります。Childからmemo()を外して、普通の関数コンポーネントに戻してみます。handleClickuseCallbackのまま残します。

function Child({ onClick }: { onClick: () => void }) {
  console.log("Childがレンダーされた");
  return <button onClick={onClick}>click me</button>;
}

実験: memoを外すと、useCallbackがあっても意味がない

これでincrementをクリックすると、useCallbackで関数の参照を安定させているにも関わらず、Childは毎回再レンダーされます。memoされていないコンポーネントは、propsの中身に関わらず親が再レンダーされれば常に再レンダーされるので、propの参照がどれだけ安定していようと無関係だからです。

つまりuseMemo/useCallbackは、それ単体では再レンダーを一切防ぎません。効果があるのは、その安定した参照を実際に利用する何か——React.memoの浅い比較や、他のフック(useEffectや別のuseMemo)の依存配列など——と組み合わせたときだけです。memoされていない子に渡す値をいくらuseCallback/useMemoで包んでも、依存配列の比較コストとメモリだけがかかって効果はゼロ、ということになります。

まとめ

  • オブジェクト・配列・関数は、レンダーのたびに中身が同じでも新しい参照になる。プリミティブ値と違い===比較では別物として扱われる。
  • React.memoはpropsの浅い比較で再レンダーをスキップする仕組みだが、propに渡すオブジェクト/配列/関数の参照が毎回変わると、このスキップが効かなくなる。
  • useMemo/useCallbackは、依存配列が変わらない限り前回の参照を使い回すことで、この問題を解決する。依存配列の意味はuseEffectと全く同じ。
  • ただしuseMemo/useCallbackは単体では何も再レンダーを防がない。React.memoや他のフックの依存配列など、「参照が安定していること」を実際に利用する仕組みと組み合わせて初めて意味を持つ。何でもかんでも付けるのではなく、「この安定性を誰が使うのか」を意識して使うべきフックだと言える。

TypeScript/Reactを学びなおす(AI生成) #4 レンダー中のsetStateは本当に危険なのか — 条件付きなら安全になる理由

「レンダー関数の中でsetStateを呼ぶ」と聞くと、多くの人は反射的に「危険」「無限ループする」と身構えます。実際、多くのケースではその通りです。ところがReactの公式ドキュメントには、これを意図的に許可している例外パターンが存在します。この記事では、まず「なぜ危険なのか」を実際に無限ループさせて体感し、次に「条件付きならなぜ安全なのか」を、実際にDOMへのコミット内容まで確認しながら見ていきます。

「レンダー中のsetState」は本来危険

実験: 無条件に呼ぶと無限ループする

次のコンポーネントを実際に動かしてみます。

function App() {
  const [count, setCount] = useState(0);

  console.log("render", count);
  setCount(count + 1); // 条件なし

  return <div>{count}</div>;
}

ブラウザのConsoleにはrender 10render 11render 12……と連番で大量にログが流れ、最終的にこうなります。

Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

レンダー→setCount→再レンダー→setCount→……と際限なく繰り返されるため、Reactは無限ループを検知して強制的に例外を投げて止めます。これが「レンダー中の無条件setStateが危険」と言われる理由です。

条件付きなら安全になる

実験: 前回値との比較で調整する

次に、無条件ではなく「前回の値と比較して、変わっていたときだけsetStateする」パターンを試します。

function GuardedComponent({ source }: { source: string }) {
  const [prevSource, setPrevSource] = useState(source);

  console.log("render body", { source, prevSource });

  if (prevSource !== source) {
    console.log("  -> 不一致を検知、setPrevSourceを呼ぶ");
    setPrevSource(source);
  }

  return (
    <div>
      source: {source} / prevSource: {prevSource}
    </div>
  );
}

これをsourceの値を切り替えるボタンと一緒に動かすと、1回のクリックでこう出ます。

render body {source: 'b', prevSource: 'a'}
  -> 不一致を検知、setPrevSourceを呼ぶ
render body {source: 'b', prevSource: 'b'}

無限ループにはなりません。

なぜ無限ループしないのか

流れを追うとこうなっています。

  1. sourceが変わり、レンダー関数が実行される。この時点でprevSourceはまだ古い値なので不一致 → setPrevSource(source)を呼ぶ
  2. setStateがレンダー中に呼ばれたので、Reactはその場で(コミット・画面描画を待たずに)レンダー関数をもう一度即座に呼び直す
  3. 2回目の実行では、もうprevSourceが更新済みなのでprevSource === sourceとなり、条件がfalseになる → setPrevSourceは呼ばれない → ここで連鎖が止まる

無条件パターンとの違いは、「1回の調整で条件そのものが自動的に解消する」ことです。無条件だと毎回条件がtrueのままなので終わりませんが、前回値との比較は「差分が無くなったら止まる」という自然な終了条件を持っています。

補足: StrictModeによるレンダー関数の二重実行

実際に手元で試すと、上のログがそれぞれ2回ずつ(計4行や6行)出ることがあります。これはReactの<StrictMode>が開発モードでレンダー関数自体をわざと2回呼ぶ挙動によるもので、今回の「条件付き調整」パターンとは無関係です(以前の記事で見た、useEffectのセットアップ/クリーンアップを開発時に2回実行する挙動と同じ趣旨の仕組みです)。本質的には「不一致を検知して調整するレンダー」が1回、「調整済みで安定したレンダー」が1回、それぞれをStrictModeが2回ずつ律儀に呼んでいる、という状態です。

なぜ useEffect ではなくレンダー中でやるのか

同じ「前回値と比較してstateを調整する」処理はuseEffectでも書けます。実際そちらの方が素直に見えるかもしれません。ではなぜ、レンダー関数の中で直接やる方法がわざわざ使われるのでしょうか。実際にDOMへのコミット内容を確認して比較します。

useLayoutEffect(DOMが更新された直後、画面に描画される前に同期的に実行される)を使うと、「実際にブラウザのDOMにコミットされた内容」を直接ログに出せます。

レンダー中に調整した場合

function GuardedComponent({ source }: { source: string }) {
  const [prevSource, setPrevSource] = useState(source);

  if (prevSource !== source) {
    setPrevSource(source);
  }

  useLayoutEffect(() => {
    console.log("DOMにコミットされた内容:", document.getElementById("target")?.textContent);
  });

  return (
    <div id="target">
      source: {source} / prevSource: {prevSource}
    </div>
  );
}

「toggle source」を1回クリックすると、ログはこの1行だけです。

DOMにコミットされた内容: source: b / prevSource: b

不一致の状態(source: b / prevSource: a)は一度もDOMにコミットされていません。レンダー関数が内部的には2回呼ばれていても、実際に画面に反映される(コミットされる)のは調整後の一貫した状態だけです。

useEffectで調整した場合

同じ調整処理をuseEffectに移してみます。

function GuardedComponentEffect({ source }: { source: string }) {
  const [prevSource, setPrevSource] = useState(source);

  useEffect(() => {
    if (prevSource !== source) {
      setPrevSource(source);
    }
  });

  useLayoutEffect(() => {
    console.log("DOMにコミットされた内容:", document.getElementById("target")?.textContent);
  });

  return (
    <div id="target">
      source: {source} / prevSource: {prevSource}
    </div>
  );
}

「toggle source」を1回クリックすると、今度はログが2行出ます。

DOMにコミットされた内容: source: b / prevSource: a
DOMにコミットされた内容: source: b / prevSource: b

1回目のコミットで、不一致のままの状態(prevSource: a)が実際にDOMに反映されていますuseEffectはコミット・画面描画が終わった後に実行されるため、まず古い(不整合な)状態がそのまま画面に描画され、その後useEffectが不一致を検知してsetPrevSourceを呼び、2回目のコミットでようやく正しい状態に修正される、という流れになっているからです。

結果の比較

コミット回数 不一致状態がDOMに反映されるか
レンダー中に調整 1回 されない
useEffectで調整 2回 される(一瞬だけ)

useEffect版は、無駄な追加コミットが発生するだけでなく、一瞬とはいえ不整合な状態(古いページ番号のまま、など)が実際に画面に描画される可能性がある、という点で明確に劣っています。レンダー関数の中で直接調整する方法は、コミットされる前にその場で状態を確定させてしまうので、こうした「ちらつき」が原理的に起こり得ません。

まとめ

  • レンダー中の無条件なsetStateは無限ループを引き起こし、ReactはToo many re-rendersエラーで強制的に止める。
  • 「前回値と比較して、変わっていたときだけsetStateする」パターンは、1回の調整で条件そのものが自動的に解消するため無限ループしない。レンダー関数が内部的に2回呼ばれるだけで済む。
  • useLayoutEffectでDOMコミットの内容を直接確認すると、レンダー中に調整した場合は不一致の状態が一度もDOMに反映されないが、同じ調整をuseEffectで行うと、まず不整合な状態がそのままコミット・描画され、その後1フレーム遅れて修正される。
  • そのため「propの変化に合わせてstateを調整する」ようなケースでは、useEffectより、条件付きでレンダー中に直接調整する方が、無駄な追加レンダーも画面のちらつきも避けられる。ただし無条件にsetStateを呼ぶと即座に無限ループするため、必ず「調整後は条件が偽になる」比較を伴わせる必要がある。

TypeScript/Reactを学びなおす(AI生成) #3 useEffectの依存配列とクリーンアップを、実際にログを見ながら理解する

useEffectの依存配列には3パターンあることや、コールバックがクリーンアップ関数をreturnできることは、知識としては知っている人が多いと思います。ただ「知っている」と「実際にその通りに動くのを見たことがある」は別の話です。この記事では、小さなコンポーネントを実際にブラウザで動かし、console.logでタイミングを可視化しながら、useEffectの実行順序を確認していきます。

依存配列の3パターン

useEffectの第2引数(依存配列)には3つの書き方があります。

  1. 省略 — 毎回のレンダー後に実行される
  2. 空配列 [] — 最初のレンダー後(マウント時)に1回だけ実行される
  3. 値を入れた配列 [a, b] — 最初のレンダー後と、それ以降aまたはbが前回のレンダーと変わったときだけ実行される

実験: 依存配列なし vs 空配列

次のコンポーネントで、2種類のuseEffectを並べて動かしてみます。

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("依存配列なし: 毎回のレンダー後に実行");
  });

  useEffect(() => {
    console.log("依存配列 []: 最初の1回だけ実行");
  }, []);

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>increment</button>
    </div>
  );
}

ページを開いた直後、両方のログが出ます。そこからincrementボタンを押してレンダーを起こすたびに増えるのは「依存配列なし」の方だけで、「依存配列 []」の方はそれ以降一度も増えません。空配列は「1回実行して終わり」ではなく、正確には「依存配列に何も無いので、変化を検知するものが無く、2回目以降実行される条件が満たされない」という理解の方が実態に近いです。

クリーンアップ関数はいつ呼ばれるか

useEffectのコールバックが関数をreturnすると、それがクリーンアップ関数になります。「アンマウント時にだけ呼ばれる」と誤解されがちですが、実際は次にそのエフェクトが再実行される直前にも毎回呼ばれます

実験: セットアップ・クリーンアップの順序を可視化する

function Child({ label }: { label: string }) {
  useEffect(() => {
    console.log(`[${label}] effect: セットアップ`);
    return () => {
      console.log(`[${label}] cleanup`);
    };
  }, [label]);

  return <p>label: {label}</p>;
}

function App() {
  const [label, setLabel] = useState("a");
  const [mounted, setMounted] = useState(true);

  return (
    <div>
      {mounted && <Child label={label} />}
      <button onClick={() => setLabel((l) => (l === "a" ? "b" : "a"))}>toggle label</button>
      <button onClick={() => setMounted((m) => !m)}>toggle mount</button>
    </div>
  );
}

これを実際に操作すると、次のようなログの流れになります。

ページを開いた直後

[a] effect: セットアップ
[a] cleanup
[a] effect: セットアップ

「toggle label」をクリック(a→b)

[a] cleanup
[b] effect: セットアップ

「toggle mount」をクリック(アンマウント)

[b] cleanup

2番目の結果に注目してください。labelが変わって次にエフェクトが再実行される直前に、前回のクリーンアップ([a] cleanup)が呼ばれてから、新しいセットアップ([b] effect)が実行されています。「アンマウント時だけ」ではないことがここではっきり確認できます。3番目の結果では、予想通りアンマウント時にもクリーンアップだけが呼ばれています。

補足: 初回マウント時に2回実行されるのは StrictMode のせい

最初の実験で、ページを開いた直後に「セットアップ→クリーンアップ→セットアップ」と2回実行されているのに気づいたかもしれません。これはVite(や多くのReactプロジェクト)がデフォルトで有効にしている<StrictMode>の仕様です。

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

開発モードに限り、StrictModeはマウント時のエフェクトをわざと「セットアップ→クリーンアップ→セットアップ」という順で2回走らせます。これは「クリーンアップをちゃんと書かないと、コンポーネントが2回マウントされたときに壊れるコード」を開発中に検出するための仕組みです(本番ビルドではこの2度実行は起きません)。逆に言えば、この2度実行で何かがおかしくなる(タイマーが2重に走る、購読が2重に登録される、など)エフェクトは、クリーンアップの書き方が足りていない可能性が高い、というシグナルにもなります。

実例: なぜdebounce処理はこれで正しく動くのか

ここまでの理解を使うと、検索欄のdebounce処理でよく見るこのパターンが、なぜ正しく動くのか説明できるようになります。

useEffect(() => {
  const timeout = setTimeout(() => setDebouncedQ(q), 300);
  return () => clearTimeout(timeout);
}, [q]);

q(入力中の検索文字列)が変わるたびに、このエフェクトは再実行されます。そして再実行の直前には必ずクリーンアップ(clearTimeout(timeout))が走ります。つまりキー入力のたびに、「前回セットしたタイマーを消してから、新しいタイマーを仕込む」が自動的に実現されています。もしこのクリーンアップが「アンマウント時にしか呼ばれない」ものだったら、キー入力のたびにタイマーが積み上がってしまい、最後のキー入力から300ms待つはずが、それより前のタイマーも次々発火してしまう(=意図しないタイミングで何度もsetDebouncedQが呼ばれる)ことになります。「エフェクトの再実行直前にもクリーンアップが走る」という挙動があってはじめて、この1行のdebounce実装が成立しています。

まとめ

  • useEffectの依存配列は「省略=毎回」「[]=マウント時1回のみ」「値あり=マウント時+それらが変化したとき」の3パターン。
  • クリーンアップ関数は「アンマウント時」だけでなく、「次にそのエフェクトが再実行される直前」にも毎回呼ばれる。
  • 開発モードでStrictModeを使っていると、マウント時のエフェクトは「セットアップ→クリーンアップ→セットアップ」とわざと2回実行される。クリーンアップの書き漏れを検出するための仕組みで、本番ビルドでは起きない。
  • 「再実行直前にもクリーンアップが走る」という性質は、debounceのように「前回の副作用を必ず後始末してから次の副作用を始めたい」という処理を、1つのuseEffectの中だけで自然に実現するための土台になっている。

TypeScript/Reactを学びなおす(AI生成) #2 判別可能ユニオンで「ありえない状態」を表現不可能にする

「あるフィールド(タグ)の値によって、他のどのフィールドが有効か決まる」というデータ構造は実務でよく出てきます。通知の送信方法がemail/sms/pushのどれかで、それぞれ固有の付随データを持つ、といったケースです。この記事では、そういうデータを素朴に書いたときにどんな穴が生まれるか、それをTypeScriptの判別可能ユニオン(discriminated union)でどう塞げるかを、実際にコンパイラを動かしながら確認していきます。

「タグ付きのフラットな型」に潜む穴

素朴な書き方

まずやってしまいがちな書き方がこれです。

type NotificationAction = {
  channel: "email" | "sms" | "push";
  emailSubject: string | null;
  smsText: string | null;
  pushTitle: string | null;
};

channel"email"ならemailSubjectだけが有効で、他はnullのはず」というルールをコメントで書きたくなりますが、この型定義自体はその対応関係を何も表現できていません。

実験: おかしい値もコンパイルを通ってしまう

実際に、本来ありえないはずの値を作ってみます。

const weird: NotificationAction = {
  channel: "email",
  emailSubject: null,
  smsText: "この値は本来存在しないはず",
  pushTitle: null,
};

これをtscにかけても、エラーは1件も出ませんchannel: "email"なのにsmsTextに値が入りemailSubjectnullという、明らかにおかしい組み合わせを、型システムは正しい値として受け入れてしまいます。「タグと他のフィールドの対応関係」は、フラットな型定義では表現できないのです。

判別可能ユニオンで書き直す

定義

これを「1つの型」ではなく「複数の型のunion」として表現し、各メンバーに共通のタグ(判別子)フィールドを持たせます。

type NotificationAction =
  | { channel: "email"; emailSubject: string }
  | { channel: "sms"; smsText: string }
  | { channel: "push"; pushTitle: string };

channel"email"のメンバーには、そもそもsmsTextpushTitleというプロパティ自体が存在しません。「ありえない組み合わせ」を型のレベルで表現不可能にする、という考え方です。

実験: 余分なプロパティがちゃんと弾かれる

さっきと同じ「おかしい値」を、この新しい型で作ろうとしてみます。

const weird: NotificationAction = {
  channel: "email",
  emailSubject: "件名",
  smsText: "この値は本来存在しないはず",
};

今度はちゃんとエラーになります。

error TS2353: Object literal may only specify known properties,
  and 'smsText' does not exist in type '{ channel: "email"; emailSubject: string; }'.

TypeScriptはchannel: "email"という値からunionのどのメンバーかを判別し、それ以外のプロパティ(smsText)を「そのメンバーには存在しないプロパティ」として拒否しています。

narrowing — タグで分岐すると、対応するフィールドだけが安全に見える

実験: 絞り込みなしでのアクセスはそもそも許されない

判別可能ユニオンのもう1つの効能は、channelで分岐した先で、対応するフィールドだけが安全にアクセスできるようになることです。まず、分岐せずにいきなりアクセスするとどうなるか確認します。

function describe(action: NotificationAction): string {
  return action.emailSubject;
}
error TS2339: Property 'emailSubject' does not exist on type 'NotificationAction'.
  Property 'emailSubject' does not exist on type '{ channel: "sms"; smsText: string; }'.

union型の値に対して直接アクセスできるのは、すべてのメンバーに共通するプロパティだけというのがTypeScriptの基本ルールです。emailSubject"sms"のメンバーには存在しないので、絞り込みなしではアクセスできません。

実験: 分岐すれば narrowing が効く

channelの値で分岐(narrowing)すると、その分岐の中だけ安全にアクセスできるようになります。

function describe(action: NotificationAction): string {
  if (action.channel === "email") {
    return action.emailSubject;
  }
  if (action.channel === "sms") {
    return action.smsText;
  }
  return action.pushTitle;
}

これはエラーなく通ります。if (action.channel === "email")の中では、TypeScriptはactionの型を{ channel: "email"; emailSubject: string }だけに絞り込んでいるので、emailSubjectに安全にアクセスできます。

網羅性チェック — 将来メンバーが増えたときの対応漏れを防ぐ

switch + never の定番パターン

判別可能ユニオンのもう1つの強力な使い方は、「将来メンバーが増えたときに、対応漏れをコンパイル時に検出する」ことです。switch文と、never型を受け取る関数を組み合わせるのが定番のパターンです。

function assertNever(x: never): never {
  throw new Error("Unhandled case: " + JSON.stringify(x));
}

function label(action: NotificationAction): string {
  switch (action.channel) {
    case "email":
      return "Eメール";
    case "sms":
      return "SMS";
    case "push":
      return "プッシュ通知";
    default:
      return assertNever(action);
  }
}

default節に到達する時点で、email/sms/pushのすべてのcaseを通り過ぎているはずなので、TypeScriptは「もう残っているメンバーは無い」と判断し、その場所でのactionの型をnever(=「ありえない」という意味の型)に絞り込みます。neverassertNever(x: never)にそのまま渡せるので、エラーになりません。

実験: 新しいメンバーを追加して、対応漏れが検出されるか確認する

ここに新しいチャンネル"webhook"を追加しますが、labelswitchにはわざとcaseを足さずにおきます。

type NotificationAction =
  | { channel: "email"; emailSubject: string }
  | { channel: "sms"; smsText: string }
  | { channel: "push"; pushTitle: string }
  | { channel: "webhook"; webhookUrl: string };

tscを実行すると、2箇所でエラーが出ます。

src/playground/06-discriminated-union.ts:22:19 - error TS2339: Property 'pushTitle' does not exist
  on type '{ channel: "push"; pushTitle: string; } | { channel: "webhook"; webhookUrl: string; }'.
    return action.pushTitle;

src/playground/06-discriminated-union.ts:40:26 - error TS2345: Argument of type
  '{ channel: "webhook"; webhookUrl: string; }' is not assignable to parameter of type 'never'.
      return assertNever(action);

1つ目はdescribe(if文の連鎖)。email/smsを絞り込んだ後に残る型がpush | webhookのunionになり、pushTitlepushにしかないので「共通プロパティではない」という理由でエラーになっています。

2つ目はlabel(switch + assertNever)。default節に到達した時点で、email/sms/pushcaseで消費済みのはずが、webhookが未対応のまま残っているので、その場所でのactionの型は{ channel: "webhook"; webhookUrl: string }のままです。これはneverに代入できないので、エラーになります。

2種類の検出のされ方の違い

どちらも「対応漏れ」を教えてくれていますが、性質が違います。describeの方は、たまたま未対応メンバー固有のフィールドにアクセスしていたから検出できた、いわば偶然の産物です。もし最後の分岐がreturn "default";のように固定値を返すだけだったら、エラーは出ずに黙って間違った挙動をしてしまいます。

一方label + assertNeverのパターンは、「switchの全caseを消費してもまだ型が残っている」という事実そのものを検出しています。分岐の中身が何をしていようと関係なく、対応漏れを確実に・意図的にコンパイルエラーにできます。これが網羅性チェックの定番パターンとして広く使われている理由です。

まとめ

  • 「タグ+複数のoptional/nullableフィールド」というフラットな型は、タグと他のフィールドの対応関係を何も保証しない。ありえない組み合わせもコンパイルを通ってしまう。
  • 判別可能ユニオン(共通のタグフィールドを持つ型のunion)にすると、各メンバーに無関係なプロパティがそもそも存在しなくなり、余分なプロパティの代入がコンパイルエラーになる。
  • union型の値は、絞り込みなしでは全メンバーに共通するプロパティしかアクセスできない。タグで分岐(narrowing)すると、その分岐内だけ対応するプロパティに安全にアクセスできる。
  • switch + default節でnever型を受け取る関数(assertNever)に渡すパターンは、将来メンバーが増えて対応漏れが生じたときに、それを確実にコンパイルエラーとして検出できる。たまたまフィールドアクセスで引っかかるのを期待するより、この明示的なパターンの方が信頼できる。