React で useState に入れた配列やオブジェクトを更新するとき、push で要素を足したり obj.name = '...' のようにプロパティを直接書き換えたりすると、値は変わっているはずなのに画面が更新されない、という現象に出くわします。これは React が状態の変化を参照の比較で判断しているためです。この記事では、なぜ状態を直接書き換えてはいけないのか(イミュータブル更新が必要な理由)を仕組みから説明し、そのうえでオブジェクトのプロパティ更新・ネストしたオブジェクトの更新・配列への追加や削除・要素の書き換え・並び替えといった実践パターンを、動くコードとあわせて解説します。対象は useState の基本を理解している初心者〜中級者の方です。
目次
なぜ状態を直接書き換えてはいけないのか
React では、状態を更新するときに setState(useState が返す更新関数)へ新しい値を渡すのが原則です。ここで大事なのは、配列やオブジェクトを更新するときに、既存のものをそのまま書き換える(ミューテーションする)のではなく、新しい配列・オブジェクトを作って渡すという点です。この「元の値を書き換えず、新しい値を作る」考え方をイミュータブル(immutable、不変)な更新と呼びます。
理由は、React が再レンダリングするかどうかを参照の比較で決めているからです。React は状態が更新されたとき、前の値と新しい値を Object.is で比較し、異なっていれば再レンダリングします。数値や文字列のようなプリミティブ値なら値そのものが比較されますが、オブジェクトや配列は中身ではなく参照(メモリ上の同一性)で比較されます。つまり、同じオブジェクトを直接書き換えて渡しても、参照が前と同じであるため React は「変わっていない」と判断し、再レンダリングをスキップしてしまうのです。
// 参照が同じかどうかで判定される
const user = { name: 'たろう' };
// 直接書き換えても、user 自身の参照は変わらない
user.name = 'はなこ';
Object.is(user, user); // true → React は「変化なし」と見なす
// 新しいオブジェクトを作れば、参照が変わる
const nextUser = { ...user, name: 'はなこ' };
Object.is(user, nextUser); // false → React は「変化あり」と見なす
そのため、状態に入れた配列やオブジェクトは「読み取り専用」だと考え、更新するときは必ず新しいものを作って setState に渡します。push / pop / splice / sort / reverse といった元の配列を書き換える破壊的メソッドや、obj.x = ... による直接代入は避け、スプレッド構文(...)や map / filter といった新しい値を返す非破壊的な操作を使うのが基本方針です。
オブジェクトの状態を更新する
1つのプロパティを更新する
オブジェクトの一部を書き換えたいときは、スプレッド構文で既存のプロパティをすべてコピーした新しいオブジェクトを作り、変更したいプロパティだけを後ろで上書きします。スプレッドは後に書いたものが優先されるため、name だけが新しい値に置き換わり、ほかのプロパティは元のまま引き継がれます。
import { useState } from 'react';
function Profile() {
const [user, setUser] = useState({ name: 'たろう', age: 20 });
const changeName = () => {
// NG: 直接書き換えても再レンダリングされない
// user.name = 'はなこ';
// setUser(user);
// OK: 既存のプロパティをコピーし、name だけ上書きした新しいオブジェクトを渡す
setUser({ ...user, name: 'はなこ' });
};
return (
<div>
<p>{user.name}({user.age}歳)</p>
<button onClick={changeName}>名前を変える</button>
</div>
);
}
フォームの入力欄が複数あり、1つのオブジェクトで管理している場合は、[key] の記法(計算されたプロパティ名)を使うと1つのハンドラーで書けます。event.target.name に対応するプロパティだけを動的に上書きできます。
const handleChange = (e) => {
// 変更された入力欄の name に対応するプロパティだけを上書きする
setUser({ ...user, [e.target.name]: e.target.value });
};
ネストしたオブジェクトを更新する
オブジェクトの中にオブジェクトが入っている(ネストしている)場合は、書き換えたい階層までスプレッドを重ねて、それぞれの階層で新しいオブジェクトを作ります。外側のオブジェクトだけをコピーして内側を直接書き換えると、内側の参照が元のまま共有されてしまい、ミューテーションになってしまうためです。
const [user, setUser] = useState({
name: 'たろう',
address: { city: '東京', zip: '100-0001' },
});
// address.city だけを更新したい
const changeCity = () => {
setUser({
...user, // 外側をコピー
address: {
...user.address, // 内側もコピー
city: '大阪', // 変えたいプロパティだけ上書き
},
});
};
ネストが深くなるとスプレッドの入れ子も深くなり、書くのが大変になります。その場合は状態の構造自体を浅く設計し直すか、Immer のようなイミュータブル更新を簡潔に書けるライブラリの利用を検討するとよいでしょう。
配列の状態を更新する
配列も同じく、元の配列を書き換えずに新しい配列を作って渡します。追加・削除・書き換え・並び替えのそれぞれで、破壊的メソッドを避けて非破壊的な方法を使うのがポイントです。以下、items という配列の状態を例に見ていきます。
要素を追加する
末尾に追加するには、push(元の配列を書き換える破壊的メソッド)ではなく、スプレッド構文で既存の要素を展開した新しい配列を作ります。先頭に追加したい場合は、新しい要素をスプレッドより前に置きます。
const [items, setItems] = useState(['りんご', 'みかん']);
// NG: push は元の配列を書き換えるうえ、参照も変わらない
// items.push('ぶどう');
// setItems(items);
// OK: 末尾に追加した新しい配列を作る
setItems([...items, 'ぶどう']);
// 先頭に追加したいときは新しい要素を前に置く
setItems(['ぶどう', ...items]);
要素を削除する
削除は filter を使うのが簡潔です。filter は条件に合う要素だけを集めた新しい配列を返すので、削除したい要素を除外する条件を書けば、元の配列を壊さずに削除できます。splice は元の配列を書き換える破壊的メソッドなので避けます。
const [todos, setTodos] = useState([
{ id: 1, text: '牛乳を買う' },
{ id: 2, text: '掃除する' },
]);
// id が 1 の要素を削除する(= id が 1 でないものだけ残す)
const remove = (targetId) => {
setTodos(todos.filter((todo) => todo.id !== targetId));
};
要素を書き換える
特定の要素だけを更新するには map を使います。map は各要素を変換した新しい配列を返すので、対象の要素だけ新しい値に差し替え、それ以外はそのまま返すようにします。要素がオブジェクトの場合は、ここでもスプレッド構文でコピーしてから該当プロパティを上書きします。
const [todos, setTodos] = useState([
{ id: 1, text: '牛乳を買う', done: false },
{ id: 2, text: '掃除する', done: false },
]);
// id が一致する要素の done だけを反転させる
const toggle = (targetId) => {
setTodos(
todos.map((todo) =>
todo.id === targetId
? { ...todo, done: !todo.done } // 対象だけ新しいオブジェクトに差し替え
: todo // それ以外はそのまま
)
);
};
並び替える
sort と reverse は元の配列を並び替えてしまう破壊的メソッドです。状態の配列に直接使うとミューテーションになるため、先にスプレッド構文でコピーを作ってから sort を呼びます。こうすれば元の配列は無傷のまま、並び替えた新しい配列を setState に渡せます。
const [nums, setNums] = useState([3, 1, 2]);
const sortAsc = () => {
// NG: sort は nums を直接並び替える(参照も変わらない)
// setNums(nums.sort((a, b) => a - b));
// OK: コピーしてから並び替える
setNums([...nums].sort((a, b) => a - b));
};
なお、比較的新しい環境であれば、コピーと並び替えを同時に行う非破壊的な toSorted / toReversed を使う方法もあります(元の配列を変更せず、並び替えた新しい配列を返します)。使える環境なら setNums(nums.toSorted((a, b) => a - b)) のように書けます。
破壊的メソッドと非破壊的な代替
配列を扱うときに「使ってはいけない破壊的メソッド」と「代わりに使う非破壊的な方法」を対応させると次のようになります。左側は元の配列を書き換えるため状態には使えず、右側は新しい配列を返すのでイミュータブル更新に使えます。
| やりたいこと | 避ける(破壊的) | 代わりに使う(非破壊的) |
|---|---|---|
| 末尾に追加 | push | [...arr, x] |
| 先頭に追加 | unshift | [x, ...arr] |
| 削除 | pop / shift / splice | filter / slice |
| 要素の書き換え | arr[i] = ... / splice | map |
| 並び替え | sort / reverse | [...arr].sort() / toSorted |
ポイントは、map / filter / slice / スプレッド構文はいずれも新しい配列を返し、元の配列を変更しないという点です。迷ったら「この操作は元の配列を書き換えないか?」を基準に選べば、状態を安全に更新できます。
前の状態から更新するときは更新関数形式を使う
「今の状態をもとに次の状態を作る」ときは、setState に値を直接渡すのではなく、前の状態を引数で受け取る更新関数(updater function)を渡すのが安全です。setItems(prev => [...prev, x]) のように書くと、React が最新の状態を prev として渡してくれるため、古い値を参照してしまう問題を避けられます。
特に、1回のイベントの中で同じ状態を続けて更新する場合に差が出ます。次の例のように setCount(count + 1) を2回書いても、どちらも同じ古い count を見ているため合計は1しか増えません。更新関数形式なら、直前の更新結果が c に渡るため、期待どおり2増えます。
const [count, setCount] = useState(0);
const addTwice = () => {
// NG: どちらも同じ古い count を参照するので +1 しか増えない
// setCount(count + 1);
// setCount(count + 1);
// OK: 前の値を受け取る更新関数なら +2 になる
setCount((c) => c + 1);
setCount((c) => c + 1);
};
// 配列も同様。前の配列に追加する形で書く
const addItem = (x) => {
setItems((prev) => [...prev, x]);
};
この場合も、更新関数の中で prev を直接書き換えてはいけない点は同じです。あくまで prev をもとに新しい配列・オブジェクトを作って返すようにします。
更新したのに画面が変わらない原因
「値は確かに変えたのに再レンダリングされない」という症状のほとんどは、状態を直接書き換えて参照が変わっていないことが原因です。前述のとおり React は Object.is による参照比較で更新を判断するため、同じオブジェクト・配列を書き換えて渡しても「変化なし」と見なされ、再レンダリングがスキップされます。
// よくある「画面が変わらない」パターン // 1. push してから同じ配列を渡している items.push(newItem); setItems(items); // 参照が同じ → 再レンダリングされない // 2. プロパティを直接書き換えてから同じオブジェクトを渡している user.name = 'はなこ'; setUser(user); // 参照が同じ → 再レンダリングされない // 3. sort で状態の配列を直接並び替えている setNums(nums.sort((a, b) => a - b)); // nums 自身が並び替わり、参照も同じ
いずれも「元の値を書き換えている」のが共通点です。解決策は一貫していて、スプレッド構文や map / filter を使って新しい配列・オブジェクトを作り、それを渡すことです。これで参照が変わり、React が変化を検知して再レンダリングします。開発中にこうしたミューテーションを見つけたい場合は、StrictMode で囲んでおくと、開発ビルドで意図しない副作用を検出しやすくなります。
まとめ
React で状態の配列・オブジェクトを更新するときは、元の値を直接書き換えず、新しい配列・オブジェクトを作って setState に渡す「イミュータブル更新」が基本です。これは React が Object.is による参照比較で再レンダリングを判断しているためで、直接書き換えると参照が変わらず画面が更新されません。オブジェクトはスプレッド構文でコピーして必要なプロパティだけ上書きし、ネストする場合はスプレッドを重ねます。配列は追加なら [...items, x]、削除なら filter、書き換えなら map、並び替えなら [...items].sort() と、破壊的メソッドを避けて非破壊的な操作を使います。そして前の状態をもとに更新するときは setCount(c => c + 1) のような更新関数形式を使うと安全です。「元の値を書き換えないこと」を意識すれば、状態更新のバグの多くは防げます。