Reactでは、stateの配列やオブジェクトを更新するとき、新しいものを作ってセッターに渡します。既存の値をその場で変えてはいけません。スプレッド(...)、map、filter、slice を使って変更を加えたコピーを作り、setItems(copy) を呼びます。
[...items, { ... }] は項目が1つ多い新しい配列で、filter は削除した項目を除いた新しい配列を返します。どちらも古い配列には手を付けません。
直接変更すると描画されない理由
セッターを呼ぶと、Reactは新しい値と古い値を Object.is で比べます。配列やオブジェクトの場合、比べるのは中身ではなく参照です。配列を push で変えて同じ配列を渡し直すと、Reactには同じ参照が見え、何も変わっていないと判断してレンダリングを省きます。
「push」を何回かクリックしてください。コンソールには配列が大きくなる様子が出ますが、新しいレンダリングの行は現れず、テキストは a のままです。次に「spread」をクリックすると、レンダリングが1回起き、pushした項目がすべて一度に表示されます。ずっと配列の中にあったからです。直接の変更はエラーを投げず、ほかの何かがレンダリングを起こすまで画面を古いままにするだけです。その後でもう一度「push」をクリックすると、今度はレンダリングされるかもしれません。Reactはいつも事前に処理を省けるわけではないので、直接の変更は反映されるときもあれば、されないときもあります。この予測できなさが、stateを直接変更してはいけない理由です。
配列:使うメソッドと避けるメソッド
ルールは単純です。新しい配列を返すメソッドを使い、呼び出した配列そのものを変えるメソッドは避けます。
| 目的 | 避ける(直接変更する) | 使う(新しい配列を返す) |
|---|---|---|
| 追加 | push、unshift | [...arr, item]、[item, ...arr] |
| 削除 | pop、shift、splice | filter、slice |
| 置き換え | arr[i] = x、splice | map、with(i, x) |
| 挿入 | splice | [...arr.slice(0, i), x, ...arr.slice(i)]、toSpliced |
| 並べ替え、反転 | sort、reverse | toSorted、toReversed、または先にコピー |
toSorted、toReversed、toSpliced、with は新しい配列メソッド(ES2023)で、2023年以降、現行の主要ブラウザすべてでサポートされています。直接変更する版と同じことを、コピーに対して行います。
mapで1つの項目を更新する
1つの項目を変えるには、配列を map し、変えたい項目には新しいオブジェクトを、それ以外の項目には元のオブジェクトを返します。
{ ...todo, done: !todo.done } の部分が重要です。よくあるバグは、配列はコピーしたのに、その中のオブジェクトを変えてしまうことです。
const next = [...todos];
next[0].done = true; // next is new, but next[0] is the same object as todos[0]
setTodos(next);
これでもレンダリングされるので問題なさそうに見えますが、古い配列と新しい配列が、変更されたオブジェクトを共有しています。古い項目と新しい項目を比べるもの(React.memo の子、その項目に依存するエフェクト)は違いを見つけられません。変える項目はコピーしてください。
指定位置に挿入する
直接変更しない insert はないので、配列を2つにスライスし、その間に新しい項目を置きます。toSpliced(index, 0, item) なら同じことが1回の呼び出しでできます。
indexを 0 にしてもう一度挿入すると、新しい C は先頭に入ります。next の式を letters.toSpliced(index, 0, 'C') に置き換えても結果は同じです。
並べ替えと反転
sort と reverse は、呼び出した配列を返しはしますが、その配列自体も変えてしまいます。そのため setItems(items.sort()) は、直接変更してから同じ参照でセットすることになり、このページの最初で見たバグになります。代わりにコピーを並べ替えてください。
[...scores].reverse() が動くのは、スプレッドが先にコピーを作り、reverse はそのコピーだけを変えるからです。
オブジェクトとネストしたオブジェクトを更新する
オブジェクトも同じルールに従います。古いオブジェクトを新しいオブジェクトにスプレッドし、変わるフィールドを上書きします。ネストしたオブジェクトなら、そのフィールドに至るまでのすべての階層でスプレッドします。1回のスプレッドは一番上の階層しかコピーしないので、内側のオブジェクトは古いままになってしまいます。
コピーした階層はそれぞれ新しいオブジェクトになり、触らない階層は同じオブジェクトのままです。これがスプレッドが安上がりな理由です。stateが長い注文のリストも持っていたとしても、{ ...user } がコピーするのはリストへの参照であって、リストそのものではありません。
stateのオブジェクトが3、4階層もネストしていて、長いスプレッドの連鎖を書き続けているなら、stateをもっと平らにできるというサインであることが多いです。項目をidで引ける形で保存したり、1つの大きなオブジェクトをいくつかの useState に分けたりすれば、各更新を短く保てます。多くのイベントが1つの構造を変えるなら、reducerも役立ちます(useReducerを参照)。
structuredCloneとスプレッド
structuredClone(user) は1回の呼び出しでディープコピーを作るので、その後はコピーを自由に変えられます。
const next = structuredClone(user);
next.address.city = 'Oslo';
setUser(next);
これは正しいのですが、変えていない部分も含めて毎回オブジェクト全体をコピーします。大きなstateでは時間がかかり、ネストしたすべてのオブジェクトが新しい参照になるので、user.orders を受け取るメモ化された子は、注文が変わっていなくても再レンダリングされます。structuredClone は関数があると例外を投げ、クラスのインスタンスは普通のオブジェクトとして返ってきます。スプレッドは変える経路だけをコピーするので、通常はこちらを選びます。structuredClone は、オブジェクトの大部分を本当に書き換えるまれな更新のために取っておきましょう。
Immerで直接変更するように書く
Immerは、直接変更するように見えるコードを書けるようにする小さなライブラリです。下書きを渡し、何を変えたかを記録して、変わった部分だけをコピーした新しいイミュータブルな値を作ります。use-immer パッケージがそれをフックとして包んでいます。
import { useImmer } from 'use-immer';
function Profile() {
const [user, updateUser] = useImmer({
name: 'Ada',
address: { city: 'London' },
});
function moveTo(city) {
updateUser((draft) => {
draft.address.city = city;
});
}
// ...
}
このページのエディタはReactしか読み込まないので、Immerは使えません。stateが深くネストしていてスプレッドが読みにくくなったときに追加する価値があります。平らなstateなら、素のスプレッドと map で十分で、ほとんどのReactのコードで目にするのもそちらです。
よくある質問
Reactのstateの配列に項目を追加するには?
古い項目と新しい項目を含む新しい配列を作り、セッターに渡します:setItems([...items, newItem])。items.push(newItem) は呼ばないでください。既存の配列を変えてしまい、Reactには同じ参照に見えるからです。
配列を更新してもコンポーネントが再レンダリングされないのはなぜですか?
おそらく配列をその場で変更し(push、splice、sort、arr[i] = x)、同じ配列を渡し直したからです。Reactは古い値と新しい値を Object.is で比べ、同じオブジェクトだと判断してレンダリングを省きます。代わりに新しい配列を渡してください。
Reactで配列の中の1つのオブジェクトを更新するには?
map を使い、変える項目には新しいオブジェクトを、それ以外の項目には元の項目を返します:setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t))。
Reactのstateでネストしたオブジェクトを更新するには?
変えるフィールドまでの経路にあるすべての階層をコピーします:setUser({ ...user, address: { ...user.address, city: 'Oslo' } })。1回のスプレッドは一番上の階層しかコピーしないので、内側のオブジェクトは共有されたままになります。
Reactのstateには、structuredCloneとスプレッド構文のどちらを使うべきですか?
スプレッドを選んでください。変わる部分だけをコピーし、触らない部分は同じオブジェクトのまま保つので、React.memo や比較に役立ちます。structuredClone は毎回すべてをコピーします。書くのは簡単ですが、大きなstateでは遅く、変わっていない部分の参照の同一性も失われます。