ReactのstateをstructuredCloneでコピーする方法

​​React​JavaScript,TypeScript

入れ子になった state の奥の値を1つだけ変えたいとき、structuredClone() で state を丸ごとコピーしてからコピーを書き換えると、スプレッド構文を何段も重ねずに済みます。元の state には手を付けないので、React の「state を直接書き換えない」というルールも守れます。

ただし、structuredClone() はすべての階層を新しいオブジェクトにします。そのため、変えていない部分まで「変わった」と React に判断されます。また、関数やクラスのインスタンスを含む state では、例外が出たりメソッドが消えたりします。ここでは、そうした点を検証環境で確かめた結果を載せています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。structuredClone() の動きは Node.js で確かめました。コンポーネントは tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Chromium で Playwright から操作しました。Firefox と Safari、本番用のビルド、Immer などのライブラリとの組み合わせは試していません。

updater 関数の中でコピーしてから書き換える

todo の一覧を持つ state で、1件の done を切り替える例です。setState に渡す updater 関数の中で、前の state(prev)を structuredClone() でコピーし、コピーのほうを書き換えて返します。

tsx
type Todo = { id: number; title: string; done: boolean };
type State = {
  settings: { theme: 'light' | 'dark' };
  todos: Todo[];
};

// structuredClone で丸ごとコピーしてから、コピーを書き換える
function toggleByClone(prev: State, id: number): State {
  const next = structuredClone(prev);
  const todo = next.todos.find((t) => t.id === id);
  if (todo) todo.done = !todo.done;
  return next;
}

// コンポーネントの中で
setState((prev) => toggleByClone(prev, id));

next.todos.find() で見つけた todo は、コピーの中のオブジェクトです。これを書き換えても、元の prev は変わりません。React の公式ドキュメントにあるオブジェクトの state を更新する方法の解説では、スプレッド構文は1段しかコピーしない(浅いコピー)と書かれています。structuredClone() は、MDN の説明のとおり、すべての階層をコピーします(ディープコピー)。

StrictMode の開発用のビルドでは、useState のリファレンスにあるとおり、updater 関数が2回呼ばれます。上の toggleByClone() は、毎回 prev をコピーしてから書き換えるだけで prev そのものは変えないので、2回呼ばれても結果は同じです。検証でも、クリックするたびにチェックが1回ずつ切り替わりました。

コピーされるもの、されないもの

structuredClone() は何でもコピーできるわけではありません。コピーできる型は MDN の構造化複製アルゴリズムの解説にまとまっています。state によく入る値で試した結果は次のとおりです。

state に入れた値 コピーの結果
入れ子のオブジェクトと配列 どの階層も別のオブジェクトになる
Date Date のまま
Set、Map Set、Map のまま
値が undefined のプロパティ プロパティごと残る
自分自身を指すプロパティ(循環参照) コピーの中で、コピー自身を指す
関数 DataCloneError の例外になる
クラスのインスタンス 中身だけのプレーンなオブジェクトになる

ディープコピーの書き方として知られる JSON.parse(JSON.stringify()) でコピーすると、同じ state の Date は文字列に、Set は中身のない {} になり、値が undefined のプロパティは消えました。Date や Set を含む state なら、structuredClone() のほうが元の形のままコピーできます。

関数を持つオブジェクトを渡すと、Node.js では次の例外になりました。state にコールバックを入れている場合は、その state を丸ごと structuredClone() に渡せません。

text
関数を持つオブジェクト → 例外 DataCloneError: () => {} could not be cloned.

クラスのインスタンスは、例外にならないため、かえって気づきにくい点です。x と y を持ち、length() メソッドのある Point クラスのインスタンスをコピーしたところ、instanceof Point が false になり、length() を呼ぶと TypeError になりました。

text
Point の clone instanceof Point → false
Point の clone の中身 → {"x":3,"y":4}
clone.length() → 例外 TypeError: p.length is not a function

ほかにも、getter は呼び出した結果の値に置き換わり、Symbol をキーにしたプロパティは消えました。Object.freeze() した値のコピーは、凍結されていない普通のオブジェクトになりました。MDN にも、プロトタイプチェーンや getter・setter は複製されないと書かれています。

変えていない部分まで「変わった」ことになる

React は、memo した子の props や、useEffect の依存配列の値を Object.is で比べます(memo のリファレンス、useEffect のリファレンス)。中身が同じでも、別のオブジェクトなら「変わった」と判断されます。

structuredClone() でコピーすると、書き換えていない todo や settings も別のオブジェクトになります。これを確かめるため、上の state を使う画面を作り、各 todo を memo した TodoItem で表示しました。比べる相手は、変わる所だけを新しいオブジェクトにする、次のスプレッド構文の書き方です。

tsx
// 変わる所だけを新しいオブジェクトにする
function toggleBySpread(prev: State, id: number): State {
  return {
    ...prev,
    todos: prev.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
  };
}

TodoItem には、ほかの props として useCallback で包んだ onToggle だけを渡しています。各 TodoItem の中を <Profiler> で囲んで再描画(コミット)された回数を数え、settings を依存配列に入れた useEffect が動いた回数も数えました。「牛乳を買う」を1回クリックしたときに増えた回数は次のとおりです。

コピーのしかた 牛乳を買う 爪を切る 本を返す settings の effect
structuredClone() 1 1 1 1
スプレッド構文 1 0 0 0

structuredClone() では、変えていない「爪を切る」と「本を返す」も再描画され、settings を見ている effect も動きました。スプレッド構文では、変えた1件だけが再描画され、effect は動きませんでした。2回目のクリックでも同じ結果でした。

2つの木の図。上の structuredClone の場合は、state、settings、todos、todo 1〜3 のすべてが点線の「新しいオブジェクト」。下のスプレッド構文の場合は、state、todos、todo 1 だけが新しいオブジェクトで、settings、todo 2、todo 3 は水色の「前と同じオブジェクト」。どちらも todo 1 はピンクの枠
todo 1 の done を切り替えたあと、新しい state のどこが別のオブジェクトになったか

effect の中でデータを取得しているなら、関係のない todo を切り替えるたびに、その取得の処理も動きます。

件数が多いと、コピーに時間がかかる

structuredClone() は、更新のたびに state 全体をたどってコピーします。todos が 10,000 件の state で、1件を切り替える処理を Node.js で200回ずつ実行し、かかった時間の中央値を比べました。3回測った結果は次のとおりです。

text
structuredClone 5.930 ms
スプレッド構文 0.083 ms
structuredClone 5.737 ms
スプレッド構文 0.085 ms
structuredClone 5.848 ms
スプレッド構文 0.087 ms

structuredClone() は 1回あたり約6ミリ秒で、スプレッド構文の約70倍かかりました。この環境(4コア)の Node.js で測った値です。

どちらを選ぶか

structuredClone() は、state がプレーンなオブジェクトと配列、Date、Map、Set だけでできていて、件数が少なく、memo や依存配列で中のオブジェクトを比べていない場面なら、短く書けて便利です。

ポイント

memo した子に state の一部を渡しているときや、件数が多いときは、スプレッド構文で変わる所だけをコピーするほうが向いています。関数を含む state は structuredClone() に渡すと例外になり、クラスのインスタンスはコピーするとメソッドを失います。

スプレッド構文の入れ子が深くなって読みにくいときは、React の公式ドキュメントでは、state の構造を平らにすることや、Immer を使うことが紹介されています(オブジェクトの state を更新する方法の解説)。

​Posted by 86