React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、基本解説の「Updating Objects in State」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。
なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。
Reactの状態に保持できるのは、オブジェクトを含めたすべてのJavaScriptの値です。ただし、状態に持たせたオブジェクトを直接変更してはいけません。更新したいときは、まず新たなオブジェクト(またはもとオブジェクトの複製)をつくって編集してください。そのうえで、状態はそのオブジェクトを用いて設定するのです。
イミュータブル(immutable)とは
まず、JavaScriptでは、すべてのプリミティブ値はイミュータブル(immutable)、つまり書き替えられません。変数に別のプリミティブ値を代入しても、もとのプリミティブはそのまま変わらず、値の差し替えに過ぎないのです。
すべてのプリミティブ値は、イミュータブル(immutable) 、つまり変更できません。変数には新しい値を再割り当てすることができますが、既存の値については、オブジェクト、配列、関数が変更できるのに対して、プリミティブ値は変更することができません。この言語では、プリミティブな値を変更するユーティリティは提供されていません。
「Primitive (プリミティブ)」
それに対して、JavaScriptのオブジェクトは変更できます(ミュータブルといいます)。けれど、Reactではプリミティブもオブジェクトも状態変数に定めて、いわば読み取り専用(イミュータブル)として扱います。そして、値を直に変更するのでなく、別の値に置き替えるのが状態設定関数です。
つぎのコード例では、オブジェクトを状態変数(position
)に定めました。値の更新は状態変数に直接触れることなく、設定関数(setPosition
)で差し替えるのです(サンプル001)。そうすることで、再レンダーも起動します(「状態の設定によるレンダーの起動」参照)。
import { CSSProperties, PointerEventHandler, useState } from 'react';
import { Dot } from './Dot';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0
});
const handlePointerMove: PointerEventHandler = ({
clientX: x,
clientY: y
}) => {
setPosition({ x, y });
};
const style: CSSProperties = {
position: 'relative',
width: '100vw',
height: '100vh'
};
return (
<div onPointerMove={handlePointerMove} style={style}>
<Dot position={position} />
</div>
);
}
import { CSSProperties } from 'react';
import { FC } from 'react';
type Props = {
position: { x: number; y: number };
};
export const Dot: FC<Props> = ({ position: { x, y } }) => {
const style: CSSProperties = {
position: 'absolute',
backgroundColor: 'deeppink',
borderRadius: '50%',
transform: `translate(${x}px, ${y}px)`,
left: -10,
top: -10,
width: 20,
height: 20
};
return <div style={style} />;
};
サンプル001■React + TypeScript: Updating Objects in State 01
関数のスコープ内でつくったオブジェクトであれば、変更しても構いません。たとえば、前掲コード例のハンドラ関数(handlePointerMove
)はつぎのように書き替えても実質は同じです。
const handlePointerMove: PointerEventHandler = ({
clientX: x,
clientY: y
}) => {
const nextPosition = { x: 0, y: 0 };
nextPosition.x = x;
nextPosition.y = y;
// setPosition({ x, y });
setPosition(nextPosition);
};
純粋な関数は、スコープ外のすでにある変数やオブジェクトを書き替えてはいけません(「コンポーネントを純粋に保つ(Keeping Components Pure)」参照)。スコープ内でつくったオブジェクトであれば、他のコードから参照できないでしょう。つまり、変更しても他に影響は及ぼさないということです。「ローカルミューテーション」と呼ばれ、レンダリング中に実行しても問題はありません。
オブジェクトを複製する
前掲コード例では、つねに新たな値でオブジェクトをつくり、状態に設定しました。けれど、オブジェクトの一部のプロパティだけ変えたい場合もあります。すると、残りのプロパティはもとのオブジェクトからコピーして使うということです。
つぎのコード例は、イベントハンドラ(handleNameChange
)が変更すべきプロパティ(name
)に新たな値を与え、他のプロパティ(artwork
)はもとのまま複製して設定しています。
import { ChangeEventHandler, useState } from 'react';
import { Input } from './Input';
export default function Form() {
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg'
}
});
const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setPerson({
artwork: {
title: person.artwork.title,
city: person.artwork.city,
image: person.artwork.image
},
name: value
});
};
return (
<>
<Input name="name" onChange={handleNameChange} value={person.name} />
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img src={person.artwork.image} alt={person.artwork.title} />
</>
);
}
このコードにまったく問題はありません。けれど、プロパティの数が増えると、煩わしい記述になるでしょう。つぎのようにオブジェクトのスプレッド構文...
を使えば複製ができます。そのうえで、新たなプロパティ(name
)だけ上書きしてしまえばよいのです。
const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setPerson({
artwork: {
/* title: person.artwork.title,
city: person.artwork.city,
image: person.artwork.image */
...person.artwork
},
name: value
});
};
状態変数person
のプロパティartwork
に収めた値も、それぞれテキスト入力フィールド(Input
コンポーネント)で書き替えられるようにしましょう。そのとき、onChange
イベントハンドラ関数本体のコードはほとんど同じになりまます。違うのは、変更するプロパティ名だけです。[]
演算子で計算プロパティ名を用いれば、プロパティ名は変数にしてしまえます。つまり、ひとつのハンドラ関数で名前の異なる複数のプロパティが扱えるのです(サンプル002)。
import { ChangeEventHandler, useState } from 'react';
import { Input } from './Input';
export default function Form() {
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg'
}
});
const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setPerson({
...person,
name: value
});
};
const handleArtworkChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value, name }
}) => {
setPerson({
...person,
artwork: {
...person.artwork,
[name]: value
}
});
};
return (
<>
<Input name="name" onChange={handleNameChange} value={person.name} />
<Input
name="title"
onChange={handleArtworkChange}
value={person.artwork.title}
/>
<Input
name="city"
onChange={handleArtworkChange}
value={person.artwork.city}
/>
<Input
name="image"
onChange={handleArtworkChange}
value={person.artwork.image}
/>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img src={person.artwork.image} alt={person.artwork.title} />
</>
);
}
サンプル002■React + TypeScript: Updating Objects in State 02
オブジェクトをイミュータブルに保つ ー Immerを使う
オブジェクトをイミュータブルに保つとき、注意しなければならないのが入れ子のオブジェクトです。スプレッド構文...
は(あるいはObject.assign()
メソッドも)、オブジェクトの第1階層のプロパティしか複製しません。それより深い入れ子のオブジェクトは参照が渡されます。
const artwork = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg'
};
const perton1 = { name: 'Niki de Saint Phalle', artwork: artwork };
const perton2 = { ...perton1, name: 'Copycat' };
artwork.city = 'Tokyo';
console.log(perton1.artwork.city, perton2.artwork.city); // Tokyo Tokyo
状態のオブジェクトの入れ子はできるだけ避けるというのが、ひとつの手です(「Avoid deeply nested state」参照)。けれど、状態のデータを構造化したいことはあるでしょう。
ライブラリImmerを使えば、データ構造はイミュータブルに保てます。Reactの状態への参照が変わっていなければ、オブジェクトもそのまま変更ありません。さらに、複製のコストは比較的低いです。データツリーの中で変更されていない部分は複製されることなく、以前の状態とメモリ上共有されます(「React + TypeScript: Immerで状態をイミュータブルに保つ」参照)。
用いるのはuseImmer
フックです(「フックuseImmerを使う」参照)。イミュータブルな(変更しない)状態からつくったミュータブルな(変更できる)状態に手を加えます。構文は、useState
フックとほぼ変わりません。戻り値の配列の第1要素が現在の状態(オブジェクト)、第2要素は設定関数です。フックの引数には状態の初期値を与えてください。
状態設定関数に渡すのは、コールバックです。コールバック関数は、ミュータブルに変換されたオブジェクトを引数(draft
)として受け取ります。したがって、このオブジェクトはどのように書き替えても構いません。もとのイミュータブルな状態(オブジェクト)は、別にそのまま保たれるのです。
// import { ChangeEventHandler, useState } from 'react';
import { ChangeEventHandler } from 'react';
import { useImmer } from 'use-immer';
export default function Form() {
// const [person, setPerson] = useState({
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
// ...[略]...
});
const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
/* setPerson({
...person,
name: value
}); */
updatePerson((draft) => {
draft.name = value;
});
};
}
userImmer
で書き改めたモジュールsrc/App.tsx
のコード全体をつぎに掲げました。動作については、以下のサンプル003でお確かめください。
import { ChangeEventHandler } from 'react';
import { useImmer } from 'use-immer';
import { Input } from './Input';
import './styles.css';
export default function Form() {
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg'
}
});
const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
updatePerson((draft) => {
draft.name = value;
});
};
const handleArtworkChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value, name }
}) => {
updatePerson((draft) => {
draft.artwork = { ...draft.artwork, [name]: value };
});
};
return (
<>
<Input name="name" onChange={handleNameChange} value={person.name} />
<Input
name="title"
onChange={handleArtworkChange}
value={person.artwork.title}
/>
<Input
name="city"
onChange={handleArtworkChange}
value={person.artwork.city}
/>
<Input
name="image"
onChange={handleArtworkChange}
value={person.artwork.image}
/>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img src={person.artwork.image} alt={person.artwork.title} />
</>
);
}
サンプル003■React + TypeScript: Updating Objects in State 03
なぜReactで状態をイミュータブルに保った方がよいのか
Reactで状態をイミュータブルに保つべき理由はつぎのような点です。とくに、開発中の新機能を使うには、状態の変更は控えてください。
-
デバッグ:
console.log()
を使い、状態は直接触れないようにすれば、過去のログが直近の変更で上書きされることはありません。レンダー間で状態がどう変わったか、はっきりと確かめられます。 -
最適化: Reactの一般的な最適化の仕方は、つぎのプロパティや状態がつぎと変わっていなければ、処理を省くことです。状態を変更していない場合、変わっていないことはきわめて高速に確認できます。
prevObj === obj
で内部に何も変化のないことがわかるからです。 - 新機能: 現在構築されている新しいReactの機能は、状態がスナップショットのように扱われることを前提としています。状態の古いバージョンを変更してしまうと、新機能は使えないかもしれません。
- 要件の変更: アプリケーションの機能によっては、イミュータブルによって実装しやすくなりまます。元に戻す/やり直し、変更履歴の表示、ユーザーがフォームを以前の値にリセットできるようにするなどです。変更がなければ、状態の過去のコピーをメモリに保持して、必要に応じて再利用できます。ミュータブルなやり方では、こうした機能をあとから加えることは難しくなるかもしれません。
- 実装のシンプル化: Reactはミューテーションに依存しないので、オブジェクトには特別なことをしなくて済みます。プロパティを奪ったり、つねにプロキシに包んだり、初期化時に余計な作業は必要ありません。Reactは大きなオブジェクトでも、パフォーマンスや正確性を損なうことなく状態として扱えるのです。
まとめ
この記事では、つぎのような項目についてご説明しました。
- Reactの状態はすべてイミュータブルとして扱ってください。
- オブジェクトを状態に保持すると、直に変更してもレンダーは起動しません。そして、前にレンダリングした「スナップショット」の状態が変わります。
- オブジェクトは書き替えずに、新たなバージョンをつくってください。そのオブジェクトを状態に設定すれば、再レンダーが起動します。
- オブジェクトの複製に使えるのが、スプレッド構文
...
です。 - スプレッド構文がつくるのは、オブジェクトの第一階層だけの浅い複製であることにご注意ください。
- 入れ子のオブジェクトを複製するには、必要な階層の子オブジェクトまで下ってコピーしなければなりません。
- データ構造をイミュータブルに保って変更するにはImmerが便利です。