React 勉強中。 useEffect を学ぶところで useState がわからないところに戻り大混乱。
claude がこれは大丈夫っていうんだよね
function Loop() {
const [count, setCount] = useState(0);
setCount(count); // 同じ値
return <p>{count}</p>;
}
setCount(count)みたいに同じ値を渡せば、Object.isで弾かれて bail out する。だから render 中に呼んでも loop しない。
- bail out = React が「値が変わってないから更新する意味なし」と判断して、re-render を途中でやめること。飛行機から緊急脱出するのが bail out.
やったら無限ループw
Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
同じ値だとレンダリングされないって確かに聞いたことがあるので、基礎から勉強
やってみた。
都市伝説の実態
ツッコんだらclaude曰く「同じ値なら re-render されない」という一言に、3つの別物があるそうな。
| 便宜的な名前 | 同じ値で止まる? | 対象の setState | 何を比較 |
|---|---|---|---|
| MANUAL_STOP | 自分で書けば止まる | class の setState | props / state(自分で比較) |
| OUTSIDE_RENDER | 自動で止まる | render 外で呼んだ setState | Object.is(新, 旧) |
| DURING_RENDER | 止まらない | render 中で呼んだ setState | 比較しない |
renderingが止まらない方が多い。
MANUAL_STOP — class 時代は同じ値でも re-render してた
Hooks(useState たち)が入ったのは React 16.8、2019年2月。それ以前の React は class で component を書いていた。class は廃止されてなくて今も動く。
class 時代の setState は、同じ値を渡しても re-render する。値の比較すらしない。
class Counter extends React.Component {
state = { count: 0 };
handleClick = () => {
this.setState({ count: this.state.count }); // 同じ値
};
render() {
console.log("render"); // 押すたびに出る
return <button onClick={this.handleClick}>{this.state.count}</button>;
}
}
同じ値でも render() は走る。
止めたければ自分で書く。class には shouldComponentUpdate という method があって、re-render の直前に React が呼んでくる。ここで false を返すと render がスキップされる。
shouldComponentUpdate(nextProps, nextState) {
// 次の state と今の state を自分で比較して、
// 変わってないなら false = re-render しない
return nextState.count !== this.state.count;
}
毎回これを書くのは面倒。なので「props と state をひと通り比較する shouldComponentUpdate」が最初から組み込まれた class も用意されていた。それが PureComponent。
class Counter extends React.PureComponent {} // 比較が組み込み済み
つまり class 時代、「同じ値なら re-render しない」は React の標準動作じゃない。開発者が比較を手で書く(か PureComponent を選ぶ)opt-in 機能。だから MANUAL_STOP。
OUTSIDE_RENDER — 自動で止まるのはここだけ
function Counter() {
const [count, setCount] = useState(0);
console.log("render");
return <button onClick={() => setCount(count)}>{count}</button>;
// ^^^^^^^^^^^ 同じ値
}
この onClick は押しても re-render されない。React が更新前に Object.is(新, 旧) を比較して、等しければ更新を捨てる。これが bail out。
ん? じゃあ最初の Loop はなんで loop した? 同じ setCount(count) なのに。
答えは DURING_RENDER。先に traps を2つ。
trap 1: object と配列は中身が同じでも別物
setItems([]); // 毎回新しい空配列
// Object.is(前の[], 新しい[]) === false → 「変わった」扱い → re-render
Object.is は参照比較。中身は見ない。毎回 [] や {} を作って渡すと、bail out を毎回すり抜ける。
trap 2: この bail out、render 外でしか効かない
これが DURING_RENDER。
DURING_RENDER — Claude が間違えた場所
OUTSIDE_RENDER の自動 bail out は、名前の通り render 外専用。render 中に呼んだ setState には効かない。
最初の Loop は setCount(count) を render 中に実行される関数に直書きしていた。 render 中の set は bail out されず loop する。
// render 中 → 効かない → Too many re-renders
function C() {
const [c, setC] = useState(0);
setC(c);
return <p>{c}</p>;
}
// render 外(onClick)→ bail out 効く → re-render なし
return <button onClick={() => setCount(count)}>same</button>
1回のレンダリングには "始まりと終わり" が存在する
React が "C() を呼んでから JSX を return するまで" が render 中。関数本体に直書きした setC(c) はこの最中に実行される。
onClick の無名関数は、render 中には定義されて React に渡されるだけで、実行されない。実行されるのは render が終わったあと、click が起きたとき。だから render 外。useEffect のコールバックや setTimeout、通信の完了も全部 return より後に発火するので render 外。
図にするとこう。
- render 中の setState(render-phase update)は
Object.isの早期 bail out 判定を通らない - 値が同じでも毎回「もう一回 render しろ」の要求が立つ
- 25回で React が無限 loop と判断して止める
- bail out 判定は render 外の通常更新経路にしか入っていない
「同じ値か」じゃなくて「一度のレンダリングの始まりから終わりまでの間にsetStateをしたか」。
render 中の setState は全部ダメなのか
聞いたら「条件で囲めば OK」。React 公式の "storing information from previous renders" パターン。
function CountLabel({ count }) {
const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState(null);
if (prevCount !== count) { // 「変わったときだけ」を自分で書く
setPrevCount(count); // 覚えてる値を更新。次は prevCount === count
setTrend(count > prevCount ? "increasing" : "decreasing");
}
return (
<>
<h1>{count}</h1>
{trend && <p>The count is {trend}</p>}
</>
);
}
やってることは、DURING_RENDER で効かなかった bail out の手書き。つまりこれも MANUAL_STOP の一種。class 時代は shouldComponentUpdate で、render 中は if で、自分で比較して自分で止める。
-
if (prevCount !== count)が「同じなら止める」の代わり -
setPrevCount(count)で次の render の条件がfalseになり、loop が止まる
setPrevCount(count) を忘れると条件がずっと true で loop する。公式も "re-render in a loop until it crashes" と書いてる。最初に見た Too many re-renders と同じ結末。
公式いわく、このパターン自体、避けたほうがいいとのこと。
This pattern can be hard to understand and is usually best avoided.
つまり OK 版も含めて、render 開始〜終了の間の setState は基本やらない。代わりは:
- render 中に計算できるなら state に持たない(
const fullName = firstName + lastNameで済むなら setState 不要) - event handler(つまりrenderの外) で更新する
じゃあどうすればいいの
一番よくあるのは「default 値で表示して、初回に API で書き換える」パターン
const [data, setData] = useState(null); // default 値
useEffect(() => {
fetch("/api/user").then(r => r.json()).then(setData);
}, []); // ← mount 時に1回だけ
loop しない理由をさっきの表に当てはめる。
- effect は render 外で実行される → DURING_RENDER ではない
- ただし API の結果は毎回新しい object。trap 1 の通り
Object.isは毎回 false で、bail out は守ってくれない - 止めているのは
[]。mount 時に1回だけ、という有限回の保証
「useEffect だから安全」ではない。[] を忘れると effect が毎 render 走って "Maximum update depth exceeded"。守っているのは依存配列。
setTimeout は代替にならない。
// BAD: render 中に毎回 timer を予約 → ゆっくり無限 loop
function C() {
const [data, setData] = useState(null);
setTimeout(() => fetch("/api").then(r => r.json()).then(setData), 0);
return <p>{data}</p>;
}
crash はしないが、render のたびに予約 → setState → re-render → また予約、で回り続ける。使うにしても置き場所は useEffect(..., []) の中。
実務では自前で書かずに TanStack Query / SWR、Next.js なら Server Component の fetch。理屈は全部同じ、「fetch を render の外・有限回に閉じ込める」。
useEffect の本質, 大事なこと
「useEffect は loop を防ぐ仕組み」ではなく 「render が終わったあとに実行する」もの。言い換えると「副作用を render の外に出す仕組み。loop を止めているのは依存配列」。
おまけ: React 18 以降 re-render タイミングが複雑に
Claude が挙げた3つ。
automatic batching
function onClick() {
setA(1);
setB(2);
setC(3);
} // render は3回じゃなく1回
呼んだ回数 = render 回数、ではない。18からは setTimeout や Promise の中でもまとめられる。
Strict Mode の二重呼び出し(開発時のみ)
const renderCount = useRef(0);
renderCount.current += 1; // 開発時は1操作で +2 に見えることがある
React がわざと2回呼ぶ。狙いは、DURING_RENDER のような render 中の副作用の炙り出し。本番では起きない。
Concurrent Rendering
render は中断・破棄・やり直しされうる。1操作 = 1 render、も保証なし。
まとめ: この順で見る
「同じ値か?」で即答しない。
1. どのAPI?
class setState → 同じ値でも re-render する(止めたいなら MANUAL_STOP を書く)
Hooks useState / useReducer → 2 へ
2. どこで呼んだ?
render 外(event / effect) → Object.is が同じなら bail out(OUTSIDE_RENDER)
render 中 → bail out 効かない(DURING_RENDER)
無条件なら Too many re-renders
合法にするなら if (prev !== next) { setPrev(next); ... }
3. どの mode?(回数を数えるとき)
Strict Mode(開発) → 2回呼ばれる
automatic batching → 複数 setState が1 render にまとまる
Concurrent → 中断・破棄・やり直しあり
「同じ値なら止まる」が成立するのは、Hooks で・render の外で・参照が本当に同じとき。それだけ。
AIが言ってきたことは試してみないとね。
参考
- React docs — useState(Bailing out of state updates / Storing information from previous renders)
- React docs — Strict Mode
- React 18 — Automatic batching