Reactでstateを書き換えても画面が変わらない理由と直し方

​​React​ESLint,Playwright,StrictMode,TypeScript,useState

React の state の配列に push したり、オブジェクトのプロパティに代入したりしてから同じものを set 関数に渡しても、画面は変わりません。React は新しい値と今の state を比べ、同じものなら再レンダーを省くからです。ToDo リストに項目を追加する、プロフィールの年齢を変えるといった場面で「ボタンを押しても何も起きない」ときは、新しい配列やオブジェクトを作って渡すと直ります。

書き換えは画面にすぐ出ないことがあるので、見つけ方も知っておくと楽です。Strict Mode の開発用ビルドで数が合わなくなる様子と、readonly の型や ESLint でどこまで見つけられるかを、私は検証環境で試しました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、React 19.3.1、TypeScript 6.0.3、esbuild 0.28.2、Playwright 1.63.0、Chromium 153、ESLint 10.15.0、eslint-plugin-react-hooks 7.1.1、typescript-eslint 8.72.2 です。.tsx を tsc で型チェックしてから esbuild でまとめ、Playwright で Chromium に読み込ませてボタンを押し、表示された文字を記録しました。開発用と本番用の両方のビルドで試しています。useReducer、クラスコンポーネント、Immer は試していません。Strict Mode で1回目だけ1匹しか増えなかった理由(React の内部の動き)は調べていません。

同じ配列やオブジェクトを渡すと、変わっていないと判断される

次のコンポーネントでは、「追加」を押すと items に push して、同じ items を setItems に渡します。

tsx
const [items, setItems] = useState<string[]>(['たま']);
const [count, setCount] = useState(0);
// ...
<button
  onClick={() => {
    items.push('みけ');
    setItems(items);
  }}
>
  追加
</button>
<button onClick={() => setCount(count + 1)}>カウント +1</button>

検証環境で「追加」を2回押しても、表示は「たま(1匹)」のままでした。続けて、別の state を変える「カウント +1」を押すと、「たま、みけ、みけ(3匹)」が出ました。オブジェクトのプロパティに代入して同じオブジェクトを渡す cat.age = cat.age + 1; setCat(cat); も、2回押しても、表示は「たまは3歳」のままでした。

React は、set 関数に渡された値と今の state を Object.is で比べ、同じなら再レンダーをスキップします(公式リファレンスにある set 関数の注意点)。配列やオブジェクトは、中身ではなく同じものかどうかで比べられます。push しても配列そのものは前と同じなので、更新は無視されます。ただ、中身は本当に増えているので、ほかの state の更新で再レンダーが起きたときにまとめて出てきます。

新しい配列やオブジェクトを作って渡す

直すには、元の配列やオブジェクトを書き換えずに、新しいものを作って渡します。

tsx
setItems([...items, 'みけ']);
setCat({ ...cat, age: cat.age + 1 });

この書き方では、押すたびに「たま、みけ(2匹)」「たま、みけ、みけ(3匹)」、「たまは4歳」「たまは5歳」と表示が変わりました。state に入れた配列やオブジェクトは読み取り専用として扱う、というのが公式ドキュメントの説明です(オブジェクトの state の更新、配列の state の更新)。配列のページには、sort や splice の代わりに使える書き方の対応表もあります。

更新用関数の中で書き換えると、Strict Mode で数が合わなくなる

書き換えは、更新用関数の中でも起きます。次のように prev に push してからコピーを返すと、新しい配列を返しているので画面は変わります。しかし、prev そのものも書き換えています。

tsx
setItems((prev) => {
  prev.push('みけ');
  return [...prev];
});
Strict Mode の中に置いた、更新用関数の中で prev に push するコンポーネント。追加を1回押すと「みけ」が1匹、2回目と3回目はそれぞれ2匹ずつ増え、最後は「たま」と「みけ」5匹の6匹になる。変わった表示の背景が、その瞬間だけピンクになる
Strict Mode の中(開発用ビルド)で、追加を3回押したときの増え方

これを <StrictMode> で囲んだものと囲まないもので、「追加」を4回押しました。表は、押したあとの「みけ」の数です。

押した回数 Strict Mode なし Strict Mode の中(開発用ビルド) Strict Mode の中(本番用ビルド)
1回目 1 1 1
2回目 2 3 2
3回目 3 5 3
4回目 4 7 4

開発用ビルドで <StrictMode> の中に置いたときだけ、1回目は1匹、2回目からは1回押すごとに2匹ずつ増えました。本番用ビルドでは、<StrictMode> の中でも1匹ずつでした。

Strict Mode では、開発時だけ更新用関数が2回呼び出されます。2回の結果のうち1つは使われませんが、prev への push は2回とも同じ配列に対して行われるので、要素が2つ増えます。公式リファレンスでも、更新用関数の中で state の配列に push する書き方が、2回の呼び出しで間違いに気付ける例として挙げられています。直し方は、prev を書き換えずに新しい配列を返すことです。

tsx
setItems((prev) => [...prev, 'みけ']);
ポイント

開発中に <StrictMode> の中でだけ数が合わないときは、更新用関数の中で prev を書き換えていないかを確かめます。本番用ビルドでは数が合って見えるので、開発中の食い違いが手がかりになります。

readonly の型にすると、tsc が書き換えを止める

useState の型引数を readonly string[] や Readonly<Cat> にすると、書き換えが型エラーになります。

tsx
const [items, setItems] = useState<readonly string[]>(['たま']);
const [cat, setCat] = useState<Readonly<Cat>>({ name: 'たま', age: 3 });

items.push('みけ'); // エラー
cat.age = cat.age + 1; // エラー
setItems((prev) => {
  prev.push('みけ'); // エラー
  return [...prev];
});
setItems([...items, 'みけ']); // 通る
setCat({ ...cat, age: cat.age + 1 }); // 通る

検証用のファイルで tsc を実行すると、書き換えている3か所がエラーになりました(行番号は、検証用のファイルのものです)。

text
ng/readonly.tsx(9,11): error TS2339: Property 'push' does not exist on type 'readonly string[]'.
ng/readonly.tsx(13,9): error TS2540: Cannot assign to 'age' because it is a read-only property.
ng/readonly.tsx(18,12): error TS2339: Property 'push' does not exist on type 'readonly string[]'.

readonly string[] の型には push がなく、Readonly<Cat> のプロパティには代入できないためです。更新用関数が受け取る prev も同じ型になるので、こちらの push もエラーになりました。新しい配列やオブジェクトを作る書き方は、そのまま通りました。

ESLint の immutability ルールが見つけたのは、代入だけだった

eslint-plugin-react-hooks には、state や props の書き換えを見つける immutability ルール があります。検証環境では、推奨設定(recommended)を読み込むだけでこのルールのエラーが出ました。

js
// eslint.config.js
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';

export default [
  { files: ['src/**/*.tsx', 'lint/**/*.tsx'], languageOptions: { parser: tseslint.parser } },
  reactHooks.configs.flat.recommended,
];

この記事で試したコンポーネントをまとめたファイルにかけると、エラーは1件でした(出力のパスは /path/to/app に置き換えています)。

text
/path/to/app/src/cases.tsx
  46:11  error  Error: This value cannot be modified

Modifying a value returned from 'useState()', which should not be modified directly. Use the setter function to update instead.

/path/to/app/src/cases.tsx:46:11
  44 |       <button
  45 |         onClick={() => {
> 46 |           cat.age = cat.age + 1;
     |           ^^^ `cat` cannot be modified
  47 |           setCat(cat);
  48 |         }}
  49 |       >  react-hooks/immutability

✖ 1 problem (1 error, 0 warnings)

見つかったのは、オブジェクトのプロパティへの代入だけです。同じファイルにある items.push('みけ') と、更新用関数の中の prev.push('みけ') は、エラーになりませんでした。

ルールの説明ページには、items.push(4); setItems(items); が違反の例として載っています。この例をそのまま別のファイルに写してかけても、eslint-plugin-react-hooks 7.1.1 では何も出ず、終了コードは 0 でした。配列の push も止めたいなら、前の節の readonly の型を合わせて使います。

​Posted by 86