🎣

Reactを根本から理解する:Hooksの仕組みを実装して学ぶ

に公開
2026/01/01

はじめに

この記事のゴール

Reactを使っていると、useStateやuseEffectを「こう書けば動く」という形で覚えていることが多いのではないでしょうか。

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

useEffect(() => {
  console.log(count);
}, [count]);

このコードは動きます。しかし、なぜ動くのかを説明できるでしょうか。

  • なぜuseStateは配列を返すのか
  • なぜsetCountを呼ぶと再レンダリングされるのか
  • なぜuseEffectの依存配列に値を入れると、その値が変わったときだけ実行されるのか
  • なぜHooksを条件分岐の中で呼んではいけないのか

この記事では、これらの疑問に答えるために、Hooksの仕組みを実際に実装しながら学んでいきます。最終的には、Hooksが「魔法」ではなく、JavaScriptの基本的な仕組みの上に成り立っていることが理解できるようになります。

対象読者

  • Reactを使ったことがあるが、Hooksの仕組みを深く理解していない方
  • 「なぜそう書くのか」を説明できるようになりたい方
  • JavaScriptの基礎(変数、関数、配列)は理解している方

第1章:JavaScriptのスコープを理解する

Hooksを理解するためには、まずJavaScriptの「スコープ」という概念を正確に理解する必要があります。スコープは、Hooksの仕組みの土台となる重要な概念です。

1-1. スコープとは何か

プログラムの中で変数を使うとき、JavaScriptは「この変数はどこにあるのか」を探す必要があります。この「変数を探す範囲」を決めるルールがスコープです。

簡単な例を見てみましょう。

const message = "こんにちは";

const greet = () => {
  console.log(message);
};

greet();  // "こんにちは"

greet関数の中でmessageを使っています。greet関数の中にはmessageという変数は定義されていませんが、外側で定義されたmessageを見つけて使うことができます。

では、次の場合はどうでしょうか。

const message = "こんにちは";

const greet = () => {
  const message = "おはよう";
  console.log(message);
};

greet();  // "おはよう"

今度はgreet関数の中にもmessageが定義されています。この場合、関数の中で定義されたmessage("おはよう")が使われます。

これがスコープの基本的な動作です。JavaScriptは変数を探すとき、まず「自分のスコープ」を探し、なければ「外側のスコープ」を探します。

1-2. グローバルスコープとローカルスコープ

スコープには大きく分けて2種類あります。

グローバルスコープは、プログラム全体からアクセスできる範囲です。関数の外側で定義された変数は、グローバルスコープに属します。

const globalVariable = "どこからでもアクセス可能";

const func1 = () => {
  console.log(globalVariable);  // アクセス可能
};

const func2 = () => {
  console.log(globalVariable);  // アクセス可能
};

ローカルスコープは、特定の範囲内からのみアクセスできる範囲です。関数の中で定義された変数は、その関数のローカルスコープに属します。

const outer = () => {
  const localVariable = "この関数内からのみアクセス可能";
  console.log(localVariable);  // アクセス可能
};

outer();
console.log(localVariable);  // エラー!外からはアクセスできない

関数の中で定義された変数は、その関数の外からはアクセスできません。これは、変数を「隠す」ための重要な仕組みです。

1-3. レキシカルスコープ(静的スコープ)

ここからが重要です。JavaScriptはレキシカルスコープ(静的スコープ)というルールを採用しています。

「レキシカル(lexical)」とは「字句的」「語彙的」という意味です。つまり、変数のスコープは、コードが書かれた位置によって決まるということです。

具体的に見てみましょう。

const x = "グローバル";

const outer = () => {
  const x = "outer";

  const inner = () => {
    console.log(x);  // ここでどのxが使われるか?
  };

  inner();
};

outer();  // "outer"

inner関数の中でxを使っています。inner関数の中にはxは定義されていません。では、どのxが使われるのでしょうか。

答えは「outer」です。なぜなら、inner関数はコード上でouter関数の内側に書かれているからです。

これがレキシカルスコープの意味です。変数を探すとき、「どこで実行されたか」ではなく「コード上でどこに書かれているか」を見ます。

もう一つ例を見てみましょう。

const x = "グローバル";

const printX = () => {
  console.log(x);  // この関数はグローバルスコープに書かれている
};

const wrapper = () => {
  const x = "wrapper";
  printX();  // wrapperの中で呼び出している
};

wrapper();  // "グローバル"

printXはwrapperの中で呼び出されていますが、出力されるのは「グローバル」です。なぜなら、printXはコード上でグローバルスコープに書かれているからです。printXが実行されるとき、xを探す先は「printXが書かれた場所から見える範囲」であり、「printXが呼び出された場所」ではありません。

1-4. スコープチェーン

変数を探すとき、JavaScriptは「自分のスコープ → 一つ外側のスコープ → さらに外側 → ... → グローバルスコープ」という順番で探します。この連鎖をスコープチェーンと呼びます。

const a = "グローバル";

const level1 = () => {
  const b = "level1";

  const level2 = () => {
    const c = "level2";

    const level3 = () => {
      console.log(a);  // 3つ外側のスコープで発見
      console.log(b);  // 2つ外側のスコープで発見
      console.log(c);  // 1つ外側のスコープで発見
    };

    level3();
  };

  level2();
};

level1();

level3関数の中から見ると、スコープは次のように入れ子になっています。

┌─────────────────────────────────────────────┐
│ グローバルスコープ                            │
│   const a = "グローバル"                     │
│                                             │
│   ┌─────────────────────────────────────┐   │
│   │ level1のスコープ                     │   │
│   │   const b = "level1"                │   │
│   │                                     │   │
│   │   ┌─────────────────────────────┐   │   │
│   │   │ level2のスコープ             │   │   │
│   │   │   const c = "level2"        │   │   │
│   │   │                             │   │   │
│   │   │   ┌─────────────────────┐   │   │   │
│   │   │   │ level3のスコープ     │   │   │   │
│   │   │   │   a, b, c を探す     │   │   │   │
│   │   │   └─────────────────────┘   │   │   │
│   │   └─────────────────────────────┘   │   │
│   └─────────────────────────────────────┘   │
└─────────────────────────────────────────────┘

level3からaを探すとき、まずlevel3のスコープを探します。見つからないので、一つ外側のlevel2のスコープを探します。まだ見つからないので、さらに外側のlevel1のスコープを探します。まだ見つからないので、グローバルスコープを探し、そこでaを発見します。

重要なのは、このスコープチェーンはコードを書いた時点で決まっているということです。実行時に動的に変わるものではありません。これがレキシカルスコープの本質です。


第2章:クロージャを理解する

スコープを理解したところで、次はクロージャという概念を学びます。クロージャは、Hooksの仕組みを理解するために不可欠な概念です。

2-1. クロージャとは何か

クロージャとは、関数が自分の外側のスコープにある変数を「記憶」し、後からでもアクセスできる仕組みのことです。

まず、通常の関数の動作を見てみましょう。

const doSomething = () => {
  const localVar = "ローカル変数";
  console.log(localVar);
};

doSomething();  // "ローカル変数"
// この時点でlocalVarは消えている

関数が実行されると、その中で定義された変数(localVar)は一時的に作られ、関数の実行が終わると消えます。これが通常の動作です。

では、次の例を見てみましょう。

const createGreeter = () => {
  const message = "こんにちは";

  const greet = () => {
    console.log(message);
  };

  return greet;  // 内側の関数を返す
};

const myGreeter = createGreeter();
// この時点でcreateGreeter()の実行は終了している

myGreeter();  // "こんにちは"

createGreeter()の実行は終了しています。通常なら、messageという変数は消えているはずです。しかし、myGreeter()を呼ぶと、まだmessageにアクセスできています。

これがクロージャです。greet関数は、自分が定義された場所のスコープ(createGreeterの内側)への参照を持ち続けています。だから、createGreeter()の実行が終わった後でも、messageにアクセスできるのです。

2-2. なぜ変数が「生き続ける」のか

通常、関数の実行が終わると、その中の変数はガベージコレクション(不要なメモリの回収)の対象になります。しかし、クロージャの場合は違います。

const createCounter = () => {
  let count = 0;  // この変数は消えない

  const increment = () => {
    count = count + 1;
    return count;
  };

  return increment;
};

const counter = createCounter();

createCounter()が実行されると、次のことが起こります。

  1. count変数が作られる(値は0)
  2. increment関数が作られる
  3. increment関数は、自分が定義されたスコープ(countがある場所)への参照を持つ
  4. increment関数が返される
  5. createCounter()の実行が終了する

通常なら、5の時点でcountは消えるはずです。しかし、increment関数がcountへの参照を持っているため、JavaScriptは「この変数はまだ使われる可能性がある」と判断し、countをメモリに残します。

console.log(counter());  // 1
console.log(counter());  // 2
console.log(counter());  // 3

counter()を呼ぶたびにcountの値が増えていきます。countはcreateCounter()の外からは直接アクセスできませんが、increment関数(今はcounterという名前で参照されている)を通じてアクセスできます。

2-3. クロージャの実用例

クロージャがどのように役立つか、いくつかの例を見てみましょう。

例1:プライベートな状態の実現

JavaScriptにはクラスのプライベートフィールド(#構文)がありますが、関数ベースでプライベートな状態を実現する場合は、クロージャが有効です。

const createBankAccount = (initialBalance) => {
  let balance = initialBalance;  // 外からアクセスできない

  const deposit = (amount) => {
    balance = balance + amount;
    return balance;
  };

  const withdraw = (amount) => {
    if (amount > balance) {
      return "残高不足";
    }
    balance = balance - amount;
    return balance;
  };

  const getBalance = () => {
    return balance;
  };

  return { deposit, withdraw, getBalance };
};

const account = createBankAccount(1000);
console.log(account.getBalance());  // 1000
console.log(account.deposit(500));  // 1500
console.log(account.withdraw(200)); // 1300
// account.balance にはアクセスできない

balanceは外部から直接変更できません。deposit、withdraw、getBalanceという関数を通じてのみアクセスできます。これはクロージャによって実現されています。

例2:関数ファクトリー

特定の値を「覚えている」関数を作ることができます。

const createMultiplier = (factor) => {
  const multiply = (number) => {
    return number * factor;  // factorを「覚えている」
  };

  return multiply;
};

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5));  // 10
console.log(triple(5));  // 15

doubleはfactorが2であることを、tripleはfactorが3であることを、それぞれ「覚えて」います。


第3章:useStateの仕組み

ここまでで、スコープとクロージャについて理解しました。いよいよReactのHooksの仕組みを見ていきます。まずはuseStateからです。

3-1. 関数コンポーネントの問題点

Reactの関数コンポーネントは、レンダリングのたびに関数が最初から実行されます。これは重要なポイントです。

const Counter = () => {
  let count = 0;

  const handleClick = () => {
    count = count + 1;
    console.log(count);
  };

  return (
    <button onClick={handleClick}>
      カウント: {count}
    </button>
  );
};

このコードには問題があります。ボタンをクリックすると、handleClickが実行され、countは1になります。しかし、画面に表示されるのは依然として「カウント: 0」です。

なぜでしょうか。countを変更しても、Reactは「画面を更新する必要がある」と認識しません。また、仮に再レンダリングが起きたとしても、let count = 0が再び実行されるため、countは0に戻ってしまいます。

関数コンポーネントで状態を管理するには、次の2つの問題を解決する必要があります。

  1. 再レンダリングをトリガーする仕組み
  2. 再レンダリング後も値が保持される仕組み

3-2. 状態を「外側」に保存する

解決策は、状態を関数コンポーネントの外側に保存することです。クロージャを使えば、これが実現できます。

// コンポーネントの「外側」に状態を保存する場所を作る
let state;

const useState = (initialValue) => {
  // stateが未定義なら初期値を使う
  if (state === undefined) {
    state = initialValue;
  }

  const setState = (newValue) => {
    state = newValue;
    render();  // 再レンダリングをトリガー
  };

  return [state, setState];
};

const render = () => {
  // コンポーネントを再実行する(簡略化)
  console.log("--- レンダリング ---");
  Counter();
};

const Counter = () => {
  const [count, setCount] = useState(0);
  console.log("count:", count);

  // 実際のReactではJSXを返すが、ここでは省略
};

このコードを追ってみましょう。

render();  // 1回目のレンダリング

// Counter()が実行される
// useState(0)が呼ばれる
//   - stateはundefinedなので、state = 0 になる
//   - [0, setState]が返される
// "count: 0" が出力される
// setCount(1) が呼ばれたとする

// setState(1)が実行される
//   - state = 1 になる
//   - render()が呼ばれる

// Counter()が再実行される
// useState(0)が呼ばれる
//   - stateは1なので、初期化はスキップ
//   - [1, setState]が返される
// "count: 1" が出力される

ポイントは、state変数がuseState関数の外側にあることです。だから、Counter関数が何度実行されても、stateの値は保持されます。

そして、setState関数はstate変数へのクロージャを持っています。だから、後からsetStateを呼んでも、正しくstateを更新できます。

3-3. 最小限のuseStateを実装する

先ほどのコードを整理して、最小限のuseStateを実装してみましょう。

const MyReact = (() => {
  let state;

  const useState = (initialValue) => {
    // 初回のみ初期化
    if (state === undefined) {
      state = initialValue;
    }

    const setState = (newValue) => {
      state = newValue;
      render();
    };

    return [state, setState];
  };

  const render = () => {
    App();
  };

  return { useState, render };
})();

const App = () => {
  const [count, setCount] = MyReact.useState(0);
  console.log("count:", count);

  // setCountを呼び出す関数を返す(実際のReactではボタンのonClickなどで使う)
  return { setCount };
};

// 初回レンダリング
MyReact.render();  // "count: 0"

// 状態を更新(実際のReactではイベントハンドラから呼ばれる)
// const { setCount } = App();  // これは説明のための擬似コード
// setCount(1);  // render()が呼ばれ、"count: 1" が出力される

このコードは動作します。しかし、一つ大きな制限があります。useStateを複数回呼ぶことができません。

const App = () => {
  const [name, setName] = MyReact.useState("太郎");
  const [age, setAge] = MyReact.useState(20);
  // nameもageも同じstate変数を参照してしまう!
};

3-4. 複数のuseStateに対応する(配列とインデックス)

複数のuseStateに対応するために、状態を配列で管理します。

const MyReact = (() => {
  let hooks = [];        // 状態を保存する配列
  let currentIndex = 0;  // 現在処理中のhookのインデックス

  const useState = (initialValue) => {
    const index = currentIndex;  // このuseStateが使うインデックスを固定

    // 初回のみ初期化
    if (hooks[index] === undefined) {
      hooks[index] = initialValue;
    }

    const setState = (newValue) => {
      hooks[index] = newValue;  // クロージャでindexを記憶している
      render();
    };

    currentIndex++;  // 次のuseStateのためにインデックスを進める

    return [hooks[index], setState];
  };

  const render = () => {
    currentIndex = 0;  // レンダリング開始時にリセット
    App();
  };

  return { useState, render };
})();

このコードを詳しく追ってみましょう。

const App = () => {
  const [name, setName] = MyReact.useState("太郎");  // 1つ目
  const [age, setAge] = MyReact.useState(20);        // 2つ目
  console.log(name, age);
};

1回目のレンダリング:

render()が呼ばれる
  currentIndex = 0 にリセット

App()が実行される

  useState("太郎")が呼ばれる
    index = 0(currentIndexの値を固定)
    hooks[0]はundefinedなので、hooks[0] = "太郎"
    setStateはindex=0をクロージャで記憶
    currentIndex = 1 に進める
    return ["太郎", setState]

  useState(20)が呼ばれる
    index = 1(currentIndexの値を固定)
    hooks[1]はundefinedなので、hooks[1] = 20
    setStateはindex=1をクロージャで記憶
    currentIndex = 2 に進める
    return [20, setState]

結果: hooks = ["太郎", 20]
出力: "太郎" 20

setName("花子")が呼ばれた後:

setName("花子")が実行される
  hooks[0] = "花子"(indexは0でクロージャに記憶されている)
  render()が呼ばれる

render()が呼ばれる
  currentIndex = 0 にリセット

App()が実行される

  useState("太郎")が呼ばれる
    index = 0
    hooks[0]は"花子"なので、初期化はスキップ
    return ["花子", setState]

  useState(20)が呼ばれる
    index = 1
    hooks[1]は20なので、初期化はスキップ
    return [20, setState]

結果: hooks = ["花子", 20]
出力: "花子" 20

重要なポイント:

  1. 各useStateは、hooks配列の特定のインデックスを使う
  2. setStateは、クロージャによって自分のインデックスを「記憶」している
  3. レンダリングのたびにcurrentIndexは0にリセットされる
  4. useStateは呼び出された順番でインデックスが割り当てられる

3-5. なぜHooksの呼び出し順序が重要なのか

Reactには「Hooksのルール」と呼ばれる制約があります。

Hooksのルール:

  1. Hooksはコンポーネントのトップレベルでのみ呼び出す
  2. 条件分岐、ループ、ネストされた関数の中で呼び出してはいけない

なぜこのようなルールがあるのでしょうか。ここまでの実装を振り返ると、理由が見えてきます。

配列とインデックスの仕組みを振り返る

私たちが実装したuseStateでは、状態を配列で管理していました。

let hooks = [];        // 状態を保存する配列
let currentIndex = 0;  // 現在のインデックス

そして、useStateが呼ばれるたびに、呼び出された順番でインデックスが割り当てられます。

const App = () => {
  const [name, setName] = useState("太郎");  // hooks[0]
  const [age, setAge] = useState(20);        // hooks[1]
  const [city, setCity] = useState("東京");  // hooks[2]
};

この対応関係を図にすると:

呼び出し順序        配列のインデックス
-----------        ----------------
1番目のuseState  →  hooks[0]  →  "太郎"
2番目のuseState  →  hooks[1]  →  20
3番目のuseState  →  hooks[2]  →  "東京"

Reactは「1番目のuseStateはhooks[0]」「2番目のuseStateはhooks[1]」というように、呼び出し順序だけで状態を識別しています。変数名(name、age、city)は関係ありません。

再レンダリング時の動作

再レンダリング時には、currentIndexが0にリセットされ、再び順番にHooksが呼ばれます。

1回目のレンダリング          2回目のレンダリング
------------------          ------------------
useState("太郎") → hooks[0]   useState("太郎") → hooks[0]
useState(20)    → hooks[1]   useState(20)    → hooks[1]
useState("東京") → hooks[2]   useState("東京") → hooks[2]

呼び出し順序が同じなので、各useStateは正しい状態を取得できます。

条件分岐を入れるとどうなるか

では、条件分岐の中でuseStateを呼ぶとどうなるでしょうか。

const App = () => {
  const [name, setName] = useState("太郎");

  // 条件付きでuseStateを呼ぶ(これは禁止!)
  if (name === "太郎") {
    const [nickname, setNickname] = useState("タロー");
  }

  const [age, setAge] = useState(20);
};

1回目のレンダリング(name = "太郎"):

if文の条件がtrueなので、nickname用のuseStateが実行されます。

useState("太郎")  → hooks[0] = "太郎"   // name
useState("タロー") → hooks[1] = "タロー"  // nickname(ifがtrue)
useState(20)     → hooks[2] = 20       // age

2回目のレンダリング(setName("花子")で更新後):

if文の条件がfalseになるので、nickname用のuseStateは実行されません。

currentIndex = 0 にリセット

useState("太郎")  → index=0 → hooks[0]から"花子"を取得 → OK

// if文がfalseなのでuseState("タロー")はスキップされる

useState(20)     → index=1 → hooks[1]から... "タロー"?!

age用のuseStateがhooks[1]を参照しますが、そこには"タロー"が入っています。

図で見ると:

1回目のレンダリング        2回目のレンダリング
------------------        ------------------
hooks[0] ← name           hooks[0] ← name       OK
hooks[1] ← nickname       hooks[1] ← age        ずれた!
hooks[2] ← age            hooks[2] ← (未使用)

インデックスがずれて、ageに"タロー"という文字列が入ってしまいます。これはバグの原因になります。

正しい書き方

条件分岐が必要な場合は、Hooksの外側で行います。

// ダメな例
const App = () => {
  if (condition) {
    const [value, setValue] = useState(0);  // NG
  }
};

// 良い例
const App = () => {
  const [value, setValue] = useState(0);  // 常に呼ばれる

  if (condition) {
    // valueを使った処理
  }
};

Hooksは常に同じ順序で呼ばれるようにし、条件分岐はHooksの戻り値を使う部分で行うのが正しいパターンです。

なぜReactはこの設計を選んだのか

「なぜ変数名ではなく、呼び出し順序で管理するのか」と疑問に思うかもしれません。

変数名で管理する設計も考えられます:

// 仮にこういう設計だったら...
const [name, setName] = useState("name", "太郎");  // キーを指定
const [age, setAge] = useState("age", 20);         // キーを指定

しかし、Reactチームはこの設計を選びませんでした。理由はいくつかありますが、主なものは:

  1. シンプルさ:キーを指定しなくてよいので、コードが簡潔になる
  2. カスタムHookとの相性:カスタムHook内でuseStateを使うとき、キーの衝突を気にしなくてよい
  3. 型安全性:TypeScriptとの相性が良い

呼び出し順序で管理する設計は、「条件分岐で使えない」という制約と引き換えに、上記のメリットを得ています。


コラム:実際のReactとの違い

ここまで実装してきたMyReactは、教育目的の簡略化されたものです。実際のReactとはいくつかの重要な違いがあります。

違い1:renderの呼び出し方

この記事のコード:

const setState = (newValue) => {
  state = newValue;
  render();  // 直接render()を呼んでいる
};

setStateとrenderが同じスコープ内にあるため、直接呼び出せています。

実際のReact:

const setState = (newValue) => {
  // 1. 更新をキューに追加
  updateQueue.push({
    fiber: currentFiber,  // どのコンポーネントか
    action: newValue,     // 新しい値
  });

  // 2. 再レンダリングをスケジュール
  scheduleUpdateOnFiber(currentFiber);
};

実際のReactでは、setStateは直接レンダリングを実行しません。代わりに、「このコンポーネントを再レンダリングしてください」というリクエストをスケジューラに登録します。

setStateは、自分がどのコンポーネント(Fiber)に属しているかをクロージャで記憶しており、その情報を使って更新をキューに追加します。

違い2:バッチ処理

この記事のコード:

const handleClick = () => {
  setCount(1);   // render()が呼ばれる
  setName("太郎"); // render()が呼ばれる
  setAge(20);    // render()が呼ばれる
  // 合計3回レンダリング
};

実際のReact:

const handleClick = () => {
  setCount(1);
  setName("太郎");
  setAge(20);
  // 3つの更新がキューに追加され、まとめて1回だけレンダリング
};

実際のReactは、複数のsetState呼び出しをバッチ処理します。イベントハンドラ内で複数回setStateを呼んでも、レンダリングは1回だけです。これにより、パフォーマンスが大幅に向上します。

注意:React 17以前とReact 18以降の違い

React 17以前では、バッチ処理はイベントハンドラ内のみで行われていました。setTimeoutやPromiseの中でのsetStateは個別にレンダリングが発生していました。

React 18以降では、すべてのsetStateが自動的にバッチ処理されます。これは「Automatic Batching」と呼ばれる機能です。

違い3:優先度の管理

実際のReactにはスケジューラがあり、更新の優先度を管理します。

// ユーザー入力(高優先度)→ すぐに反映
setInputValue(e.target.value);

// 重い計算(低優先度)→ 後回しにできる
startTransition(() => {
  setSearchResults(heavyCalculation());
});

ユーザーの入力など、即座に反映すべき更新は高優先度で処理されます。一方、検索結果の表示など、少し遅れても問題ない更新は低優先度として後回しにできます。これがReact 18で導入されたConcurrent Modeの基盤です。

違い4:Fiberアーキテクチャ

この記事では、状態を単純な配列(hooks = [])で管理しました。実際のReactでは、Fiberと呼ばれるデータ構造を使っています。

// Fiberノードのイメージ(簡略化)
const fiber = {
  type: App,                    // コンポーネントの種類
  memoizedState: {              // Hooksの状態(リンクドリスト)
    value: 0,
    next: {
      value: "太郎",
      next: null
    }
  },
  updateQueue: [],              // 保留中の更新
  // ... 他にも多くのプロパティ
};

Fiberは、レンダリングを中断・再開できるようにするためのアーキテクチャです。これにより、重い処理中でもユーザー操作に即座に反応できます。

この記事で学んだことは無駄ではない

実際のReactは複雑ですが、根本的な考え方は同じです。

  • 状態はコンポーネントの外側で管理される
  • Hooksは呼び出し順序で識別される
  • setStateはクロージャで自分の状態への参照を持つ

この記事で学んだメンタルモデルは、実際のReactを理解する土台として有効です。


第4章:useEffectの仕組み

useStateの仕組みを理解したところで、次はuseEffectを見ていきましょう。useEffectも、同じ配列とインデックスの仕組みを使っています。

4-1. 副作用とは何か

Reactにおける「副作用(side effect)」とは、コンポーネントのレンダリング(UIの計算)以外の処理のことです。

具体的には:

  • APIからのデータ取得
  • DOMの直接操作
  • タイマーの設定
  • ローカルストレージへのアクセス
  • イベントリスナーの登録

これらの処理は、コンポーネントが「何を表示するか」を計算する処理とは別物です。useEffectは、このような副作用を適切なタイミングで実行するためのHooksです。

4-2. 依存配列の役割

useEffectには「依存配列」という引数があります。

useEffect(() => {
  console.log("countが変わった");
}, [count]);  // countが変わったときだけ実行

依存配列は、「この値が変わったときだけ、副作用を実行してください」という指定です。

  • [count]:countが変わったときだけ実行
  • [count, name]:countまたはnameが変わったときに実行
  • []:初回のレンダリング時のみ実行(マウント時)
  • 省略:毎回のレンダリングで実行

4-3. 最小限のuseEffectを実装する

useStateと同じ配列の仕組みを使って、useEffectを実装します。

const MyReact = (() => {
  let hooks = [];
  let currentIndex = 0;

  const useState = (initialValue) => {
    const index = currentIndex;

    if (hooks[index] === undefined) {
      hooks[index] = initialValue;
    }

    const setState = (newValue) => {
      hooks[index] = newValue;
      render();
    };

    currentIndex++;
    return [hooks[index], setState];
  };

  const useEffect = (callback, deps) => {
    const index = currentIndex;
    const prevDeps = hooks[index];  // 前回の依存配列を取得

    // 実行すべきかどうか判定
    let shouldRun;

    if (prevDeps === undefined) {
      // 前回の依存配列がない = 初回レンダリング
      shouldRun = true;
    } else {
      // 前回と今回の依存配列を比較
      shouldRun = deps.some((dep, i) => dep !== prevDeps[i]);
    }

    if (shouldRun) {
      // 注意: 実際のReactでは、callbackはレンダリング完了後に非同期で実行される
      // ここでは仕組みを理解するため、簡略化して同期実行している
      callback();
    }

    // 今回の依存配列を保存
    hooks[index] = deps;
    currentIndex++;
  };

  const render = () => {
    currentIndex = 0;
    App();
  };

  return { useState, useEffect, render };
})();

4-4. 依存配列の比較ロジックを理解する

useEffectの中で、依存配列を比較している部分を詳しく見てみましょう。

if (prevDeps === undefined) {
  shouldRun = true;
} else {
  shouldRun = deps.some((dep, i) => dep !== prevDeps[i]);
}

deps.some(...)の部分は、forループで書くと以下と同じ意味です:

for (let i = 0; i < deps.length; i++) {
  if (deps[i] !== prevDeps[i]) {
    shouldRun = true;
    break;
  }
}

配列の要素を一つずつ比較し、**一つでも違っていればtrue**を返します。

具体例で見てみましょう。

const App = () => {
  const [count, setCount] = MyReact.useState(0);
  const [name, setName] = MyReact.useState("太郎");

  MyReact.useEffect(() => {
    console.log("countが変わった:", count);
  }, [count]);
};

1回目のレンダリング:

hooks = [0, "太郎", undefined]
//       ↑    ↑       ↑
//      [0]  [1]     [2]
//     count name  useEffectの依存配列

useEffect([count])が呼ばれる
  index = 2
  prevDeps = hooks[2] = undefined(初回だから)

  → 初回なので shouldRun = true
  → callback()を実行 → "countが変わった: 0"
  → hooks[2] = [0] を保存

hooks = [0, "太郎", [0]]

2回目のレンダリング(setCount(1)後):

hooks = [1, "太郎", [0]]
//       ↑    ↑     ↑
//      [0]  [1]   [2]
//     count name  前回の依存配列

useEffect([count])が呼ばれる
  index = 2
  prevDeps = hooks[2] = [0]
  deps = [1](現在のcount)

  比較(forループ):
    i = 0: deps[0] (= 1) !== prevDeps[0] (= 0) → 違う!

  → shouldRun = true
  → callback()を実行 → "countが変わった: 1"
  → hooks[2] = [1] を保存

hooks = [1, "太郎", [1]]

3回目のレンダリング(setName("花子")後、countは1のまま):

hooks = [1, "花子", [1]]
//       ↑    ↑     ↑
//      [0]  [1]   [2]

useEffect([count])が呼ばれる
  index = 2
  prevDeps = hooks[2] = [1]
  deps = [1](現在のcount)

  比較(forループ):
    i = 0: deps[0] (= 1) !== prevDeps[0] (= 1) → 同じ

  → shouldRun = false
  → callback()は実行されない

hooks = [1, "花子", [1]](変化なし)

このように、依存配列の中身を前回の値と今回の値で一つずつ比較して、一つでも違っていれば副作用を実行します。

4-5. 空配列[]の意味

空の依存配列[]を渡すと、初回レンダリング時のみ実行されます。

MyReact.useEffect(() => {
  console.log("初回のみ実行");
}, []);

なぜでしょうか。コードを追ってみましょう。

1回目のレンダリング:

prevDeps = undefined(初回だから)
shouldRun = true
callback()を実行 → "初回のみ実行"
hooks[index] = []  // 空配列を保存

2回目以降のレンダリング:

prevDeps = [](前回保存した空配列)
deps = [](今回も空配列)

比較:
  deps.some((dep, i) => dep !== prevDeps[i])

  配列が空なので、someは一度もtrueを返さない
  → someの結果はfalse

shouldRun = false
callback()は実行されない

空の配列同士を比較すると、「一つでも違う要素」は存在しないので、someは常にfalseを返します。結果として、初回以降は副作用が実行されません。

4-6. クリーンアップ関数

useEffectには、クリーンアップ関数という重要な機能があります。これは、副作用を「後始末」するための仕組みです。

useEffect(() => {
  // 副作用の実行
  const subscription = api.subscribe(userId);

  // クリーンアップ関数を返す
  return () => {
    subscription.unsubscribe();  // 後始末
  };
}, [userId]);

クリーンアップ関数は、以下のタイミングで実行されます。

  1. コンポーネントがアンマウントされるとき
  2. 依存配列の値が変わり、次の副作用が実行される直前

なぜクリーンアップが必要なのでしょうか。典型的な例を見てみましょう。

例:イベントリスナーの登録と解除

useEffect(() => {
  const handleKeyDown = (e) => {
    console.log('キーが押されました:', e.key);
  };

  window.addEventListener('keydown', handleKeyDown);

  // クリーンアップ:リスナーを解除
  return () => {
    window.removeEventListener('keydown', handleKeyDown);
  };
}, []);

もしクリーンアップを行わないと、コンポーネントが何度も再マウントされるたびにイベントリスナーが追加され続け、メモリリークや予期しない動作の原因になります。

簡略化した実装

クリーンアップ関数を含めたuseEffectの実装イメージは次のようになります。

const useEffect = (callback, deps) => {
  const index = currentIndex;
  const prevHook = hooks[index];
  const prevDeps = prevHook?.deps;

  let shouldRun;
  if (prevDeps === undefined) {
    shouldRun = true;
  } else {
    shouldRun = deps.some((dep, i) => dep !== prevDeps[i]);
  }

  if (shouldRun) {
    // 前回のクリーンアップ関数があれば実行
    if (prevHook?.cleanup) {
      prevHook.cleanup();
    }

    // 新しい副作用を実行し、クリーンアップ関数を保存
    const cleanup = callback();
    hooks[index] = { deps, cleanup };
  }

  currentIndex++;
};

クリーンアップ関数は、副作用が「次に実行される前」または「コンポーネントが消える時」に呼ばれます。これにより、リソースの適切な解放が保証されます。


コラム:useMemoとuseCallbackも同じ仕組み

useEffectの仕組みを理解したところで、useMemoとuseCallbackについても触れておきましょう。実は、これらも全く同じ仕組みで動いています。

useMemoの役割

useMemoは、計算結果をキャッシュするHooksです。

const expensiveValue = useMemo(() => {
  return heavyCalculation(a, b);
}, [a, b]);

依存配列[a, b]が変わらなければ、前回の計算結果を再利用します。

useCallbackの役割

useCallbackは、関数をキャッシュするHooksです。

const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

依存配列[count]が変わらなければ、前回と同じ関数オブジェクトを返します。

内部実装のイメージ

useEffectとの違いは、「何を保存するか」だけです。

// useEffectのイメージ
hooks[index] = deps;  // 依存配列だけ保存

// useMemoのイメージ
hooks[index] = { value: 計算結果, deps: deps };  // 計算結果と依存配列を保存

// useCallbackのイメージ
hooks[index] = { callback: 関数, deps: deps };  // 関数と依存配列を保存

依存配列の比較ロジックは、useEffectと全く同じです。前回と今回の依存配列を一つずつ比較し、一つでも違えば再計算(または関数の再生成)を行います。

つまり、useMemoとuseCallbackも:

  1. 配列とインデックスで管理される
  2. 依存配列の比較はuseEffectと同じロジック
  3. 呼び出し順序が変わってはいけない(Hooksのルール)

という点で、useEffectと同じ基盤の上に成り立っています。


第5章:カスタムHookとは何か

ここまでで、useStateとuseEffectの仕組みを理解しました。最後に、カスタムHookについて説明します。

5-1. カスタムHookの正体

カスタムHookとは、Hooksを内部で使用する通常の関数です。特別な仕組みがあるわけではありません。

// これがカスタムHook
const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
};

useCounterは、内部でuseStateを呼び出している普通の関数です。useで始まる名前をつけるのは慣習であり、必須ではありません(ただし、ESLintのルールでこの命名が推奨されています)。

使い方は次のようになります。

const App = () => {
  const { count, increment, decrement, reset } = useCounter(10);

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
};

5-2. なぜカスタムHookが有用なのか

カスタムHookの主な利点は、ロジックの再利用です。

例えば、APIからデータを取得するロジックを考えてみましょう。

// APIからデータを取得する処理(複数のコンポーネントで使いたい)
const App = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch('/api/users');
        const json = await response.json();
        setData(json);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  // ...
};

このロジックを複数のコンポーネントで使いたい場合、同じコードをコピーすることになります。カスタムHookを使えば、このロジックを一箇所にまとめられます。

// カスタムHookとして抽出
const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;  // 競合状態を防ぐフラグ

    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);
        const json = await response.json();
        if (!cancelled) {  // キャンセルされていなければ更新
          setData(json);
        }
      } catch (e) {
        if (!cancelled) {
          setError(e);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    };

    fetchData();

    // クリーンアップ:URLが変わったら古いリクエストの結果を無視
    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
};

// 使用する側
const UserList = () => {
  const { data, loading, error } = useFetch('/api/users');

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラーが発生しました</p>;

  return (
    <ul>
      {data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
};

const ProductList = () => {
  const { data, loading, error } = useFetch('/api/products');

  // 同じパターンで使える
};

カスタムHookによって、複雑なロジックを隠蔽し、コンポーネントをシンプルに保つことができます。


まとめ

Hooksの本質

この記事では、Hooksの仕組みを実装しながら学んできました。最終的に分かったことは、Hooksは「魔法」ではなく、JavaScriptの基本的な仕組みの上に成り立っているということです。

  1. 状態は配列で管理される:hooks = []という配列に、各Hookのデータが順番に格納されます。

  2. インデックスで対応づけられる:useStateやuseEffectは、呼び出された順番(インデックス)で自分のデータを識別します。

  3. クロージャで参照を保持する:setStateは、クロージャによって自分のインデックスを「記憶」しています。だから、後から呼び出しても正しいデータを更新できます。

理解すべき3つのポイント

1. レキシカルスコープとスコープチェーン

変数のスコープは、コードを書いた位置によって決まります。変数を探すときは、自分のスコープから外側に向かって順番に探します。

2. クロージャ

関数は、自分が定義されたスコープへの参照を持ち続けます。これにより、外側の関数の実行が終わった後でも、その変数にアクセスできます。

3. Hooksの配列とインデックス

Hooksは、配列と呼び出し順序(インデックス)によってデータを管理しています。だから、Hooksの呼び出し順序は常に同じでなければなりません。条件分岐やループの中でHooksを呼ぶと、インデックスがずれてバグの原因になります。

最後に

Hooksを「こう書けば動く」という知識から、「なぜ動くのか」という理解に深めることで、より自信を持ってコードを書けるようになります。また、予期しない挙動に遭遇したときにも、原因を推測しやすくなります。

この記事が、Reactへの理解を深める一助となれば幸いです。


参考文献

React公式ドキュメント

JavaScriptの基礎(スコープ・クロージャ)

Hooksの内部実装・発展的な記事

Discussion