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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?