Reactでstateを書き換えても画面が変わらない理由と直し方
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 に渡します。
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 の更新で再レンダーが起きたときにまとめて出てきます。
新しい配列やオブジェクトを作って渡す
直すには、元の配列やオブジェクトを書き換えずに、新しいものを作って渡します。
setItems([...items, 'みけ']);
setCat({ ...cat, age: cat.age + 1 });
この書き方では、押すたびに「たま、みけ(2匹)」「たま、みけ、みけ(3匹)」、「たまは4歳」「たまは5歳」と表示が変わりました。state に入れた配列やオブジェクトは読み取り専用として扱う、というのが公式ドキュメントの説明です(オブジェクトの state の更新、配列の state の更新)。配列のページには、sort や splice の代わりに使える書き方の対応表もあります。
更新用関数の中で書き換えると、Strict Mode で数が合わなくなる
書き換えは、更新用関数の中でも起きます。次のように prev に push してからコピーを返すと、新しい配列を返しているので画面は変わります。しかし、prev そのものも書き換えています。
setItems((prev) => {
prev.push('みけ');
return [...prev];
});

これを <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 を書き換えずに新しい配列を返すことです。
setItems((prev) => [...prev, 'みけ']);
開発中に <StrictMode> の中でだけ数が合わないときは、更新用関数の中で prev を書き換えていないかを確かめます。本番用ビルドでは数が合って見えるので、開発中の食い違いが手がかりになります。
readonly の型にすると、tsc が書き換えを止める
useState の型引数を readonly string[] や Readonly<Cat> にすると、書き換えが型エラーになります。
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か所がエラーになりました(行番号は、検証用のファイルのものです)。
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)を読み込むだけでこのルールのエラーが出ました。
// 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 に置き換えています)。
/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 の型を合わせて使います。





