3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React + TypeScript: 状態の構造を選択する

Last updated at Posted at 2023-04-17

React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、基本解説の「Choosing the State Structure」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。

なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。

コンポーネントの変更やデバッグがしやすいか、逆にバグに悩まされるかという差につながるのが、状態を適切に構造化するかどうかです。状態を構造化するときに考えるべき点についてご紹介します。

状態を構造化するときの原則

状態の備わったコンポーネントをつくる場合、用いる状態変数の数とどのような形式のデータにすべきか決めなければなりません。最適とはいえない状態の構造であっても、正しく動くプログラムは書けます。けれど、よりよい選択をするための原則はつぎのとおりです。

  • 関連する状態はグループ化しましょう。複数の状態変数をいつも同時に更新する場合は、ひとつの変数にまとめた方がよいかもしれません。
  • 状態が矛盾するのを避けることです。状態の一部が矛盾し、一致しないような構造にすると、誤りを招きやすくなります。矛盾は避けてください。
  • 状態が冗長にならないようにします。レンダリング中に、コンポーネントのプロパティや他の状態変数から情報を算出できる場合です。その情報はコンポーネントの状態変数に加えるべきではありません。
  • 状態の重複は避けましょう。状態が他の変数や入れ子のオブジェクトと重複すると、同期を保つことは難しくなります。重複はできるかぎり減らしてください。
  • 状態を深い入れ子にしません。状態の階層を深くすると、更新はしにくくなります。状態はできるだけフラットに構造化しましょう。

これらの原則が目指すのは、間違いは起こさずに状態を簡単に更新できるようにすることです。状態からデータの冗長さや重複を除けば、それらすべての同期が確実に保てるようになるでしょう。エンジニアがデータベース構造の「正規化」により、バグを減らそうとするのに似ているかもしれません。アルバート・アインシュタインの言葉になぞらえるなら、「状態は極力シンプルにすべきだが、シンプルすぎてもいけない」ということです。

これらの原則について、順に見ていきましょう。

関連する状態はグループ化する

ふたつの状態とすべき値があったとき、それぞれを変数にすることは別に問題ありません。

const [x, setX] = useState(0);
const [y, setY] = useState(0);

けれど、ふたつの値がつねに一緒に変わる場合、ひとつの状態変数にまとめることを考えましょう。つぎのコードの状態変数positionがもつプロパティxyは、ひとつの要素のxy座標です(サンプル001)。状態変数がひとつなら、ふたつの値を同期することに心配は要りません。

src/App.tsx
export default function MovingDot() {
	const [position, setPosition] = useState({
		x: 0,
		y: 0
	});
	const handlePointerMove: PointerEventHandler = ({
		clientX: x,
		clientY: y
	}) => {
		setPosition({ x, y });
	};

	return (
		<div onPointerMove={handlePointerMove} style={style}>
				<Dot position={position} />
		</div>
	);
}

サンプル001■React + TypeScript: Updating Objects in State 01

状態変数のオブジェクトのプロパティひとつだけ変えたいとき、とくにプロパティの数が多い場合には、スプレッド構文...を使うとよいでしょう。

状態が矛盾するのを避ける

ホテルに感想を送るフォームについて考えてみます。フォームを送信中(isSending)か、送信済み(isSent)かブール値の状態がわからなければなりません。それぞれを状態変数に定めても、動くコードは書けます。けれど、ふたつの値がともにtrueになることはありません。矛盾が生じないようにするには、ふたつをつねに注意深く設定しなければならないのです。コンポーネントが複雑になると、コードはさらにわかりにくくなるでしょう。

値がともにtrueになる矛盾を避けるには、状態変数はまとめてしまいます。そのうえで、ひとつだけ取りうる値を選べばよいのです。つぎのコード例では、状態変数(status)の値を'typing'(初期値)、'sending''sent'のいずれかとしました(サンプル002)。

src/App.tsx
const sendMessage = (text: string) => {
	// ネットワーク接続のシミュレーション
};
export default function FeedbackForm() {
	const [text, setText] = useState('');
	const [status, setStatus] = useState('typing');
	const handleSubmit: FormEventHandler = async (event) => {
		event.preventDefault();
		setStatus('sending');
		await sendMessage(text);
		setStatus('sent');
	};

	return (
		<form onSubmit={handleSubmit}>

			<textarea

				value={text}
				onChange={({ target: { value } }) => setText(value)}
			/>

		</form>
	);
}

送信中(isSending)か送信済み(isSent)かは、状態変数(status)から算出できます。ふたつはローカル変数ですので、同期の心配は要りません。

src/App.tsx
export default function FeedbackForm() {

	const isSending = status === 'sending';
	const isSent = status === 'sent';
	if (isSent) {
		return <h1>Thanks for feedback!</h1>;
	}
	return (
		<form onSubmit={handleSubmit}>

			<textarea
				disabled={isSending}

			/>

			<button disabled={isSending} type="submit">
				Send
			</button>
			{isSending && <p>Sending...</p>}
		</form>
	);
}

サンプル002■React + TypeScript: Choosing the State Structure 02

状態が冗長にならないようにする

情報が、レンダリング中にコンポーネントのプロパティやすでにある状態変数から算出できる場合、状態に加えるのは避けるべきです。たとえば、つぎのような3つの状態変数を定めようとしたとします。けれど、氏名(fullName)は、姓(lastName)と名(firstName)からレンダー時に導けますfullName冗長であり、状態からは除くべきです

const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

さらに、firstNamelastNameはひとりの人の情報です。まとめてしまった方がよいでしょう。もっとも、テキストフィールドには、それぞれ別に入力します。更新はオブジェクトのスプレッド構文...を使うのが簡単です。さらに、[]演算子で計算プロパティ名を用いれば、onChangeイベントのハンドラ関数(handleNameChange)も共通化できます(「オブジェクトを複製する」参照)。

src/App.tsx
export default function Form() {
	const [name, setName] = useState({ firstName: '', lastName: '' });
	const fullName = name.firstName + ' ' + name.lastName;
	const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { name: _name, value }
	}) => {
		setName({ ...name, [_name]: value });
	};
	return (
		<>
			<h2>Let’s check you in</h2>
			<label>
				First name:{' '}
				<input
					name="firstName"
					value={name.firstName}
					onChange={handleNameChange}
				/>
			</label>
			<label>
				Last name:{' '}
				<input
					name="lastName"
					value={name.lastName}
					onChange={handleNameChange}
				/>
			</label>
			<p>
				Your ticket will be issued to: <b>{fullName}</b>
			</p>
		</>
	);
}

冗長な例として挙げられるのが、プロパティを状態に定めることです。

const Message = ({ messageColor: string }) => {
	const [color, setColor] = useState(messageColor);

};

この場合、プロパティ(messageColor)は状態(color)の初期値を定めるだけです。つまり、親コンポーネントが渡すプロパティ値を改めても、状態変数は変わりません

プロパティをあえて初期値とするときのみ、状態変数に与えるのが適切です。その場合、プロパティ名はinitial(initialColor)またはdefault(defaultColor)ではじめる慣習にしたがうと、わかりやすいでしょう。

状態の重複は避ける

つぎのコード例は、選択(ボタンクリック)したスナックの名前を画面の下に表示します。スナックのリストは、オブジェクトを配列要素に収めた状態変数(items)です。そして、選ばれたスナックのオブジェクトを、もうひとつの状態変数(selectedItem)に定めました。けれど、このオブジェクトがリストの配列要素のひとつと重複していることは問題です。

src/App.tsx
import { ChangeEvent, useState } from 'react';

const initialItems = [
	{ title: 'pretzels', id: 0 },
	{ title: 'crispy seaweed', id: 1 },
	{ title: 'granola bar', id: 2 }
];
export default function Menu() {
	const [items, setItems] = useState(initialItems);
	const [selectedItem, setSelectedItem] = useState(items[0]);
	const handleItemChange = (
		id: number,
		{ target: { value } }: ChangeEvent<HTMLInputElement>
	) => {
		setItems(
			items.map((item) => {
				if (item.id === id) {
					return {
						...item,
						title: value
					};
				} else {
					return item;
				}
			})
		);
	};
	return (
		<>
			<h2>What's your travel snack?</h2>
			<ul>
				{items.map((item, index) => (
					<li key={item.id}>
						<input
							value={item.title}
							onChange={(event) => {
								handleItemChange(item.id, event);
							}}
						/>{' '}
						<button
							onClick={() => {
								setSelectedItem(item);
							}}
						>
							Choose
						</button>
					</li>
				))}
			</ul>
			<p>You picked {selectedItem.title}.</p>
		</>
	);
}

ボタンクリックで選択したスナック名が画面の下に表れます。問題は、テキストフィールドの入力を書き替えたときです。画面下のテキストは、もとのまま変わりません(サンプル003)。直接の原因は、onChangeイベントのハンドラ関数(handleItemChange)が選択されたオブジェクトの状態変数(selectedItem)を更新していないからです。けれど、同じオブジェクトを状態変数に重複してもたせると、このような見落としやバグが起こりやすくなります。

サンプル003■React + TypeScript: Choosing the State Structure 03

選択したオブジェクトを、重ねて状態にもたせることはありません。オブジェクトのリストの状態変数(items)から、配列要素を取り出す識別子(id)さえわかればよいのです(selectedId)。選択されたオブジェクト(selectedItem)は、識別子から算出できます(サンプル004)。

src/App.tsx
export default function Menu() {
	const [items, setItems] = useState(initialItems);
	// const [selectedItem, setSelectedItem] = useState(items[0]);
	const [selectedId, setSelectedId] = useState(0);
	const selectedItem = items.find((item) => item.id === selectedId);

	return (
		<>

			<ul>
				{items.map((item, index) => (
					<li key={item.id}>

						<button
							onClick={() => {
								// setSelectedItem(item);
								setSelectedId(item.id);
							}}
						>
							Choose
						</button>
					</li>
				))}
			</ul>
			<p>You picked {selectedItem?.title}.</p>
		</>
	);
}

サンプル004■React + TypeScript: Choosing the State Structure 04

onChangeハンドラから状態設定関数(setItems)を呼び出すのが、再レンダリングの起動です。選択されたオブジェクト(selectedItem)も、レンダー中に算出されます。状態間でオブジェクトが重複することなく、最小限の値(selectedId)をもたせるだけで済みました。

状態を深い入れ子にしない

宇宙も含めた場所をリストにするとしましょう。データの構造は入れ子です。

Places to visit

  1. Earth
    1. Africa
      1. Botswana
      2. ...[略]...
    2. Americas
      1. Argentina
      2. ...[略]...
    3. Asia
      ...[略]...
  2. Moon
    ...[略]...

すると、状態もそのままオブジェクトや配列の入れ子にしたくなるかもしれません。

type Place = {
	id: number;
	title: string;
	childPlaces: Place[];
};
const initialTravelPlan: Place = {
	id: 0,
	title: '(Root)',
	childPlaces: [
		{
			id: 1,
			title: 'Earth',
			childPlaces: [
				{
					id: 2,
					title: 'Africa',
					childPlaces: [
						{
							id: 3,
							title: 'Botswana',
							childPlaces: []
						}
						// ...[略]...
					]
				},
				{
					id: 4,
					title: 'Americas',
					childPlaces: [
						{
							id: 5,
							title: 'Argentina',
							childPlaces: []
						}
						// ...[略]...
					]
				},
				{
					id: 6,
					title: 'Asia',
					childPlaces: []
				}
			]
		},
		{
			id: 7,
			title: 'Moon',
			childPlaces: []
		}
	]
};

けれど、深い入れ子の状態を操作しようとすると、手間や問題が生じます。書き替える子オブジェクトまで階層を下ったうえで、複製して編集しなければなりません(「オブジェクトを複製する」参照)。

状態の階層化はできるかぎり浅く「フラット」にすべきです。React公式サイトのコード例は、データの構造をつぎのように組み立てています(「Avoid deeply nested state」参照)。オブジェクトは一階層です(「正規化」と呼ばれる考え方です)。プロパティ(childIds)の配列が子要素のIDをもちますので、コンポーネントの表示はその値から階層化できます。

export const initialTravelPlan = {
	0: {
		id: 0,
		title: '(Root)',
		childIds: [1, 7]
	},
	1: {
		id: 1,
		title: 'Earth',
		childIds: [2, 4, 6]
	},
	2: {
		id: 2,
		title: 'Africa',
		childIds: [3]
	},
	3: {
		id: 3,
		title: 'Botswana',
		childIds: []
	},
	// ...[略]...
	4: {
		id: 4,
		title: 'Americas',
		childIds: [5]
	},
	5: {
		id: 5,
		title: 'Argentina',
		childIds: []
	},
	// ...[略]...
	6: {
		id: 6,
		title: 'Asia',
		childIds: []
	},
	// ...[略]...
	7: {
		id: 7,
		title: 'Moon',
		childIds: []
	}
};

リストされた場所に削除ボタンを加えたのが、以下のサンプル005です。ボタンクリックのハンドラ関数(handleComplete)は、状態(plan)から場所のオブジェクトをつぎのように除きます。ただし、削除するのは親オブジェクトがもつ配列(childIds)のidです。

  1. 複製した親オブジェクトの配列(childIds)から、削除する場所(オブジェクト)のidを外します。
  2. 更新した親オブジェクトをもとの状態(plan)のデータ内で差し替えればよいでしょう。

サンプル005■React + TypeScript: Choosing the State Structure 05

サンプル005が状態(plan)の初期値として与えたモジュールsrc/places.tsのデータ(initialTravelPlan)に違和感をもったかもしれません。オブジェクトのプロパティがidと同じ整数です。それなら、配列にすればよいのではと思えます。けれど、状態の配列にインデックスを指定して代入するのは適切ではありません(「配列をイミュータブルに更新する」)。つまり、要素の上書きが難しいのです。

オブジェクトであれば、スプレッド構文...で展開して、重複する名前のプロパティをあとから加えれば上書きできます。とはいえ、オブジェクトに数値のプロパティ名を与えることはお勧めできません。TypeScriptからは警告されるでしょう。

Immerを使えば、状態の配列が直に操作できますし、メモリも抑えられます(「配列の状態をImmerでイミュータブルに更新する」参照)。Immerを用い、状態の初期値(initialTravelPlan)は配列にしたのがつぎのサンプル006です。

サンプル006■React + TypeScript: Choosing the State Structure 06

モジュールsrc/places.tsに定めた状態(plan)の初期値(initialTravelPlan)はフラットな配列にしました。それぞれのオブジェクトは、プロパティidで識別します。

src/places.ts
export type Place = {
	id: number;
	title: string;
	childIds: number[];
};
export const initialTravelPlan: Place[] = [
	{ id: 0, title: '(Root)', childIds: [1, 7] },
	{
		id: 1,
		title: 'Earth',
		childIds: [2, 4, 6]
	},
	{
		id: 2,
		title: 'Africa',
		childIds: [3]
	},
	{
		id: 3,
		title: 'Botswana',
		childIds: []
	},
	// ...[略]...
	{
		id: 4,
		title: 'Americas',
		childIds: [5]
	},
	{
		id: 5,
		title: 'Argentina',
		childIds: []
	},
	// ...[略]...
	{
		id: 6,
		title: 'Asia',
		childIds: []
	},
	// ...[略]...
	{
		id: 7,
		title: 'Moon',
		childIds: []
	},
];

ボタンクリックで、画面に表示したリストから場所の項目を消すのが、ルートモジュール(src/App.tsx)に定めた以下のハンドラ関数(handleComplete)です。状態変数(plan)に収めたオブジェクトのプロパティ(childIds)から配列要素のidを除くのは、前掲サンプル005と変わりません。サンプル006は、さらに状態の配列から場所のオブジェクトそのものも削除しました。

その処理を担うのが、イベントハンドラ(handleComplete)の中で再帰的に呼び出している関数deleteAllChildrenです。プロパティchildIdsの配列に収められた子要素のidを順に確かめて、おおもとの状態(draft)から取り除いています。Immerの構文はミュータブルなので、用いた配列のメソッドはArray.prototype.splice()です(「Array mutations」参照)。

src/App.tsx
export default function TravelPlan() {
	const [plan, updatePlan] = useImmer(initialTravelPlan);
	const handleComplete = (parentId: number, childId: number) => {
		updatePlan((draft: Draft<Place[]>) => {
		const parent = draft.find((place) => place.id === parentId);
		if (!parent) return;
		parent.childIds = parent.childIds.filter((id: number) => id !== childId);
		const deleteAllChildren = (id: number) => {
			const place = draft.find((place) => place.id === id);
			if (!place) return;
			place.childIds.forEach(deleteAllChildren);
			const index = draft.findIndex((place) => place.id === id);
			if (index > -1) {
				draft.splice(index, 1);
			}
		};
		deleteAllChildren(childId);
		});
	};
	
}

まとめ

この記事では、つぎのような項目についてご説明しました。

  • ふたつの状態変数をつねに一緒に更新する場合は、ひとつにまとめた方がよいかもしれません。
  • 発生しない状態が生じないように、変数はよく考えて選んでください。
  • 状態の構造化は、更新を間違えることのないように行いましょう。
  • 冗長で重複した状態は避けると、同期を保つ心配が要りません。
  • あえて更新しない場合を除いて、プロパティは状態に設定しないでください。
  • 選択などのUIのパターンでは、状態に保持するのはオブジェクトそのものではなく、そのインデックス(ID)にします。
  • 深い入れ子にした状態の更新が複雑になったら、フラット化を考えましょう。
3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?