1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】state更新で起きる「予期しない動作」を防ぐ:機能更新(Functional Update)を理解する

Posted at

はじめに

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値を受け取ります。この引数名は prevcurrentprevState など、わかりやすい名前であれば何でも構いません。

どんな時に使うべきか

機能更新は特に以下のようなケースで有効です。

  • 現在の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を基に更新する」という安全性を提供します。最初は書き方に慣れないかもしれませんが、バグを未然に防ぐ強力なパターンなので、ぜひ実践で活用してみてください。

1
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?