⚛️ React Boundary
基礎 — Hooks

🔋 stateとは何か?

なぜstateが変わると再レンダリングが起きるのか。 ローカル変数では代替できない理由、useStateの保存場所、batchingまで深掘りします。

🤔 問題提起:なぜ普通の変数じゃダメなのか?

Reactを始めた人が最初に疑問に思うことの1つ——「ただの変数を使えばいいんじゃないか?」。 実際に試してみましょう。

// ❌ これは動かない
function Counter() {
  let count = 0; // ローカル変数

  function handleClick() {
    count++; // 変数を変えても...
    console.log(count); // → 値は増えている!
  }

  // でもUIには反映されない
  return (
    <div>
      <p>{count}</p> {/* ← 常に0のまま */}
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

countの値は増えているのに、画面は更新されません。 なぜでしょうか?この謎を解くには、Reactのレンダリングの仕組みを理解する必要があります。

🎯 結論から言う

stateとは「再レンダリングをトリガーする、Reactが管理する変数」です。

stateの変更はReactに通知され、Reactがコンポーネントを再実行することでUIが更新されます。

ローカル変数
  • 関数が実行されるたびに初期化される
  • 変更してもReactに通知されない
  • 再レンダリングが起きない
  • 関数呼び出し間で値が保持されない
state(useState)
  • レンダリング間で値が保持される
  • 変更するとReactに通知される
  • 再レンダリングがトリガーされる
  • Reactが外部に値を保管している

🗺️ 構造図:stateの保存場所

Reactはstateをコンポーネント関数の「外」に保存しています。 具体的には、各コンポーネントに対応するFiberオブジェクトの中です。

stateの保存場所(概念図)
Reactランタイム(外部ストレージ)
Counter コンポーネントのFiber:
memoizedState: { queue: ..., memoizedState: 3 }
↑ useState(0) の現在値は 3
コンポーネント関数が呼ばれると…
コンポーネント関数(毎回新しく実行)
function Counter() {
const [count, setCount] = useState(0);
// ↑ ReactはFiberから「3」を取り出して返す
// 引数の「0」は初回のみ使われる
}

📚 概念説明:Reactの内部でstateはどう保存されているか

Hookのリンクリスト

コンポーネントが複数のuseStateを持つとき、 Reactはそれらを連結リスト(リンクリスト)として管理します。

function UserProfile() {
  const [name, setName] = useState('');     // Hook #1
  const [age, setAge] = useState(0);        // Hook #2
  const [email, setEmail] = useState('');   // Hook #3
  // ...
}

// Reactの内部(概念的な表現):
// Fiber.memoizedState = {
//   memoizedState: '',     ← name の値
//   next: {
//     memoizedState: 0,    ← age の値
//     next: {
//       memoizedState: '', ← email の値
//       next: null
//     }
//   }
// }

なぜHooksを条件分岐の中で使えないのか?

ReactはHookを呼ばれた順番で管理しています。 条件分岐によってHookの呼び出し順が変わると、リストの「何番目のHookか」がずれて、 誤ったstateが返されます。これが「Hooksは条件分岐の中で使えない」という制約の理由です。

レンダリングのサイクル

コンポーネント関数は「レンダリングのたびに最初から実行される」ことを理解するのが重要です。

// 1回目のレンダリング(マウント)
function Counter() {
  // useState(0) → Reactが新しいstateを作成して「0」を保存
  // count = 0, setCount = dispatch関数
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(1)}>Click</button>;
}

// ボタンクリック後、2回目のレンダリング
function Counter() {
  // useState(0) → Reactが既存のstateから「1」を取り出す
  // 引数の「0」は無視される!
  // count = 1, setCount = dispatch関数(同じ参照)
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(1)}>Click</button>;
}

スナップショットとしてのstate

各レンダリングにおいて、stateはスナップショットです。 そのレンダリングの間、stateの値は変わりません。 setCountを呼んでも、 現在実行中のレンダリングのcountは変わらず、 次のレンダリングで新しい値が使われます。

💻 コード例:dispatch/queueの仕組み

setStateは即座にstateを変えない

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

  function handleClick() {
    setCount(count + 1);
    console.log(count); // → まだ0! stateは変わっていない
    
    setCount(count + 1);
    console.log(count); // → まだ0!
    
    // このclickハンドラが終わったあと、
    // Reactは1回だけ再レンダリングを実行する
    // でも count+1 を2回呼んでも、countは「0」なので
    // 結果は 0+1=1 が2回 → 最終的に count = 1 になる
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

関数型の更新で解決する

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

  function handleClick() {
    // 関数を渡すと、Reactが「最新のstate」を引数として渡してくれる
    setCount(prev => prev + 1); // prev = 0 → 1
    setCount(prev => prev + 1); // prev = 1 → 2
    setCount(prev => prev + 1); // prev = 2 → 3
    // 3回インクリメントされる!
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

// 内部ではキューに更新関数が積まれる
// update queue: [prev => prev+1, prev => prev+1, prev => prev+1]
// Reactが順番に処理: 0 → 1 → 2 → 3
updateキューの仕組み(概念)
// Reactの内部(簡略版)
interface Update<S> {
  action: S | ((state: S) => S); // 値 or 関数
  next: Update<S> | null;
}

// setCount(value) が呼ばれたとき
function dispatchSetState(fiber, queue, action) {
  const update = { action, next: null };
  
  // キューに追加
  const pending = queue.pending;
  if (pending === null) {
    update.next = update; // 環状リンクリスト
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;
  
  // Reactに再レンダリングをスケジュール
  scheduleUpdateOnFiber(fiber);
}

🔧 仕組み分解:Batching(バッチング)

Reactは複数のsetState呼び出しを 1つの再レンダリングにまとめます。これをバッチングと呼びます。

React 18以前のバッチング

// React 17以前: イベントハンドラ内のみバッチング
function handleClick() {
  setCount(c => c + 1); // バッチ
  setFlag(f => !f);     // バッチ
  // ↑ 2つまとめて1回の再レンダリング ✓
}

// イベントハンドラ外はバッチングされなかった(React 17以前)
setTimeout(() => {
  setCount(c => c + 1); // 1回再レンダリング
  setFlag(f => !f);     // また1回再レンダリング
  // 計2回のレンダリングが発生!
}, 0);

React 18のAutomatic Batching

// React 18: どこでもバッチング(Automatic Batching)
setTimeout(() => {
  setCount(c => c + 1); // バッチ
  setFlag(f => !f);     // バッチ
  // ↑ 1回の再レンダリングにまとめられる ✓
}, 0);

fetch('/api/data').then(() => {
  setData(result);      // バッチ
  setLoading(false);    // バッチ
  // ↑ これも1回の再レンダリング ✓
});

// バッチングを無効にしたい場合(稀なケース)
import { flushSync } from 'react-dom';

flushSync(() => {
  setCount(c => c + 1); // 即座に再レンダリング
});
flushSync(() => {
  setFlag(f => !f); // また即座に再レンダリング
});

バッチングはなぜ重要か?

再レンダリングはコストがかかります。複数のstateを更新するとき、 それぞれで再レンダリングが発生すると、中間状態のUIが一瞬表示されることもあります。 バッチングにより、すべての更新を1回の再レンダリングにまとめることで、 パフォーマンスと一貫性が向上します。

stateの更新が非同期な理由

// よくある誤解:setStateが非同期
function handleClick() {
  setCount(5);
  // この行でcountはまだ5ではない!
  // countは現在のレンダリングのスナップショット(例:0)
  console.log(count); // → 0
  
  // 「最新のstate」を使いたいなら関数型更新
  setCount(prev => {
    console.log(prev); // → 5(前の更新が反映されている)
    return prev + 1;
  });
}

// setStateが「非同期」に見える理由:
// 1. setStateはupdateをキューに入れるだけ
// 2. 実際のstate変更と再レンダリングは
//    現在のイベントハンドラが終わった後に実行される
// 3. Promise のような本当の非同期ではなく、
//    「同期処理が終わってから」という意味

🌊 stateの「スナップショット」モデル

Reactの公式ドキュメントでは、stateを「スナップショット」と表現しています。 この概念を理解することで、多くのハマりポイントを回避できます。

// スナップショットを実感する例
function AlertTimer() {
  const [message, setMessage] = useState('hello');

  function handleClick() {
    // ここでのmessageは「このレンダリング時点」の値
    const snapshot = message; // "hello"
    
    setTimeout(() => {
      // 3秒後にmessageを参照しても...
      alert(snapshot); // → "hello"(変更されていても!)
      
      // setMessageが呼ばれてmessage="world"になっていても、
      // このコールバックが閉じ込めているのは
      // クリック時点のスナップショット
    }, 3000);
    
    setMessage('world');
  }

  return (
    <div>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleClick}>3秒後にalert</button>
    </div>
  );
}
// 解決策:最新のstateが欲しいならuseRef + useStateを組み合わせる

📌 まとめ

  • ✓ ローカル変数はレンダリング間で値が保持されず、変更してもReactに通知されない
  • ✓ stateはFiberオブジェクトの外部ストレージに保存され、レンダリング間で保持される
  • ✓ useStateのstateはリンクリストで管理されるため、Hookの呼び出し順を変えてはいけない
  • ✓ setStateは即座にstateを変えず、updateキューに追加してReactに再レンダリングを依頼する
  • ✓ 関数型更新(prev => prev + 1)で最新のstateを基に更新できる
  • ✓ React 18のAutomatic Batchingで、どこでも複数のsetStateが1回の再レンダリングにまとめられる
  • ✓ stateは各レンダリングのスナップショット——クロージャが閉じ込めるのはその時点の値

関連記事