LoginSignup
0
1

React + TypeScript: 状態の保持とリセット

Last updated at Posted at 2023-05-08

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

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

状態はコンポーネントごとに別々です。Reactはどの状態がどのコンポーネントに属するか、UIツリー内の位置にもとづいて把握します。そして、再レンダリング間で、状態をいつ保持し、いつリセットするかは制御できるのです。

UIツリー

ブラウザはさまざまなツリー構造によりUIをモデル化します。

  • DOM: Web文書(HTML)
  • CSSOM: CSS
  • AOM: アクセシビリティ

ReactがUIを管理し、モデル化するために用いるのもツリー構造です。

  1. ReactはUIツリーをJSXからつくります。
  2. ブラウザのDOM要素をUIツリーに合わせて更新するのがRreact DOMです。

(なお、React Nativeは、これらのツリーをモバイルプラットフォーム固有の要素に変換します。)

状態はツリー内の位置に紐づく

コンポーネントに与えた状態は、そのコンポーネントの中に存在すると思うかもしれません。けれど、実際に状態を保持するのはReactです。そして、Reactのもつそれぞれの状態は、コンポーネントがUIツリー内のどこにあるかに応じて正しく紐づけられます。

Reactでは、ひとつの画面に同じコンポーネントを並べたとしても、状態は別々です。同じ名前の状態変数に、それぞれの値をもちます。コンポーネントがツリーの中の異なる位置にレンダーされるからです。通常は、ツリー内の位置についてまで、細かく考える必要はありません。ただ、仕組みを知っておくことは有用です。

たとえば、つぎのコンポーネントCounterには、状態としてscorehoverが備わっています。それぞれカウンターの数値とコンポーネントにポインタが重なっているかどうかのブール値です。

src/Counter.tsx
import { useState } from 'react';
import type { FC } from 'react';

export const Counter: FC = () => {
	const [score, setScore] = useState(0);
	const [hover, setHover] = useState(false);
	const className = 'counter' + (hover ? ' hover' : '');
	return (
		<div
			className={className}
			onPointerEnter={() => setHover(true)}
			onPointerLeave={() => setHover(false)}
		>
			<h1>{score}</h1>
			<button onClick={() => setScore(score + 1)}>Add one</button>
		</div>
	);
};

ふたつのCounterを親コンポーネントAppに並べても、状態は別々で互いに影響しません。ボタンクリックでカウントアップするscoreの数値やポインタの重なりによりスタイルを変えるhoverの値は、それぞれのコンポーネントごとにもつのです(サンプル001)。

src/App.tsx
import { Counter } from './Counter';

export default function App() {
	return (
		<div>
			<Counter />
			<Counter />
		</div>
	);
}

サンプル001■React + TypeScript: Preserving and Resetting State 01

Reactは、同じコンポーネントが同じ位置にレンダリングされているかぎり、状態を保ちます。たとえば、親コンポーネントAppを書き替えて、ふたつめのCounterコンポーネントがチェックボックスで消せるようにしてみましょう。

両方のカウンターの数値を増やしてから、チェックボックスのオフでふたつめのカウンターは画面から除きます。それから、オンに戻してカウンターを再表示してください。

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

export default function App() {
	const [showB, setShowB] = useState(true);
	return (
		<div>

			{/* <Counter /> */}
			{showB && <Counter />}
			<label>
				<input
					type="checkbox"
					checked={showB}
					onChange={({ target: { checked } }) => {
						setShowB(checked);
					}}
				/>
				Render the second counter
			</label>
		</div>
	);
}

改めて表示されたふたつめのカウンターの数値は0にリセットされます(サンプル002)。Reactがコンポーネントを削除してレンダリングしなくなると、そのコンポーネントの状態はすべて破棄されるからです。再表示してDOMに加わるコンポーネントの状態は初期値に戻ります。

サンプル002■React + TypeScript: Preserving and Resetting State 02

Reactがコンポーネントの状態を保持するのは、そのコンポーネントがUIツリーの同じ位置でレンダリングされている間です。削除されたり、別のコンポーネントがその位置にレンダリングされた場合、Reactは前のコンポーネントの状態を破棄します。

同じ位置にある同じコンポーネントの状態を保持

親コンポーネントに置くCounterはひとつにしましょう。そして、親から新たに渡すプロパティがブール値のisFancyです。この値に応じて、子コンポーネントにはまた別のスタイルが割り当てられます。

src/Counter.tsx
type Props = {
	isFancy: boolean;
};
// export const Counter: FC = () => {
export const Counter: FC<Props> = ({ isFancy }) => {

	// const className = 'counter' + (hover ? ' hover' : '');
	let className = 'counter';
	if (hover) {
		className += ' hover';
	}
	if (isFancy) {
		className += ' fancy';
	}

};

注目していただきたいのは、親コンポーネントAppが返すJSXの記述です。状態変数isFancyの値による条件分岐に応じて、ふたつの<Counter />タグがあります。これらは、それぞれ別のコンポーネントと扱われるのでしょうか。

src/App.tsx
export default function App() {
	const [isFancy, setIsFancy] = useState(false);
	return (
		<div>
			{isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
			<label>
				<input
					type="checkbox"
					checked={isFancy}
					onChange={({ target: { checked } }) => {
						setIsFancy(checked);
					}}
				/>
				Use fancy styling
			</label>
		</div>
	);
}

つぎのサンプル003でお試しください。チェックボックスのオン/オフでカウンターのスタイルが変わりまます。けれど、カウントアップした数値はそのままリセットされません。状態変数isFancyの値にかかわらずUIツリー上、Counterコンポーネントはつねに親のAppが返すルート要素<div>の最初の子だからです。

サンプル003■React + TypeScript: Preserving and Resetting State 03

同じ位置にある同じコンポーネントは、Reactからは同じという扱いになります。ただし、Reactが状態を紐づける基準とするのは、UIツリーにおける位置であって、JSXのマークアップではありません

たとえば、前掲サンプル003のルートコンポーネントAppのJSXを書き替えて、<Counter />if条件文の中と外に分けてみました。

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

	if (isFancy) {
		return (
			<div>
				<Counter isFancy={true} />

			</div>
		);
	}
	return (
		<div>
			<Counter isFancy={false} />

		</div>
	);
}

ルート要素の<div>ごとふたつに分かれています。チェックボックスで状態変数isFancyの値を切り替えるとCounterコンポーネントはリセットされるでしょうか。結果は、前掲サンプル003と変わりません(サンプル004)。UIツリーにおけるCounterがレンダリングされる位置は変わらないからです。Reactが見るのはあくまでUIツリー上の位置であって、JSXの条件文の記述がどうかは確かめません。

サンプル004■React + TypeScript: Preserving and Resetting State 04

いずれにしても、コンポーネントAppの戻り値はルート要素<div>の最初の子に<Counter />を含めます。つまり、Reactから見たCounterコンポーネントの「住所」は変わりません。Reactはこうして、レンダリングの前とあとが一致しているかどうか調べます。コードのロジックがどう組み立てられているかは関係ありません。

同じ位置に別のコンポーネントが差し替わると状態はリセットされる

親コンポーネント(App)が、チェックボックスのクリックでCounterコンポーネントと<p>要素を差し替えたとしましょう。

src/App.tsx
export default function App() {
	const [isPaused, setIsPaused] = useState(false);
	return (
		<div>
			{isPaused ? <p>See you later!</p> : <Counter />}
			<label>
				<input
					type="checkbox"
					checked={isPaused}
					onChange={({ target: { checked } }) => {
						setIsPaused(checked);
					}}
				/>
					Take a break
			</label>
		</div>
	);
}

置き替えたのは、位置は同じでも異なるコンポーネントです(サンプル005)。はじめ、返されたJSXの<div>要素は最初の子としてCounterをもっていました。けれど、<p>要素に差し替えると、ReactはUIツリーからCounterを除き、状態が破棄されるのです。

サンプル005■React + TypeScript: Preserving and Resetting State 05

また、コンポーネントは同じであってもレンダーされるUIツリーが変われば、サブツリー全体の状態はリセットされます。たとえば、親コンポーネント(App)がつぎのようにCounterを切り替えた場合です(サンプル006)。

src/App.tsx
export default function App() {
	const [isFancy, setIsFancy] = useState(false);
	return (
		<div>
			{isFancy ? (
				<div>
					<Counter isFancy={true} />
				</div>
			) : (
				<section>
					<Counter isFancy={false} />
				</section>
			)}
		<label>
			<input
				type="checkbox"
				checked={isFancy}
				onChange={({ target: { checked } }) => {
					setIsFancy(checked);
				}}
			/>
			Use fancy styling
		</label>
		</div>
	);
}

サンプル006■React + TypeScript: Preserving and Resetting State 06

チェックボックスをクリックするたびに、Counterの状態(数値)はリセットされます。レンダリングされるCounterコンポーネントは同じでも、ルート要素<div>の最初の子が<div><section>とで切り替わるからです。子要素がDOMから除かれると、その下のツリー全体がCounterとその状態も含めて破棄されます。

基本的なやり方として、レンダリング間で状態を保持したい場合は、ツリー構造はレンダーごとに「一致」させなければなりません。構造が異なれば、状態は失われます。Reactがコンポーネントをツリーから除くとき、状態も破棄されるからです。

また、コンポーネントを入れ子で定めてはいけません。子コンポーネントが、レンダリングのたびにつくり直されるためです。

つぎのコード例のMyComponentでは、ボタンをクリックするたびに子コンポーネントMyTextFieldのテキスト入力フィールドの状態(text)はリセットされます。それに対して、親コンポーネントの状態(counter)は保たれたままでしょう(サンプル007)。入れ子の関数は、親のレンダリングごとに新たにつくられます。すると、同じ位置に異なるコンポーネントをレンダリングすることになるのです。Reactは子コンポーネント以下のすべての状態をリセットします。バグやパフォーマンスの問題につながるでしょう。それを避けるため、コンポーネント関数は入れ子にせず、つねにトップレベルで宣言してください。

export default function MyComponent() {
	const [counter, setCounter] = useState(0);
	const MyTextField: FC = () => {
		const [text, setText] = useState('');
		return (
			<input
				value={text}
				onChange={({ target: { value } }) => setText(value)}
			/>
		);
	};
	return (
		<>
			<MyTextField />
			<button
				onClick={() => {
					setCounter(counter + 1);
				}}
			>
				Clicked {counter} times
			</button>
		</>
	);
}

サンプル007■React + TypeScript: Preserving and Resetting State 07

同じ位置の状態をリセットする

Reactはデフォルトでは、コンポーネントが同じ位置にある間は、その状態を保持します。デフォルトなのは、多くの場合その方が都合がよいからです。けれど、コンポーネントを切り替えたとき、あえて状態はリセットしたいこともあるでしょう。たとえば、ふたりのプレーヤーのカウンターに、それぞれのスコア(状態)を示す場合です。

つぎのコード例では、ふたりのプレーヤー(isPlayerA)ごとに子コンポーネントのカウンター(Counter)を切り替えても子の状態は保たれます。Counterコンポーネントの位置は同じため、Reactがpersonプロパティの異なる同じコンポーネントとみなすからです。

src/App.tsx
export default function App() {
	const [isPlayerA, setIsPlayerA] = useState(true);
	return (
		<div>
			{isPlayerA ? <Counter person="Taylor" /> : <Counter person="Sarah" />}
			<button
				onClick={() => {
					setIsPlayerA(!isPlayerA);
				}}
			>
				Next player!
			</button>
		</div>
	);
}

けれど、このアプリケーションでは、ふたつのカウンターは別々だと捉えなければなりません。コンポーネントがUI上置かれる位置は同じであっても、カウンターはプレーヤーごとに別個です。

切り替えのとき状態をリセットする方法はふたつ考えられます。

  1. コンポーネントを異なる位置にレンダリングすることです。
  2. 各コンポーネントに明確な識別のためのキーを与えてください。

方法1: コンポーネントを異なる位置にレンダリングする

ふたつのCounterを独立に扱いたい場合は、異なる位置にレンダリングしましょう。以下のコードでは、状態変数isPlayerAの値に応じて、ふたつの位置のコンポーネントが切り替わります(サンプル008)。

  • isPlayerAtrue: ひとつめの位置にCounterの状態が含まれ、ふたつめは空です。
  • isPlayerAfalse: ひとつめの位置はクリアされ、ふたつめにCounterが加わります。

なお、論理積(&&)演算子の左辺が値としてfalseを返すとき、その式のJSXはレンダリングされません(「条件つきレンダリング(Conditional Rendering)」参照)。

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

	return (
		<div>
			{/* {isPlayerA ? <Counter person="Taylor" /> : <Counter person="Sarah" />} */}
			{isPlayerA && <Counter person="Taylor" />}
			{!isPlayerA && <Counter person="Sarah" />}

		</div>
	);
}

サンプル008■React + TypeScript: Preserving and Resetting State 08

それぞれのCounterの状態は、コンポーネントがDOMから除かれるたびに破棄されます。したがって、ボタンをクリックするたびに状態はリセットされるのです。

このやり方は、独立してレンダーしたいコンポーネントが少ないときにはお手軽でしょう。このサンプルでは、Counterがふたつしかありません。JSXでレンダリングの位置を変えるのに適した例です。

各コンポーネントに明確な識別のためのキーを与える

コンポーネントの状態をリセットするもっと一般的なやり方もあります。

リストをレンダリングするときに加えるのがkeyプロパティです(「リストのレンダリング」参照)。でも、リストにしか使えないわけではありません。keyを与えれば、Reactはコンポーネントが区別できるのです。

デフォルトでは、Reactは親の中における順序によってコンポーネントを識別します。けれど、keyを与えれば、Reactは順序にかかわりなく各コンポーネントが認識できるのです。こうして、コンポーネントがツリーのどこにレンダリングされても、Reactから識別可能になります。

つぎのコードのようにふたつのCounterコンポーネントにkeyを加えると、JSXの同じ位置に置かれても状態は共有されません。異なるkeyによりふたつのコンポーネントは区別され、切り替えたときに状態が保持されないのです(サンプル009)。

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

	return (
		<div>
			{isPlayerA ? (
				<Counter key="Taylor" person="Taylor" />
			) : (
				<Counter key="Sarah" person="Sarah" />
			)}

		</div>
	);
}

サンプル009■React + TypeScript: Preserving and Resetting State 09

keyを定めると、Reactに親の中における順序でなく、位置はkeyで認識するよう伝えたことになります。そのため、JSX内の同じ位置にレンダーされても、Reactは別々のCounterだと識別するのです。したがって、状態は共有されません。コンポーネントがそれぞれレンダリングされるたび、状態はつくり直されます。そして、コンポーネントが除かれれば、状態は破棄されるのです。こうして、コンポーネントを切り替えると、そのつど状態はリセットされます。

なお、keyの値はグローバルに一意である必要はありません。親の中で位置が特定できればよいのです。

フォームをキーでリセットする

状態をkeyでリセットすることは、とくにフォームを扱うときに役立ちます。チャットアプリケーションの中で、Chatコンポーネントがテキスト入力の状態をもつとしましょう。

まず、リセットは考えないチャットアプリケーションの組み立てです。Chatコンポーネントはテキストエリアをもち、入力したメッセージは[Send to <相手先メール>]ボタンで送信します(ボタンクリックではとくに何も起こりません)。宛先情報を親から受け取るプロパティがcontactです。

src/Chat.tsx
export const Chat: FC<Props> = ({ contact }) => {
	const [text, setText] = useState('');
	return (
		<section className="chat">
			<textarea
				value={text}
				onChange={({ target: { value } }) => setText(value)}
			/>
			<br />
			<button>Send to {contact.email}</button>
		</section>
	);
};

親コンポーネントMessengerには3人分の宛先情報(contacts)が備わり、子のChatに加えContactListにプロパティで情報を渡します。

src/App.tsx
const contacts: Contact[] = [
	{ id: 0, name: 'Taylor', email: 'taylor@mail.com' },
	{ id: 1, name: 'Alice', email: 'alice@mail.com' },
	{ id: 2, name: 'Bob', email: 'bob@mail.com' }
];
export default function Messenger() {
	const [to, setTo] = useState(contacts[0]);
	return (
		<div>
			<ContactList contacts={contacts} onSelect={(contact) => setTo(contact)} />
			<Chat contact={to} />
		</div>
	);
}

ContactListは、3人の内からボタンで相手先を選ぶリストのコンポーネントです。相手が切り替わったら、その情報(contact)を親コンポーネントに送ります(onSelect)。

src/ContactList.tsx
export const ContactList: FC<Props> = ({ contacts, onSelect }) => {
	return (
		<section className="contact-list">
			<ul>
				{contacts.map((contact) => (
					<li key={contact.id}>
						<button
							onClick={() => {
								onSelect(contact);
							}}
						>
							{contact.name}
						</button>
					</li>
				))}
			</ul>
		</section>
	);
};

サンプル010でテキストエリアに何か入力してから、ボタンで相手を切り替えてみてください。入力したテキストは消えません。Chatコンポーネントのレンダリングされるツリー上の位置が変わらないため、状態は保たれるからです。

サンプル010■React + TypeScript: Preserving and Resetting State 10

チャットアプリケーションでは、この場合の状態保持は望ましくありません。前の相手に入力したメッセージを、うっかりつぎの宛先に送信してしまうかもしれないからです。

こういうとき、ContactListから送られた相手先の変更を親が捉え、もうひとつの子であるChatに対して入力されたテキストは消すように伝えるというやり方も考えられます。

けれど、Chatコンポーネントの状態はリセットしてしまうのなら、keyを用いれば簡単です。つぎのようにkeyを加えれば、相手先が切り替わったときテキストはクリアされるでしょう(サンプル011)。

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

	return (
		<div>

			{/* <Chat contact={to} /> */}
			<Chat key={to.id} contact={to} />
		</div>
	);
}

サンプル011■React + TypeScript: Preserving and Resetting State 11

これで、相手先を切り替えたとき、Chatコンポーネントはその下のツリーの状態も含めて新たにつくられます。Reactは、DOM要素も再利用することなくつくり直すのです。

削除されたコンポーネントの状態を保持する

実際のチャットアプリケーションでは、前の宛先に戻したとき、前に入力していた状態を回復したい場合があるでしょう。消したコンポーネントの状態を保持して「復活」させる方法はいくつか考えられます。

  • 現在だけでなく、すべてのチャットをレンダーすることです。要らないチャットはCSSで隠します。ツリーのすべてのチャットは削除されません。ローカルの状態に保持されます。シンプルなUIには適しているでしょう。けれど、隠れたツリーが大きくなり、DOMノードもたくさん含まれるようになると、速度の大幅な低下を招くかもしれません。
  • 状態を引き上げて、宛先ごとに残しておくメッセージは親コンポーネントに保持することです(「React + TypeScript: コンポーネント間で状態を共有する」参照)。これなら、子コンポーネントが除かれても、情報は親がもっているので失われません。これがもっとも一般的な解決方法でしょう。
  • Reactの状態に加えて、異なるソースを使うことも考えられます。たとえば、ユーザーがうっかりページを閉じても、書きかけたメッセージは残しておきたいという場合です。その実装としては、Chatコンポーネントが状態を初期化するとき、localStorageから読み込みます。そうすれば、下書きも保存できるでしょう。

いずれの方法をとるにしても、チャットは宛先によって別個と考えるべきです。したがって、Chatコンポーネントのツリーに宛先ごとのkeyを与えるのは適切といえます。

まとめ

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

  • Reactは、同じコンポーネントが同じ位置にレンダリングされるかぎり、状態を保持します。
  • 状態はJSXの中に保持されるのではありません。JSXが配置されたツリーの位置に紐づけられるのです。
  • 一意のkeyを変えれば、サブツリーの状態は強制的にリセットできます。
  • コンポーネントの定義を入れ子にしないでください。状態が意図せずリセットされることになります。
0
1
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
0
1