はじめに
Reactでアプリケーションを開発していると、「ボタンを連続でクリックしたのに1回分しか反映されない」「setTimeoutの中でstateを更新したら古い値が使われた」といった経験はありませんか。
こうした問題の多くは、stateの更新方法を見直すことで解決できます。この記事では、Reactの「機能更新(Functional Update)」というパターンを使って、安全にstateを更新する方法を学んでいきましょう。
この記事で学べること
- 機能更新とは何か、基本的な書き方
- なぜ古いstateの値が使われてしまうのか
- 具体的な問題例とその解決方法
- 実務で使えるベストプラクティス
機能更新(Functional Update)とは
機能更新とは、Reactのstate更新関数に「新しい値」ではなく「現在のstateを引数に取る関数」を渡す方法です。
基本的な書き方の違い
Reactでstateを更新する方法には2種類あります。
直接値を渡す方法
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1); // 現在のcountの値を直接参照
};
関数を渡す方法(機能更新)
const [count, setCount] = useState(0);
const increment = () => {
setCount(prev => prev + 1); // prevは常に最新のcountを指す
};
機能更新では、更新関数に渡すコールバック関数の引数(上記の prev)が、常に最新のstate値を受け取ります。この引数名は prev、current、prevState など、わかりやすい名前であれば何でも構いません。
どんな時に使うべきか
機能更新は特に以下のようなケースで有効です。
- 現在のstateに依存して新しいstateを作る時
- 非同期処理内でstateを更新する時
- 複数のstate更新が連続して実行される可能性がある時
次のセクションで、なぜこのパターンが必要なのか、その理由を見ていきましょう。
なぜ機能更新が必要なのか
「古いクロージャ」の問題とは
JavaScriptのクロージャという仕組みにより、関数が定義された時点での変数の値が「保存」されます。Reactのコンポーネント内でも同じことが起こるため、state更新時に意図しない古い値が使われてしまうことがあるのです。
具体例を見てみましょう。
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1); // この時点のcountの値を使う
};
return <button onClick={increment}>Count: {count}</button>;
}
この increment 関数は、定義された時点の count の値を参照しています。つまり、コンポーネントが再レンダリングされるまで、古い count の値を持ち続けてしまいます。
クロージャの仕組みを図解で理解する
上の図は正常な動作です。しかし、連続してクリックした場合や非同期処理が絡むと、問題が発生します。
問題が起きる具体例
カウンターの例
function Counter() {
const [count, setCount] = useState(0);
const incrementTwice = () => {
setCount(count + 1);
setCount(count + 1);
// 期待: count = 2
// 実際: count = 1(両方とも count = 0 を参照している)
};
return <button onClick={incrementTwice}>Count: {count}</button>;
}
配列への追加の例
function EmojiList() {
const [emojis, setEmojis] = useState(['😀']);
const addMultipleEmojis = () => {
setEmojis([...emojis, '😎']);
setEmojis([...emojis, '🎉']);
// 期待: ['😀', '😎', '🎉']
// 実際: ['😀', '🎉'](両方とも古いemojisを参照)
};
return (
<div>
{emojis.map((emoji, i) => <span key={i}>{emoji}</span>)}
<button onClick={addMultipleEmojis}>追加</button>
</div>
);
}
これらの問題は、機能更新を使うことで解決できます。
実践:よくあるバグと解決方法
ここでは、実務でよく遭遇する3つのケースと、その解決方法を見ていきます。
ケース1:連続してボタンをクリックした場合
問題のあるコード
const addEmoji = () => {
// 直接 emojis を参照すると、古い値を使ってしまう可能性がある
setEmojis([...emojis, '✨']);
};
連続してボタンをクリックすると、すべてのクリックが同じ emojis の値を参照してしまい、最後の1回分しか反映されません。
推奨される書き方(機能更新を使う)
const addEmoji = () => {
// prev は常に最新の emojis(直前の状態)を指す
setEmojis(prev => [...prev, '✨']);
};
機能更新を使うことで、各更新が直前の最新状態を基に実行されるため、すべてのクリックが正しく反映されます。
ケース2:setTimeout/setInterval内でのstate更新
非同期処理の中でstateを更新する場合、クロージャの問題が顕著に現れます。
問題のあるコード
const delayedAdd = () => {
setTimeout(() => {
// 誤: 古い emojis を使ってしまう
setEmojis([...emojis, '🔥']);
}, 1000);
};
この場合、setTimeout が定義された時点の emojis の値が使われるため、その間に他の更新があっても反映されません。
推奨される書き方
const delayedAdd = () => {
setTimeout(() => {
// 正: 常に最新の状態を使う
setEmojis(prev => [...prev, '🔥']);
}, 1000);
};
ケース3:イベントハンドラでの配列操作
配列に要素を追加する場合も、機能更新を使うことで安全に更新できます。
完全な実装例
function EmojiManager() {
const [emojis, setEmojis] = useState(['😀']);
// 即座に追加
const addEmoji = () => {
setEmojis(prev => [...prev, '✨']);
};
// 1秒後に追加
const delayedAdd = () => {
setTimeout(() => {
setEmojis(prev => [...prev, '🔥']);
}, 1000);
};
// 複数同時に追加
const addMultiple = () => {
setEmojis(prev => [...prev, '🎉']);
setEmojis(prev => [...prev, '🎊']);
};
return (
<div>
<div>
{emojis.map((emoji, i) => <span key={i}>{emoji}</span>)}
</div>
<button onClick={addEmoji}>追加</button>
<button onClick={delayedAdd}>1秒後に追加</button>
<button onClick={addMultiple}>2つ追加</button>
</div>
);
}
この実装であれば、どのボタンを押しても常に最新の emojis に対して更新が行われます。
より実践的な使い方
useCallbackとの組み合わせ
機能更新を使うと、useCallback で関数をメモ化する際に依存配列を減らせるメリットもあります。
機能更新を使わない場合
const addEmoji = useCallback(() => {
setEmojis([...emojis, '✨']);
}, [emojis]); // emojisが変わるたびに関数が再生成される
機能更新を使う場合
const addEmoji = useCallback(() => {
setEmojis(prev => [...prev, '✨']);
}, []); // 依存配列が空でOK、関数は一度だけ生成される
これにより、パフォーマンスの最適化にも繋がります。
オブジェクトのstate更新での注意点
オブジェクトを更新する場合も、機能更新のパターンが有効です。
const [user, setUser] = useState({ name: 'Taro', age: 25 });
// 推奨: 機能更新で現在の値を基に更新
const updateAge = () => {
setUser(prev => ({
...prev,
age: prev.age + 1
}));
};
スプレッド構文 ...prev を使うことで、他のプロパティを保持しつつ、特定のプロパティだけを更新できます。
まとめ
機能更新を使うべきタイミングのチェックリスト
以下の条件に1つでも当てはまる場合は、機能更新を使うことを検討しましょう。
- 現在のstateの値を使って新しいstateを計算する
- 配列やオブジェクトに要素を追加・更新する
- setTimeout、setInterval など非同期処理内でstateを更新する
- イベントハンドラで連続した更新が発生する可能性がある
- useCallbackの依存配列を減らしたい
基本ルール
// ❌ 避けるべき書き方
setState(currentState + 1);
// ✅ 推奨される書き方
setState(prev => prev + 1);
機能更新は、「常に最新のstateを基に更新する」という安全性を提供します。最初は書き方に慣れないかもしれませんが、バグを未然に防ぐ強力なパターンなので、ぜひ実践で活用してみてください。