🤖

【React】そろそろ技術ブログで setCount(count + 1) と書くのはやめませんか

5 min read 12

用意されている適切な API を使用しましょう

結論

こうではなく

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

const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);

これが正しい書き方です。

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

const increment = () => setCount((prevCount) => prevCount + 1);
const decrement = () => setCount((prevCount) => prevCount - 1);

setState の引数は 2 種類ある

  • 次のステートを直接引数で受け取るインターフェイス
setState(newState);
  • 直前のステートから新しいステートを計算する関数を引数で受け取るインターフェイス
setState((prev) => createNewStateFromPrevState(prev));

useState の紹介記事の題材でよくサンプルとして提示されるカウンターアプリの increment は「直前のカウントに 1 を足す関数」を意味しているはずです。
ですので、2 つ目の関数インターフェイスに当てはめて setCount((prev) => prev + 1) と書くのが適切でしょう。 decrement も同様です。

問題が発生するシチュエーション

「でも 1 つ目の書き方でも正しく動くじゃん」

それはそのサンプルがたまたま正しく動いているだけで、気づきにくいバグの可能性を含んでいます。

例えば、アプリのどこからでも使っていいユーティリティ関数として useCounter をモジュール化したとします:[1]

useCounter.ts
export const useCounter = (init: number = 0) => {
  const [count, setCount] = useState(init);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);

  return { count, increment, decrement };
};

incrementdecrement が引数を受け取らないため差分 1 ずつしか操作できないことに気づいた useCounter の使用者が、応用して 2 ずつ変化させて使おうと考えます:

CounterDouble.tsx
import { useCounter } from "./useCounter";

export default function App() {
  const { count, increment, decrement } = useCounter(10);

  const incrementDouble = () => {
    increment();
    increment();
  };

  const decrementDouble = () => {
    decrement();
    decrement();
  };

  return (
    <div className="App">
      <h1>Count: {count}</h1>
      <button onClick={incrementDouble}>2 増やす</button>
      <button onClick={decrementDouble}>2 減らす</button>
    </div>
  );
}

CodeSandbox で動作確認してみましょう。

差分 2 ずつ変化させるはずが 1 ずつしか変化しません。
ボタンが 1 度クリックされたら increment (or decrement) が 2 回ずつ呼ばれているので 2 変化するはずです。なぜでしょうか?

経験豊富な人や勘のいい人は既にお気づきでしょう。 setCount に同じ値を繰り返し渡しているだけだからです。

count はあくまで定数であって変化する値ではない

下記の App コンポーネントを例示します。

export default function App() {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);

  return (
    <div className="App">
      <h1>Count: {count}</h1>
      <button onClick={increment}>増やす</button>
      <button onClick={decrement}>減らす</button>
    </div>
  );
}

count === 20 の場合、コンポーネントの中身を書き下してみると下記のようになっています。

export default function App() {
const [20, setCount] = useState(0);
const increment = () => setCount(20 + 1);
const decrement = () => setCount(20 - 1);

  return (
    <div className="App">
      <h1>Count: {20}</h1>
      <button onClick={increment}>増やす</button>
      <button onClick={decrement}>減らす</button>
    </div>
  );
}

increment (1 増加) と言いつつ、その実 setCount21 を渡しているだけなのがわかります。
これを踏まえると、先ほど出てきた increment を 2 回繰り返した incrementDouble の中身は count === 20 のとき下記のようになっていることがわかります。

const incrementDouble = () => {
  setCount(20 + 1);
  setCount(20 + 1);
};

incrementDouble という関数名であるにもかかわらず、ステートを 21 に更新する処理を 2 回繰り返しているだけです。これでは想定している動作が実現できるわけがありませんね。

setState に渡される関数はステート変換の 指示 である

それでは useCounter を正しく修正しましょう。

useCounter.ts
export const useCounter = (init: number = 0) => {
  const [count, setCount] = useState(init);

  const increment = () => setCount((prevValue) => prevValue + 1);
  const decrement = () => setCount((prevValue) => prevValue - 1);

  return { count, increment, decrement };
};

useCounter を使用している App コンポーネント側は全く修正していないのでコードは省略します。

CodeSandbox で動作を確認してみます。

ボタンをクリックすると、正しく差分 2 ずつカウントが変化しているのがわかります。

関数 increment はもはや count に依存していません。その代わり、 setCount に対して「現在のステートを渡してくれたらそれに 1 加算して返却するからそれを新しいステートとせよ」という指示を出しています。ここで言う「現在のステート」というのは count のことではなく、 React のバックグラウンドでステートを計算している途中の値のことです。

count === 20 のときに ボタンをクリックして incrementDouble を実行した場合の内部動作を見てみましょう。

まずは直前のステートの値を 受け取ります:

let newState = 20;

1 つ目の increment が実行されます。直前のステートの値である 20 に対して指示を実行します:

newState = 20 + 1; // 21

ここで計算された newState はまだ画面には反映されません。同時に渡された指示をすべて処理し終えるまでループします。

2 つ目の increment が実行されます。直前のステートの値である 21 に対して指示を実行します:

newState = 21 + 1; // 22

すべての指示を処理したので、ステートが確定して画面に反映されます。
これなら本当に意図していること(1 を加算する処理を 2 回行う)が実行されているのがわかりますね。

ちなみにここで説明している動作の本物のソースコード該当箇所は下記リンクです。ぜひ読んでみてください(useStateuseReducer の特殊ケースとして実装されているので useReducer のソースコードとなります)。

https://github.com/facebook/react/blob/master/packages/react-dom/src/server/ReactPartialRendererHooks.js#L288-L305

ここまでカウンターアプリを題材にして説明してきましたが、ステートがどんな型であっても共通して言えることです。
更新後のステートが更新前のステートに依存しているなら、 setState には値ではなく関数を渡してあげましょう。

setState に渡す値を state から作るべきケースはないと思っています(あったらコメントください)。

まとめ

setCount(count + 1) と書くのはやめませんか」という話をしてきました。
代わりに setCount((prevValue) => prevValue + 1) と書くようにしましょう。

初学者向けの技術ブログ記事の場合、簡略化のためわざと書いている可能性もあります。しかし、僕はそれがいいこととはあまり思えません。初学者が今回の記事のように自力で応用してみようとしたときに思い通りに動作せず困惑させることになるからです。
それよりかは多少難易度が上がったり説明文が多くなったとしても、適切な API の紹介をすべきだと思います。

以上、最後までご覧いただきありがとうございました 🎉

脚注
  1. useCallback を使用すべき箇所ですが説明のために省略しています ↩︎

この記事に贈られたバッジ

Discussion

文意はほぼ同意なのですが、別に「正しい書き方」というわけではないのではないでしょうか?「プログラマの意図を正しく反映させた書き方」であれば納得できます。

関数を受け取る書き方はこのようなケースにハマらずに済み、またプログラマの意図した「count に 1 を足す」という動作を正確に書けています。しかし、それをもって「正しい」と言われるとなんだかモヤモヤします。

pandanoirさんのモヤモヤが私にはわかりませんでした。すみません…。

正しい、間違えているという表現はすごく乱暴で、もう片方が間違えている悪いものという印象を生みがちです。たいていのケースではもっとよい表現で置き換えられます。

今回で言えば、正しいという言葉を使わずに「直前の値を使って値を更新する場合の適切な書き方」と書けます。

コードに正しい/間違いなんてないのではないでしょうか

(要するに文章表現の話なので、本筋とはあまり関係ない話題です)

複数の選択肢があってメリット・デメリットが議論できる場合は、自分がより良いと思っているほうを「正しい」と主張するのは表現が悪いと思います。

ただ、この記事で題材にした setCount(count + 1) の書き方に関しては私は間違っているとさえ思っています。採用すべきではないと思っています。
それゆえにもう一方の書き方を正しいと主張しています。

「この人はそう思ってるんだ」程度にとらえていただければ幸いです

全体的に良い内容でした💪

読んでいて気になったのですが、setStateに渡す関数にバグを含んでしまう可能性があると思いますが、これについてどう思っていますか?

私は描画更新を担う関数にバグが発生してしまうと、大きな影響を与えてしまうので、なるべくバグを含みにくいsetCount(count + 1)のような関数を渡さない書き方の方が良いと思っています。

あと、描画更新が増えてしまう問題もあるので、そこもちょっと気になります👀

setState にわたす関数は要するに reducer に該当する関数なので、ステートの変換が複雑になるなら別の純粋関数としてくくりだして単体テストするだけでいいように思われます
setState にわたすのが関数だろうが値を直接だろうが、どこかしらで変換処理はつくらないといけないのでバグ発生の確率も影響範囲も変わらないのではないでしょうか

あと、描画更新が増えてしまう問題もあるので、そこもちょっと気になります👀

すみません、ここに関してはおっしゃっている問題がイメージできていません…。
setState に関数を渡すタイプのステート更新はレンダリング回数は増えるのでしょうか?(見当違いのことを言っていたらすみません)

setState にわたす関数は要するに reducer に該当する関数なので、ステートの変換が複雑になるなら別の純粋関数としてくくりだして単体テストするだけでいいように思われます
setState にわたすのが関数だろうが値を直接だろうが、どこかしらで変換処理はつくらないといけないのでバグ発生の確率も影響範囲も変わらないのではないでしょうか

なるほど。回答ありがとうございます!

setState に関数を渡すタイプのステート更新はレンダリング回数は増えるのでしょうか?(見当違いのことを言っていたらすみません)

これは私の説明不足でした、すいません。
記事の中で、increments()decrement()を連続して2回書いているところがあったので、それによって描画が2回発生してしまうことについての言及でした。

単純にsetCount()useCounter()の返り値に含めて、setCount( count + 2 )にしても良いのではないか?と思った次第です。

でもそうなった場合に、今回の例ですとsetStateに関数を渡す意義が消えてしまうので、うーん?どうなんだろう🤔 ?

記事の中で、increments()・decrement()を連続して2回書いているところがあったので、それによって描画が2回発生してしまうことについての言及でした。

これは実は描画は一度で済みます。useReducer は同期的に複数の action が dispatch された場合、すべての変更を適用し終えた最後のステートだけを画面に反映します。
ですので(あまりにステートの変換処理がヘビーでなければ)何回 setState を呼び出そうと負担はさほど変わりません

これは実は描画は一度で済みます。useReducer は同期的に複数の action が dispatch された場合、すべての変更を適用し終えた最後のステートだけを画面に反映します。
ですので(あまりにステートの変換処理がヘビーでなければ)何回 setState を呼び出そうと負担はさほど変わりません

そうだったんですね!色々と勉強になりました。
というか、よく見たら記事に書いてありましたね。。。

すべての指示を処理したので、ステートが確定して画面に反映されます。
これなら本当に意図していること(1 を加算する処理を 2 回行う)が実行されているのがわかりますね。

ちゃんと読まず、すいません😅

ここからは余談ですが、少し前に、今回の記事の例のようにsetStateを複数回呼ぶ処理を実行した時に、描画処理が複数回実行されてしまう問題に遭遇しました。

しかし、

useReducer は同期的に複数の action が dispatch された場合、すべての変更を適用し終えた最後のステートだけを画面に反映します。

と解説されているように、一回の描画処理で済むはずです。

「 あれ、おかしいぞ🤔 」と思ったので、ちょっとcodesandboxで実験してみたところ、非同期関数が絡むと描画処理が増える可能性があることが分かりました。

※ ここでの描画処理は、コンポーネント関数が実行される事を指しています。

以下は、そのサンプルです。

「 なんでそうなるんだよ😇 」となったので、公式のドキュメントを確認してみると、

現在、setState はイベントハンドラの内側では非同期です。

と書いてありました。

「 イベントハンドラの内側では 」と言うことは、普通の非同期関数内ではバッチ処理を行ってくれないのかもしれません。

なので、もし非同期関数などを扱っている場合は、この挙動の違いに注意する必要があるかもしれませんね。。。

この挙動は知りませんでした…。

適当に触ってみると、 await より前は一度に更新されて、それ以後は setState の度に?描画が走ってるみたいですね…。

onClick={async () => {
  increment();
  increment();

  await sleep();

  increment();
  increment();
}}

// 16 のときにクリックすると
// rendering 18
// rendering 19
// rendering 20

共有していただきありがとうございます🙇‍♂️

ありがとうございます!

「イベントハンドラの内側」というのがよくわかってませんでしたが、この issue 眺めると少しわかった気がします…。
React Hooks が関数コンポーネントの直下でのみ呼び出し可能であるのと似てるかなと思いました(伝わりにくいかもしれない)

ログインするとコメントできます