【実務・中級編】 不変性を保った状態更新のテクニック – React実践ガイド

こんにちは。現場で日々、複雑なUIと格闘している中級エンジニアの皆さん、調子はどうかな?

「よし、APIから取得したユーザーデータの権限を書き換えて、画面に反映させよう!」
そう意気込んで `user.role = ‘admin’; setUser(user);` なんてコードを書いた瞬間、画面が微動だにせず頭を抱えた経験、一度や二度ではないはずだ。あるいは、Todoリストの配列に `push` メソッドを使ってしまい、なぜか再描画が走らずに絶望した夜もあるだろう。

安心しなさい。君たちが通ってきたその道は、優れたフロントエンドエンジニアになるための通過儀礼だ。

今日は、Reactにおける状態管理の肝、「不変性(Immutability)を保った状態更新のテクニック」について、ブラウザの裏側の動きも含めて徹底的に解説しよう。ここをマスターすれば、あの「レンダリングされない幽霊バグ」とは永遠におさらばできる。実務ですぐに使える実践的な知見を授けよう。

—

なぜReactは「直接書き換え(ミューテーション)」を嫌うのか?

まず、Reactがなぜこれほどまでに不変性にこだわるのか、その理由を腹落ちさせよう。

Reactの本質は `UI = f(state)` という関数型パラダイムにある。つまり、状態(state)という入力値が変われば、必ず新しいUI(仮想DOMのツリー)という出力値が返されなければならない。

もし君がオブジェクトや配列を直接書き換えた(ミューテートした)場合、何が起きるか?
JavaScriptのオブジェクトは参照型だ。メモリ上の同じアドレスを指したまま中身だけが書き換わる。Reactが `useState` の前後の値を比較(`Object.is` による浅い比較:Shallow Comparison)したとき、「おっ、参照先の住所は一緒だから、中身も同じだな!再描画はスキップしようぜ!」 と勘違いしてしまうのだ。

これが、画面が更新されないバグの正体。ブラウザのメモリ上ではデータが書き換わっているのに、Reactの検知網をすり抜けてしまう。だからこそ、私たちは「中身を書き換えるな、新しいものを生み出せ」という鉄則を守る必要がある。

—

1. スプレッド構文(`…`)で新世界を作る基本テクニック

まずは外部ライブラリに頼らず、JavaScriptの標準機能であるスプレッド構文(Spread Syntax)を使った堅牢な更新方法を見ていこう。

実務でよくある「ユーザーのプロフィール設定画面」を想定してほしい。

import React, { useState } from ‘react’;

export const UserProfile = () => {
const [user, setUser] = useState({
name: ‘Taro’,
settings: {
theme: ‘dark’,
notifications: true,
},
});

const handleNameChange = (newName) => {
// 【NG】user.name = newName; setUser(user); (参照が変わらないため再描画されない)

// 【OK】スプレッド構文で新しいオブジェクトを生成する
setUser((prevUser) => ({
…prevUser, // 既存のプロパティを展開
name: newName, // 変更したい部分だけ上書き
}));
};

const handleThemeChange = (newTheme) => {
// ネストしたオブジェクトの不変性維持(ここがよくある罠!)
setUser((prevUser) => ({
…prevUser,
settings: {
…prevUser.settings, // 内側のオブジェクトも必ず展開する!
theme: newTheme,
},
}));
};

return (

名前: {user.name}

テーマ: {user.settings.theme}


);
};

シニアからのワンポイント・アドバイス

ネストが深くなればなるほど、スプレッド構文の連打(`…prev, settings: { …prev.settings, profile: { … } }`)は地獄を見る。もし状態のネストが2階層以上あるなら、それは設計を見直すサインだ。状態をフラットにするか、次の「Immer」の導入を検討しよう。

—

2. 配列操作における不変性の黄金律

配列の更新も基本は同じだ。`push`, `pop`, `shift`, `unshift`, `splice` といった破壊的メソッドは、Reactの世界では「禁忌(きんき)」と心得よ。

配列操作におけるCRUDの対応表はこうだ:

  • 追加:`[…array, newItem]` (末尾)または `[newItem, …array]` (先頭)
  • 削除:`array.filter(item => item.id !== targetId)`
  • 更新:`array.map(item => item.id === targetId ? { …item, value: newValue } : item)`

実際にTodoリストで見てみよう。

import React, { useState } from ‘react’;

export const TodoApp = () => {
const [todos, setTodos] = useState([
{ id: 1, text: ‘Reactの勉強をする’, completed: false },
{ id: 2, text: ‘TypeScriptを書く’, completed: true },
]);

// 1. 追加(Add)
const addTodo = (text) => {
const newTodo = { id: Date.now(), text, completed: false };
setTodos((prevTodos) => […prevTodos, newTodo]);
};

// 2. 削除(Delete)
const removeTodo = (id) => {
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
};

// 3. 更新(Update – 完了状態の切り替え)
const toggleTodo = (id) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { …todo, completed: !todo.completed } : todo
)
);
};

return (

    {todos.map((todo) => (

  • toggleTodo(todo.id)} style={{ cursor: ‘pointer’ }}>
    {todo.text}

  • ))}

);
};

`map` や `filter` は新しい配列を返す(非破壊的メソッド)。Reactのstate管理において、これらは最強の相棒だ。必ず手に馴染ませておいてほしい。

—

3. 複雑な構造には「Immer」で立ち向かう

「スプレッド構文地獄」に陥ったとき、あるいは巨大なフォームの状態管理やツリー構造のデータを扱うとき、私たちの精神衛生を守ってくれるのが Immer だ。

Redux Toolkitでも内部で標準採用されているので、名前を聞いたことがある読者も多いだろう。
Immerを使うと、「まるで直接書き換えているかのような直感的なコード」を書きながら、裏側で安全に不変な新しいオブジェクトを生成して手渡してくれる。

まずはインストールから(実務ではすでにプロジェクトに入っていることも多いはずだ)。

npm install immer use-immer

Reactのカスタムフックである `useImmer` を使ったコードを見てみよう。

import React from ‘react’;
import { useImmer } from ‘use-immer’;

export const ComplexForm = () => {
// useImmerを使うことで、draftを直接いじれるようになる
const [formState, updateFormState] = useImmer({
user: {
name: ‘Taro’,
address: {
city: ‘Tokyo’,
zip: ‘100-0001’,
},
},
hobbies: [‘Reading’, ‘Coding’],
});

const handleCityChange = (newCity) => {
// 従来のスプレッド構文なら気が狂いそうなネストも、こう書ける!
updateFormState((draft) => {
draft.user.address.city = newCity; // 直接代入してOK!
});
};

const handleAddHobby = (newHobby) => {
updateFormState((draft) => {
draft.hobbies.push(newHobby); // pushを使ってもOK!Immerが裏でよしなにやってくれる
});
};

return (

都市: {formState.user.address.city}

趣味: {formState.hobbies.join(‘, ‘)}


);
};

なんてクリーンなんだ……!
Immerの内部では、Proxy(プロキシ)というJavaScriptのモダンな機能を使って変更を監視し、変更された部分だけをコピーして新しいオブジェクトを組み上げている。パフォーマンスも実用上十分すぎる速度が出るため、複雑なフォームや状態を持つコンポーネントでは積極的に採用して構わない。

—

結びにかえて:プロとしての心構え

状態の不変性を保つことは、単なる「Reactのお作法」ではない。
データを不変に保つことで、「いつ、どこで、何によって状態が変化したのか」の予測可能性が飛躍的に高まる。これは、Redux DevToolsのようなタイムトラベルデバッグを可能にするし、React.memoによる不要な再描画の最適化(メモ化)が正しく機能するための絶対条件でもある。

「動けばいいや」でミューテーションを放置するコードは、半年後の自分、そしてチームメンバーに爆弾を託すようなものだ。

今日からコードを書くときは、指先でこう唱えるといい。
「私は値を書き換えない。新しい歴史を創るのだ」と。

君たちのフロントエンドライフが、バグのない快適なものになることを応援している。さあ、エディタに戻ってコードを書こう!

コメント

タイトルとURLをコピーしました