こんにちは!Reactの学習、楽しく進められていますか?
画面を自由に動かせるようになってくると、「よし、アプリっぽくなってきたぞ!」とワクワクする反面、なんだかよく分からない不思議なバグにぶつかって頭を抱えてしまうこと、ありますよね。
特に、「ボタンを連打しているのに、画面の数字が1つズレていく…」とか、「タイマーや非同期処理の中で、なぜか古い状態(state)がそのまま使われている…」といった現象。
これ、Reactの初学者の誰もが一度は通る、いわば「登竜門」のようなものなんです。あなたが今つまずいていたとしても、それはコードの書き方が悪いわけでも、才能がないわけでもありません。単に、JavaScriptとReactの「ちょっとした仕組み」を知らないだけなんです。
今回は、この厄介な問題の正体である「ステイルクロージャ(Stale Closure:古びた密室)」について、身近な例えを交えながら、優しく、そして確実に解決していきましょう!大丈夫、一緒に紐解いていけば絶対に怖くありませんよ。
—
1. ステイルクロージャってなに?(お買い物かごの例え)
まずは、この「ステイルクロージャ」というちょっと難しそうな名前の正体を、身近なもので例えてみましょう。
想像してみてください。あなたは今、スーパーでお買い物中。
目の前には「お買い物かご」があって、中には今入れた商品の数(State)が入っています。
あなたはスマホのメモアプリ(これがクロージャの世界です)に、「いま、かごの中に商品は〇個入っているな」とメモを書きました。
さて、しばらくお買い物を続けて、別の人(非同期のタイマーやAPI通信など)が勝手にかごの中にリンゴを3個追加してくれたとします。
でも、あなたが手に持っているスマホのメモ(古い記憶)は、最初に書いた「〇個」のまま更新されていませんよね?
Reactの世界でもこれと全く同じことが起きます。
関数(コンポーネント)が作られた瞬間に、その中のコードや非同期処理は「当時のStateの価値観」をそのままギュッと閉じ込めてしまいます。これが「ステイル(古い)クロージャ(閉じ込められた空間)」と呼ばれる状態です。
実務の現場でも、「非同期処理が完了したときに、なぜか古いユーザー名が表示されてエラーになる…」なんてバグは、このステイルクロージャが原因であることが本当に多いんです。
—
2. 実際にやってしまいがちな「悲しいコード」
言葉だけだとイメージしにくいと思うので、よくある「カウントアップがズレちゃう例」を見てみましょう。
「ボタンを素早く2回押したのに、1しか増えない!」というバグが発生するコードです。
import React, { useState } from ‘react’;
export default function CounterBug() {
const [count, setCount] = useState(0);
// 3秒後に「クリックされた時点のカウント」を表示させたいとします
const handleDelayedClick = () => {
setTimeout(() => {
// ここで当時の count を参照しようとするのですが……
alert(`3秒前のカウントは ${count} でした!`);
}, 3000);
};
return (
現在のカウント: {count}
{/ 普通にカウントを増やすボタン /}
{/ 3秒後にアラートを出すボタン /}
);
}
何が起きているの?
1. 初め、`count` は `0` です。
2. 「3秒後にカウントを教えるね」ボタンを押します。この瞬間、タイマーがセットされ、関数の中には `count = 0` という記憶が閉じ込められます(これがステイルクロージャ!)。
3. タイマーがカウントダウンしている間に、気が早いあなたは「+1するよ」ボタンを3回連打しました。画面上の `count` は `3` になります。
4. 3秒が経ち、先ほどセットしたアラートが発動します。
画面の表示は「3」になっているのに、アラートが出る瞬間、画面がこう告げます。
「3秒前のカウントは 0 でした!」
……あれ?いま3なのに、どうして?
これが、まさにステイルクロージャの罠です。タイマーの中の関数が、過去の古い `count`(0)を握りしめたままタイムカプセルから出てきてしまったんですね。
—
3. 解決策その1:王道!「関数型更新」を使う
この問題を一番スマートに解決する方法、それが「関数型更新(Updater Function)」です。
難しく考えなくて大丈夫です。要するに、`setCount(count + 1)` のように「今の数字を直接教える」のではなく、「Reactさん、直前の最新の数に1を足した新しい数を計算してよ!」と丸投げ(コールバック関数を渡す)しちゃえばいいんです。
先ほどのコードの、カウントを増やす部分をこう書き換えてみましょう。
// ❌ 直前の値を直接使っちゃう(危ない!)
setCount(count + 1);
// ⭕️ Reactに最新の値を計算してもらう(関数型更新)
setCount((prevCount) => prevCount + 1);
この `prevCount`(名前は何でもいいですが、previous=前の値、という意味です)を使うと、Reactは「今現在の本当の最新の値」を必ず引数に渡してくれます。これなら、クロージャの中に古い記憶が閉じ込められていても、React側が「いやいや、今の最新はこれだよ!」と正しい値で計算し直してくれるのです。
—
4. 解決策その2:useEffectの依存配列とuseRefでガッチリ守る
もう一つのパターンとして、`useEffect` の中でタイマーやイベントリスナーを仕込むときにも、このステイルクロージャは牙をむきます。
「画面が表示された初回に一度だけタイマーを仕込みたいんだけど、その中で最新の `count` を使いたい……!」
そんなときは、`useRef` という「値の保管庫(メモ帳)」を使うのがプロの現場の定番テクニックです。
import React, { useState, useEffect, useRef } from ‘react’;
export default function CounterFixed() {
const [count, setCount] = useState(0);
// 1. 最新の count をいつでも取り出せるように、Ref(保管庫)を用意します
const countRef = useRef(count);
// 2. count が変わるたびに、Refの中身を最新に書き換えます
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
// 初回マウント時にタイマーを仕込みます
const timer = setInterval(() => {
// 3. ここでは、クロージャ内の古い count ではなく、Refから「今の最新値」を直接取りにいきます!
console.log(`現在の最新カウント(Ref経由): ${countRef.current}`);
}, 1000);
// お片付け(クリーンアップ関数)
return () => clearInterval(timer);
}, []); // 依存配列が空なので、このタイマーは最初に1回だけ作られます
return (
現在のカウント: {count}
);
}
なぜ `useRef` で解決するの?
`useRef` のすごいところは、「中身を書き換えても、コンポーネントが再レンダリングされない(画面がチラつかない)」、そして何より「いつどこからでも最新の値を `.current` で取りに行ける」という性質を持っていることです。
タイムカプセルの外から無線通信で「今の現場の状況はどうなってる!?」と確認できるようなイメージですね。これならステイルクロージャの密室の中にいても、常に最新のデータをキャッチすることができます。
—
まとめ:もうステイルクロージャは怖くない!
お疲れ様でした!ここまで読んでいただきありがとうございます。
今回のポイントをギュッと凝縮して振り返ってみましょう。
1. ステイルクロージャとは?
- 関数や非同期処理の中に「古いStateの記憶」が閉じ込められてしまう現象。
2. 解決策①(状態の更新時):関数型更新を使う
- `setCount(count + 1)` ではなく `setCount(prev => prev + 1)` を使って、Reactに最新値を計算してもらう。
3. 解決策②(useEffectやタイマーの中):useRefを使う
- 常に最新の値を保持する「専用の保管庫(`useRef`)」を作り、必要なときに `.current` で取りに行く。
Reactの仕組みを少しだけ覗いてみると、「なぜこんな動きをするんだろう?」という疑問が「なるほど、だからこういう書き方をするのか!」という確信に変わっていきます。
もしまたコードを書いていて「あれ、値がおかしいぞ?」と思ったら、この記事の「お買い物かご」や「タイムカプセル」の例えを思い出してみてくださいね。
あなたのReact開発が、少しでも楽しく、スムーズになりますように。
現場のチーフアーキテクトより、心を込めて応援しています!明日からも一緒にコードを楽しんでいきましょう!

コメント