- React
React useStateの配列・オブジェクト更新早見表 追加・削除・変更
useStateの配列やオブジェクトは、中身を書き換えるのではなく「新しい配列・オブジェクトを作ってsetに渡す」ことで更新します。 Reactは前のstateと次のstateを Object.is で比べ、同じものなら再レンダリングを省きます。push や sort は元の配列そのものを書き換えるため、setに渡しても「同じもの」と判定され、画面が変わりません。
この記事では、配列7種類(追加・先頭追加・挿入・削除・置換・並べ替え・反転)とオブジェクト3種類(項目変更・項目削除・ネスト更新)の書き方を、「使ってよいメソッド」と「stateを書き換えてしまうメソッド」に分けた早見表にしました。push・sort・spliceで再レンダリングされない様子を Object.is の結果つきで再現し、ES2023の toSorted・toReversed・toSpliced・with や structuredClone での書き換え、配列内オブジェクトの更新とImmerでの同じ処理の比較まで、すべて動かした出力を載せています。
確認環境:Ubuntu 24.04、Node.js 22.22.2、React 19.3.0 / react-dom 19.3.0、TypeScript 7.0.2、Vitest 5.0.3、jsdom 30.1.1、@testing-library/react 16.3.3、immer 11.1.21、use-immer 0.11.0。
配列・オブジェクト更新の早見表
配列の更新です。prev は更新関数に渡される「1つ前のstate」です。
| やりたいこと | 使ってよい書き方(新しい配列を返す) | stateを書き換えてしまう書き方 |
|---|---|---|
| 末尾に追加 | setItems(prev => [...prev, x]) |
items.push(x) |
| 先頭に追加 | setItems(prev => [x, ...prev]) |
items.unshift(x) |
| 途中に挿入 | setItems(prev => prev.toSpliced(i, 0, x)) |
items.splice(i, 0, x) |
| 条件で削除 | setItems(prev => prev.filter(v => v.id !== id)) |
items.splice(i, 1) |
| 位置で削除 | setItems(prev => prev.toSpliced(i, 1)) |
items.splice(i, 1)、pop()、shift() |
| 位置で置換 | setItems(prev => prev.with(i, x)) |
items[i] = x |
| 条件で置換 | setItems(prev => prev.map(v => v.id === id ? x : v)) |
items[i] = x |
| 並べ替え | setItems(prev => prev.toSorted(compare)) |
items.sort(compare) |
| 反転 | setItems(prev => prev.toReversed()) |
items.reverse() |
| 空にする | setItems([]) |
items.length = 0 |
オブジェクトの更新です。
| やりたいこと | 使ってよい書き方(新しいオブジェクトを返す) | stateを書き換えてしまう書き方 |
|---|---|---|
| 項目を変更 | setUser(prev => ({ ...prev, age: 21 })) |
user.age = 21 |
| キー名を変数で指定 | setUser(prev => ({ ...prev, [key]: value })) |
user[key] = value |
| 項目を削除 | setUser(prev => { const { memo, ...rest } = prev; return rest; }) |
delete user.memo |
| ネストした項目を変更 | setUser(prev => ({ ...prev, address: { ...prev.address, city: "Osaka" } })) |
user.address.city = "Osaka" |
| 配列内オブジェクトの項目を変更 | setTodos(prev => prev.map(t => t.id === id ? { ...t, done: true } : t)) |
todos[i].done = true |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React useStateの配列・オブジェクト更新早見表 追加・削除・変更」
コピペで動かすための準備
空のフォルダで次を実行すると、この記事と同じ環境になります。
npm init -y
npm i react react-dom typescript vitest jsdom \
@testing-library/react @testing-library/dom \
@types/react @types/react-dom immer use-immer
// vitest.config.mts
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "jsdom", globals: true } });
{
"compilerOptions": {
"target": "ES2023", "lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext", "moduleResolution": "Bundler",
"jsx": "react-jsx", "strict": true, "skipLibCheck": true, "noEmit": true,
"types": ["vitest/globals"]
},
"include": ["a2/state.test.tsx"]
}
テストファイルの先頭は共通です。各例では log という配列に文字列をため、最後に out() でまとめて表示しています。実行は npx vitest run a2 --silent=false です。
// a2/state.test.tsx の先頭
import { useState } from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { produce } from "immer";
import { useImmer } from "use-immer";
afterEach(cleanup);
const log: string[] = [];
const out = (title: string) => { console.log(`--- ${title}\n` + log.join("\n")); log.length = 0; };
push・sort・spliceで再レンダリングされない様子を再現する
元の配列を書き換えてからsetに渡すと、stateの中身は変わっているのに画面が変わらない、という状態になります。 4つのボタンで、書き換える前後の配列を Object.is で比べ、描画回数と画面の表示を記録します。
test("1. push / sort / splice は再レンダリングされない", () => {
let renders = 0;
function App() {
const [items, setItems] = useState([3, 1, 2]);
renders++;
const run = (label: string, mutate: (a: number[]) => void) => () => {
const prev = items;
mutate(items);
log.push(`${label}: Object.is(前, 次)=${Object.is(prev, items)} 配列の中身=${JSON.stringify(items)}`);
setItems(items);
};
return (
<>
<p data-testid="view">{items.join(",")}</p>
<button onClick={run("push", (a) => a.push(4))}>push</button>
<button onClick={run("sort", (a) => a.sort((x, y) => x - y))}>sort</button>
<button onClick={run("splice", (a) => a.splice(0, 1))}>splice</button>
<button onClick={() => { const next = [...items, 5]; log.push(`[...items, 5]: Object.is(前, 次)=${Object.is(items, next)}`); setItems(next); }}>copy</button>
</>
);
}
render(<App />);
log.push(`初回: render回数=${renders} 画面=${screen.getByTestId("view").textContent}`);
for (const name of ["push", "sort", "splice", "copy"]) {
fireEvent.click(screen.getByText(name));
log.push(` → render回数=${renders} 画面=${screen.getByTestId("view").textContent}`);
}
out("mutate");
});
実行結果:
--- mutate
初回: render回数=1 画面=3,1,2
push: Object.is(前, 次)=true 配列の中身=[3,1,2,4]
→ render回数=1 画面=3,1,2
sort: Object.is(前, 次)=true 配列の中身=[1,2,3,4]
→ render回数=1 画面=3,1,2
splice: Object.is(前, 次)=true 配列の中身=[2,3,4]
→ render回数=1 画面=3,1,2
[...items, 5]: Object.is(前, 次)=false
→ render回数=2 画面=2,3,4,5
読み取れることは3つあります。
- push・sort・spliceのあとは
Object.is(前, 次)=trueです。配列の中身は[3,1,2,4]→[1,2,3,4]→[2,3,4]と変わっていますが、配列そのもの(参照)は同じままです。 - そのため描画回数は1のまま、画面も
3,1,2のままです。Reactは「前と同じstateが渡された」と判断して再レンダリングを省いています。 - 最後に
[...items, 5]で新しい配列を渡すとObject.isがfalseになり、描画回数が2に増えます。画面は2,3,4,5になり、それまで溜まっていた書き換えがまとめて表示されます。 「別のボタンを押したら急に反映された」という現象は、この仕組みで説明できます。
flowchart LR
A["setItems(次の値)"] --> B{"Object.is(前, 次)"}
B -- true --> C[再レンダリングを省く]
B -- false --> D[再レンダリングして画面を更新]
Object.is は、配列やオブジェクトについては「同じ参照かどうか」を見ます。中身が同じでも別々に作った配列は false、中身が違っても同じ配列なら true です。
配列メソッド21個を実行して2つに分ける
「どのメソッドが元の配列を書き換えるのか」を覚えるより、実行して確かめるのが確実です。[3, 1, 2] に各操作を行い、元の配列がどうなったか、戻り値が元の配列と同じものかを出力します。
// a2/methods.ts(前半)
type Op = [string, (a: number[]) => unknown];
const ops: Op[] = [
["push(4)", (a) => a.push(4)],
["unshift(0)", (a) => a.unshift(0)],
["pop()", (a) => a.pop()],
["shift()", (a) => a.shift()],
["splice(1, 1)", (a) => a.splice(1, 1)],
["sort()", (a) => a.sort((x, y) => x - y)],
["reverse()", (a) => a.reverse()],
["fill(0)", (a) => a.fill(0)],
["copyWithin(0, 1)", (a) => a.copyWithin(0, 1)],
["a[0] = 9", (a) => { a[0] = 9; return a; }],
["a.length = 1", (a) => { a.length = 1; return a; }],
["[...a, 4]", (a) => [...a, 4]],
["concat(4)", (a) => a.concat(4)],
["filter", (a) => a.filter((x) => x !== 1)],
["map", (a) => a.map((x) => x * 2)],
["slice(1)", (a) => a.slice(1)],
["toSorted()", (a) => a.toSorted((x, y) => x - y)],
["toReversed()", (a) => a.toReversed()],
["toSpliced(1, 1)", (a) => a.toSpliced(1, 1)],
["with(0, 9)", (a) => a.with(0, 9)],
["structuredClone", (a) => structuredClone(a)],
];
console.log("操作".padEnd(18) + "元の配列(実行後)".padEnd(12) + "戻り値".padEnd(11) + "Object.is(元, 戻り値)");
for (const [name, fn] of ops) {
const a = [3, 1, 2];
const r = fn(a);
console.log(name.padEnd(20) + JSON.stringify(a).padEnd(18) + JSON.stringify(r).padEnd(14) + Object.is(a, r));
}
実行結果(node --experimental-strip-types a2/methods.ts):
操作 元の配列(実行後) 戻り値 Object.is(元, 戻り値)
push(4) [3,1,2,4] 4 false
unshift(0) [0,3,1,2] 4 false
pop() [3,1] 2 false
shift() [1,2] 3 false
splice(1, 1) [3,2] [1] false
sort() [1,2,3] [1,2,3] true
reverse() [2,1,3] [2,1,3] true
fill(0) [0,0,0] [0,0,0] true
copyWithin(0, 1) [1,2,2] [1,2,2] true
a[0] = 9 [9,1,2] [9,1,2] true
a.length = 1 [3] [3] true
[...a, 4] [3,1,2] [3,1,2,4] false
concat(4) [3,1,2] [3,1,2,4] false
filter [3,1,2] [3,2] false
map [3,1,2] [6,2,4] false
slice(1) [3,1,2] [1,2] false
toSorted() [3,1,2] [1,2,3] false
toReversed() [3,1,2] [2,1,3] false
toSpliced(1, 1) [3,1,2] [3,2] false
with(0, 9) [3,1,2] [9,1,2] false
structuredClone [3,1,2] [3,1,2] false
「元の配列(実行後)」が [3,1,2] のまま変わらない下の10行が、stateにそのまま使える操作です。
| 分類 | 操作 | stateでの扱い |
|---|---|---|
| 元の配列を書き換える | push、unshift、pop、shift、splice、sort、reverse、fill、copyWithin、添字への代入、length への代入 |
stateの配列には直接使わない。コピーに対して使うのは問題ない |
| 新しい配列を返す | スプレッド構文、concat、filter、map、slice、toSorted、toReversed、toSpliced、with、structuredClone |
そのままsetに渡せる |
見落としやすい点が2つあります。
pushとunshiftの戻り値は「新しい長さ」(上の表では4)です。setItems(items.push(x))と書くと、stateが配列ではなく数値になり、次の描画でTypeError: items.map is not a functionになります(手元でも同じエラーを確認しました)。sortとreverseは、並べ替えた結果として元の配列そのものを返します(Object.isがtrue)。setItems(items.sort())は見た目は新しい配列を渡しているようで、実際は同じ配列を渡しています。
配列の追加・挿入・削除・置換・並べ替えをまとめて動かす
早見表の「使ってよい書き方」を、1つのコンポーネントで順番に実行します。
test("2. 配列の操作一覧(コピーして更新)", () => {
const results: string[] = [];
function App() {
const [items, setItems] = useState(["b", "c", "d"]);
results.push(items.join(","));
return (
<>
<button onClick={() => setItems((prev) => [...prev, "e"])}>末尾に追加</button>
<button onClick={() => setItems((prev) => ["a", ...prev])}>先頭に追加</button>
<button onClick={() => setItems((prev) => prev.toSpliced(2, 0, "X"))}>挿入</button>
<button onClick={() => setItems((prev) => prev.filter((v) => v !== "X"))}>削除</button>
<button onClick={() => setItems((prev) => prev.toSpliced(0, 1))}>位置で削除</button>
<button onClick={() => setItems((prev) => prev.with(1, "C"))}>置換</button>
<button onClick={() => setItems((prev) => prev.map((v) => (v === "d" ? "D" : v)))}>条件で置換</button>
<button onClick={() => setItems((prev) => prev.toSorted((x, y) => y.localeCompare(x)))}>並べ替え</button>
<button onClick={() => setItems((prev) => prev.toReversed())}>反転</button>
<button onClick={() => setItems([])}>空にする</button>
</>
);
}
render(<App />);
log.push(`初期値: ${results.at(-1)}`);
for (const name of ["末尾に追加", "先頭に追加", "挿入", "削除", "位置で削除", "置換", "条件で置換", "並べ替え", "反転", "空にする"]) {
fireEvent.click(screen.getByText(name));
log.push(`${name}: ${results.at(-1) || "(空)"}`);
}
out("array ops");
});
実行結果:
--- array ops
初期値: b,c,d
末尾に追加: b,c,d,e
先頭に追加: a,b,c,d,e
挿入: a,b,X,c,d,e
削除: a,b,c,d,e
位置で削除: b,c,d,e
置換: b,C,d,e
条件で置換: b,C,D,e
並べ替え: e,D,C,b
反転: b,C,D,e
空にする: (空)
どのボタンも1回のクリックで画面が変わっています。
toSorted・toReversed・toSpliced・withの対応
ES2023で入った4つのメソッドは、従来のメソッドと引数が同じで、元の配列を変えずに新しい配列を返します。
| 従来のメソッド(元を書き換える) | ES2023のメソッド(新しい配列を返す) | 以前からある書き方 |
|---|---|---|
sort(compare) |
toSorted(compare) |
[...items].sort(compare) |
reverse() |
toReversed() |
[...items].reverse() |
splice(start, deleteCount, ...追加) |
toSpliced(start, deleteCount, ...追加) |
[...items.slice(0, i), x, ...items.slice(i)] |
items[i] = x |
with(i, x) |
items.map((v, idx) => idx === i ? x : v) |
React公式ドキュメント(Updating Arrays in State)の表は、追加に concat とスプレッド構文、削除に filter と slice、置換に map、並べ替えに「先にコピーしてから」を挙げています。ES2023の4メソッドは、その「コピーしてから操作する」を1つのメソッドで書けるようにしたものです。
TypeScriptで toSorted などを使うには、tsconfigの target または lib をES2023以上にします。ES2022のままだと次のエラーになります。
// a2/ng/lib.ts
const items = [3, 1, 2];
const sorted = items.toSorted((a, b) => a - b);
const replaced = items.with(0, 9);
console.log(sorted, replaced);
$ npx tsc --ignoreConfig --noEmit --strict --target es2022 a2/ng/lib.ts
a2/ng/lib.ts(2,22): error TS2550: Property 'toSorted' does not exist on type 'number[]'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2023' or later.
a2/ng/lib.ts(3,24): error TS2550: Property 'with' does not exist on type 'number[]'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2023' or later.
--target es2023 に変えると、エラーなしで通りました(上の出力は、同時に出た引数の型のエラーを省いています)。
書き換えをTypeScriptで見つけてもらう
stateの型を readonly にしておくと、書き換えるメソッドを型エラーとして教えてもらえます。
// a2/ng/readonly.tsx
import { useState } from "react";
type Todo = { readonly id: number; readonly title: string; readonly done: boolean };
export function App() {
const [todos, setTodos] = useState<readonly Todo[]>([{ id: 1, title: "買い物", done: false }]);
const add = () => { todos.push({ id: 2, title: "掃除", done: false }); setTodos(todos); };
const sort = () => { todos.sort((a, b) => a.id - b.id); setTodos(todos); };
const toggle = () => { todos[0].done = true; setTodos(todos); };
const ok = () => setTodos(todos.toSorted((a, b) => a.id - b.id).with(0, { ...todos[0], done: true }));
return <button onClick={() => { add(); sort(); toggle(); ok(); }}>{todos.length}</button>;
}
a2/ng/readonly.tsx(5,29): error TS2339: Property 'push' does not exist on type 'readonly Todo[]'.
a2/ng/readonly.tsx(6,30): error TS2339: Property 'sort' does not exist on type 'readonly Todo[]'.
a2/ng/readonly.tsx(7,35): error TS2540: Cannot assign to 'done' because it is a read-only property.
push・sort・項目への代入がエラーになり、toSorted と with を使った ok の行はエラーになっていません。readonly を付けるだけで、「使ってよいメソッド」だけが候補に残るので、早見表を覚えていなくても書き換えに気づけます。
実行時に確かめたい場合は Object.freeze が使えます。
// a2/methods.ts(続き)
const frozen = Object.freeze([3, 1, 2]) as number[];
try { frozen.push(4); } catch (e) { console.log(`${(e as Error).name}: ${(e as Error).message}`); }
try { frozen.sort(); } catch (e) { console.log(`${(e as Error).name}: ${(e as Error).message}`); }
console.log(JSON.stringify(frozen.toSorted()));
TypeError: Cannot add property 3, object is not extensible
TypeError: Cannot assign to read only property '0' of object '[object Array]'
[1,2,3]
オブジェクトの項目変更・項目削除・ネスト更新
オブジェクトも考え方は同じで、スプレッド構文で新しいオブジェクトを作ります。
test("3. オブジェクトの操作", () => {
type User = { name: string; age: number; memo?: string; address: { city: string; zip: string } };
let latest: User | undefined;
function App() {
const [user, setUser] = useState<User>({ name: "Sato", age: 20, memo: "x", address: { city: "Tokyo", zip: "100" } });
latest = user;
const key = "name";
return (
<>
<button onClick={() => setUser((prev) => ({ ...prev, age: 21 }))}>項目変更</button>
<button onClick={() => setUser((prev) => ({ ...prev, [key]: "Suzuki" }))}>キーを変数で</button>
<button onClick={() => setUser((prev) => { const { memo, ...rest } = prev; return rest; })}>項目削除</button>
<button onClick={() => setUser((prev) => ({ ...prev, address: { ...prev.address, city: "Osaka" } }))}>ネスト更新</button>
<button onClick={() => { user.address.city = "Nagoya"; setUser({ ...user }); }}>ネストを直接</button>
</>
);
}
render(<App />);
for (const name of ["項目変更", "キーを変数で", "項目削除", "ネスト更新"]) {
const before = latest!;
fireEvent.click(screen.getByText(name));
log.push(`${name}: ${JSON.stringify(latest)}`);
log.push(` 外側は新しい参照=${!Object.is(before, latest)} address は同じ参照=${Object.is(before.address, latest!.address)}`);
}
const before = latest!;
fireEvent.click(screen.getByText("ネストを直接"));
log.push(`ネストを直接: 新しい state の city=${latest!.address.city} / 1つ前の state の city=${before.address.city}`);
out("object ops");
});
実行結果:
--- object ops
項目変更: {"name":"Sato","age":21,"memo":"x","address":{"city":"Tokyo","zip":"100"}}
外側は新しい参照=true address は同じ参照=true
キーを変数で: {"name":"Suzuki","age":21,"memo":"x","address":{"city":"Tokyo","zip":"100"}}
外側は新しい参照=true address は同じ参照=true
項目削除: {"name":"Suzuki","age":21,"address":{"city":"Tokyo","zip":"100"}}
外側は新しい参照=true address は同じ参照=true
ネスト更新: {"name":"Suzuki","age":21,"address":{"city":"Osaka","zip":"100"}}
外側は新しい参照=true address は同じ参照=false
ネストを直接: 新しい state の city=Nagoya / 1つ前の state の city=Nagoya
| 操作 | 書き方のポイント |
|---|---|
| 項目変更 | { ...prev, age: 21 }。スプレッドを先、変える項目を後に書く。順番が逆だと元の値で上書きされる |
| キー名を変数で指定 | { ...prev, [key]: value }。入力欄のname属性をキーにして、1つのハンドラで複数の項目を更新できる |
| 項目削除 | 分割代入で取り除きたい項目だけ別の変数に受け、残りを ...rest で受けて返す |
| ネスト更新 | 変更する階層まで、外側から順にスプレッドでコピーする |
スプレッド構文は1階層だけコピーする
出力の「address は同じ参照=true」に注目してください。スプレッド構文がコピーするのは1階層目だけで、ネストしたオブジェクトは元と同じものを指したままです。変更しない部分を使い回せるので無駄がない一方、ネストの中を直接書き換えると元のstateにも影響します。
最後の「ネストを直接」のボタンは、user.address.city = "Nagoya" と書き換えてから { ...user } で外側だけコピーしています。外側が新しいので画面は更新されますが、出力のとおり1つ前のstateのcityも Nagoya に変わっています。 前後のstateを比べる処理(変更の検知、元に戻す機能、メモ化)が期待どおりに動かなくなるため、ネストした部分もコピーして更新します。
structuredCloneで丸ごとコピーする
階層が深いときは、structuredClone で全階層をコピーしてから書き換える方法もあります。
// a2/methods.ts(続き)
const user = { name: "Sato", address: { city: "Tokyo", zip: "100" }, tags: ["a"] };
const shallow = { ...user };
const deep = structuredClone(user);
console.log("spread: ", Object.is(user, shallow), Object.is(user.address, shallow.address));
console.log("structuredClone: ", Object.is(user, deep), Object.is(user.address, deep.address));
shallow.address.city = "Osaka";
console.log("spread 後に city を書き換え → 元:", user.address.city, "/ clone:", deep.address.city);
try { structuredClone({ onClick: () => {} }); } catch (e) { console.log(`${(e as Error).name}: ${(e as Error).message}`); }
class Task { title: string; constructor(title: string) { this.title = title; } done() { return true; } }
const t = structuredClone(new Task("x"));
console.log("クラスのインスタンス:", t instanceof Task, JSON.stringify(t), typeof (t as Task).done);
const d = structuredClone({ at: new Date(0), s: new Set([1]), m: new Map([["k", 1]]) });
console.log("Date/Set/Map:", d.at instanceof Date, d.s instanceof Set, d.m instanceof Map);
const j = JSON.parse(JSON.stringify({ at: new Date(0), u: undefined, s: new Set([1]) }));
console.log("JSON 経由:", JSON.stringify(j));
spread: false true
structuredClone: false false
spread 後に city を書き換え → 元: Osaka / clone: Tokyo
DataCloneError: () => {} could not be cloned.
クラスのインスタンス: false {"title":"x"} undefined
Date/Set/Map: true true true
JSON 経由: {"at":"1970-01-01T00:00:00.000Z","s":{}}
| コピーの方法 | ネストの扱い | Date・Set・Map | 関数を含む値 | クラスのインスタンス |
|---|---|---|---|---|
| スプレッド構文 | 1階層目だけコピー(中は共有) | 共有 | 共有 | メソッドがなくなる |
structuredClone |
全階層をコピー | 型を保ってコピー | DataCloneError になる |
メソッドがなくなる |
JSON.parse(JSON.stringify(x)) |
全階層をコピー | Dateは文字列、Setは空オブジェクトになる | 項目ごと消える | メソッドがなくなる |
structuredClone は全階層を新しく作るため、変更していない部分も別の参照になります。部分的な更新はスプレッド構文、フォームの「編集用の下書き」を作るような丸ごとのコピーには structuredClone と使い分けると、それぞれの持ち味を生かせます。
配列内オブジェクトの更新とImmerを比べる
タスク一覧のように「オブジェクトの配列」を持ち、その1件の項目を変える場面です。map で書く方法と、Immerの produce で書く方法を同じデータで比べます。
test("4. 配列内オブジェクトの更新: map と Immer", () => {
type Todo = { id: number; title: string; done: boolean; tags: string[] };
const initial: Todo[] = [
{ id: 1, title: "買い物", done: false, tags: ["家"] },
{ id: 2, title: "掃除", done: false, tags: ["家"] },
{ id: 3, title: "読書", done: false, tags: [] },
];
const byMap = initial.map((t) => (t.id === 2 ? { ...t, done: true, tags: [...t.tags, "週末"] } : t));
const byImmer = produce(initial, (draft) => {
const t = draft.find((t) => t.id === 2);
if (t) { t.done = true; t.tags.push("週末"); }
});
log.push(`map: ${JSON.stringify(byMap[1])}`);
log.push(`immer: ${JSON.stringify(byImmer[1])}`);
log.push(`元の配列は変わらない: ${JSON.stringify(initial[1])}`);
for (const [name, next] of [["map", byMap], ["immer", byImmer]] as const) {
log.push(`${name}: 配列は新しい参照=${!Object.is(initial, next)} id=2 は新しい参照=${!Object.is(initial[1], next[1])} id=1 は同じ参照=${Object.is(initial[0], next[0])}`);
}
log.push(`immer の結果は凍結されている: ${Object.isFrozen(byImmer)} / ${Object.isFrozen(byImmer[1])}`);
try { (byImmer as Todo[]).push({ id: 4, title: "x", done: false, tags: [] }); } catch (e) { log.push(`${(e as Error).name}: ${(e as Error).message}`); }
const same = produce(initial, (draft) => { draft[0].done = false; });
log.push(`値が変わらない更新: Object.is(元, 結果)=${Object.is(initial, same)}`);
out("immer");
実行結果:
--- immer
map: {"id":2,"title":"掃除","done":true,"tags":["家","週末"]}
immer: {"id":2,"title":"掃除","done":true,"tags":["家","週末"]}
元の配列は変わらない: {"id":2,"title":"掃除","done":false,"tags":["家"]}
map: 配列は新しい参照=true id=2 は新しい参照=true id=1 は同じ参照=true
immer: 配列は新しい参照=true id=2 は新しい参照=true id=1 は同じ参照=true
immer の結果は凍結されている: true / true
TypeError: Cannot add property 3, object is not extensible
値が変わらない更新: Object.is(元, 結果)=true
- 結果はmapとImmerで同じです。配列とid=2のオブジェクトは新しくなり、変更していないid=1は同じ参照のまま使い回されています。
- Immerの
draftにはt.done = trueやt.tags.push()と、書き換える形でそのまま書けます。Immerが書き換えを記録し、変更のあった部分だけをコピーした新しいデータを返します。 - Immerが返したデータは凍結されているので、あとから
pushするとTypeErrorになり、書き換えに気づけます。 - 値が変わらない更新(
falseをfalseにする)では元と同じものが返るので、再レンダリングも起きません。
useImmerでstateとして使う
use-immer の useImmer は、useStateと同じ形で使えて、更新関数の中で draft を書き換えられます(同じテストの続きです)。
let renders = 0;
function App() {
const [todos, updateTodos] = useImmer(initial);
renders++;
return (
<>
<ul>{todos.map((t) => <li key={t.id}>{t.title}:{t.done ? "済" : "未"}</li>)}</ul>
<button onClick={() => updateTodos((draft) => { draft[1].done = true; })}>完了</button>
<button onClick={() => updateTodos((draft) => { draft.push({ id: 4, title: "散歩", done: false, tags: [] }); })}>追加</button>
<button onClick={() => updateTodos((draft) => { draft.splice(0, 1); })}>先頭を削除</button>
</>
);
}
render(<App />);
const view = () => [...document.querySelectorAll("li")].map((li) => li.textContent).join(" / ");
log.push(`初回: render回数=${renders} ${view()}`);
for (const name of ["完了", "追加", "先頭を削除"]) { fireEvent.click(screen.getByText(name)); log.push(`${name}: render回数=${renders} ${view()}`); }
out("useImmer");
});
実行結果:
--- useImmer
初回: render回数=1 買い物:未 / 掃除:未 / 読書:未
完了: render回数=2 買い物:未 / 掃除:済 / 読書:未
追加: render回数=3 買い物:未 / 掃除:済 / 読書:未 / 散歩:未
先頭を削除: render回数=4 掃除:済 / 読書:未 / 散歩:未
draft.push や draft.splice を使っても、クリックのたびに描画回数が増え、画面が更新されています。
mapとImmerのどちらを選ぶか
| 観点 | スプレッド構文・map | Immer(useImmer) |
|---|---|---|
| 追加の導入 | 不要 | npm i immer use-immer |
| 1〜2階層の更新 | 短く書ける | 短く書ける |
| 3階層以上の更新 | スプレッドが階層の数だけ重なる | draft.a.b.c = x と1行で書ける |
| 書き換えへの備え | readonly の型で見つける |
結果が凍結され、実行時にエラーで気づける |
| 向いている場面 | 配列やオブジェクトが浅い、依存を増やしたくない | ネストが深い、更新の種類が多い |
ネストが2階層までならスプレッド構文とmap、3階層以上が何か所も出てくるならImmer、を目安にすると選びやすくなります。stateの形を浅くしておく(ネストを減らす)のも、更新のコードを短くする有効な方法です。
連続して更新するときは関数で渡す
1つのハンドラの中でsetを2回呼ぶ場合、値を直接渡す書き方と、関数を渡す書き方で結果が変わります。
test("5. 連続更新", () => {
function App() {
const [items, setItems] = useState([1]);
return (
<>
<p data-testid="v">{items.join(",")}</p>
<button onClick={() => { setItems([...items, 2]); setItems([...items, 3]); }}>値で2回</button>
<button onClick={() => { setItems((p) => [...p, 2]); setItems((p) => [...p, 3]); }}>関数で2回</button>
</>
);
}
render(<App />);
fireEvent.click(screen.getByText("値で2回"));
log.push(`値で2回: ${screen.getByTestId("v").textContent}`);
fireEvent.click(screen.getByText("関数で2回"));
log.push(`関数で2回: ${screen.getByTestId("v").textContent}`);
out("batch");
});
実行結果:
--- batch
値で2回: 1,3
関数で2回: 1,3,2,3
「値で2回」は、どちらの行も同じ items([1])をもとに計算するため、あとの [1, 3] だけが残ります。「関数で2回」は、1回目の結果を受けて2回目が計算されるので、2 と 3 の両方が追加されています。前のstateをもとに次のstateを作るときは setItems(prev => ...) の形にしておくと、連続した更新でも抜けがありません。早見表をすべてこの形で書いているのは、そのためです。
画面が更新されないときのチェックリスト
| 確認すること | 見るポイント | 直し方 |
|---|---|---|
| 元の配列を書き換えるメソッドを使っていないか | push、sort、splice、reverse、添字への代入 |
早見表の「使ってよい書き方」に置き換える |
sort() や reverse() の戻り値をそのまま渡していないか |
setItems(items.sort()) |
setItems(items.toSorted()) |
push の戻り値を渡していないか |
setItems(items.push(x)) |
setItems([...items, x]) |
| ネストの中だけ書き換えていないか | user.address.city = ... |
変更する階層までスプレッドでコピー |
| 1つのハンドラでsetを複数回呼んでいないか | 同じ items をもとに2回計算 |
setItems(prev => ...) の形にする |
| 更新直後にstateを読んでいないか | setの次の行で items を表示 |
次の描画まで値は変わらない。計算した値を変数に取って使う |
よくある質問
Q. pushしてからsetに渡したのに、なぜ画面が変わらないのですか?
A. push は元の配列そのものに要素を足すため、setに渡す配列は前のstateと同じ参照です。Reactは Object.is で前後を比べて同じなら再レンダリングを省くので、画面はそのままになります。setItems(prev => [...prev, x]) のように新しい配列を渡すと更新されます。
Q. toSortedやwithはどの環境で使えますか?
A. ES2023で追加されたメソッドです。この記事ではNode.js 22.22.2で動かしました。TypeScriptでは target または lib をES2023以上にします。対象の環境で使えない場合は、[...items].sort() のように先にコピーしてから従来のメソッドを使えば同じ結果になります。
Q. オブジェクトの一部だけ更新したら、ほかの項目が消えました。
A. setUser({ age: 21 }) のように変える項目だけを渡すと、stateがそのオブジェクトに置き換わります。useStateのsetは自動で合成しないので、setUser(prev => ({ ...prev, age: 21 })) と、先にスプレッドで全項目を展開してから上書きします。
Q. Immerを入れるべきか迷っています。
A. ネストが浅ければ、スプレッド構文と map・filter で十分読みやすく書けます。3階層以上の更新が何か所もあってスプレッドが重なる場合は、Immerで見通しがよくなります。どちらも結果(新しい参照が作られ、変えていない部分は使い回される)は同じです。
まとめ
- stateの配列・オブジェクトは書き換えず、新しいものを作ってsetに渡す。 Reactは
Object.isで前後を比べている。 - 追加はスプレッド構文、削除は
filter、置換はmap。位置で扱うならtoSplicedとwith。 - 並べ替えは
toSorted、反転はtoReversed。sortとreverseは元の配列を書き換え、同じ配列を返す。 - オブジェクトは
{ ...prev, 項目: 値 }。ネストは変更する階層までスプレッドを重ねる。 - スプレッド構文のコピーは1階層だけ。丸ごとコピーしたいときは
structuredClone。 - 配列内オブジェクトは
mapと{ ...t, 項目: 値 }。ネストが深ければImmerで同じ結果を短く書ける。 - 型を
readonlyにすると、書き換えるメソッドをTypeScriptが教えてくれる。
参考
- React公式ドキュメント Updating Arrays in State(https://react.dev/learn/updating-arrays-in-state)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React useStateの配列・オブジェクト更新早見表 追加・削除・変更」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。