React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、基本解説の「Preserving and Resetting State」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。
なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。
状態はコンポーネントごとに別々です。Reactはどの状態がどのコンポーネントに属するか、UIツリー内の位置にもとづいて把握します。そして、再レンダリング間で、状態をいつ保持し、いつリセットするかは制御できるのです。
UIツリー
ブラウザはさまざまなツリー構造によりUIをモデル化します。
ReactがUIを管理し、モデル化するために用いるのもツリー構造です。
- ReactはUIツリーをJSXからつくります。
- ブラウザのDOM要素をUIツリーに合わせて更新するのがRreact DOMです。
(なお、React Nativeは、これらのツリーをモバイルプラットフォーム固有の要素に変換します。)
状態はツリー内の位置に紐づく
コンポーネントに与えた状態は、そのコンポーネントの中に存在すると思うかもしれません。けれど、実際に状態を保持するのはReactです。そして、Reactのもつそれぞれの状態は、コンポーネントがUIツリー内のどこにあるかに応じて正しく紐づけられます。
Reactでは、ひとつの画面に同じコンポーネントを並べたとしても、状態は別々です。同じ名前の状態変数に、それぞれの値をもちます。コンポーネントがツリーの中の異なる位置にレンダーされるからです。通常は、ツリー内の位置についてまで、細かく考える必要はありません。ただ、仕組みを知っておくことは有用です。
たとえば、つぎのコンポーネントCounter
には、状態としてscore
とhover
が備わっています。それぞれカウンターの数値とコンポーネントにポインタが重なっているかどうかのブール値です。
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)。
import { Counter } from './Counter';
export default function App() {
return (
<div>
<Counter />
<Counter />
</div>
);
}
サンプル001■React + TypeScript: Preserving and Resetting State 01
Reactは、同じコンポーネントが同じ位置にレンダリングされているかぎり、状態を保ちます。たとえば、親コンポーネントApp
を書き替えて、ふたつめのCounter
コンポーネントがチェックボックスで消せるようにしてみましょう。
両方のカウンターの数値を増やしてから、チェックボックスのオフでふたつめのカウンターは画面から除きます。それから、オンに戻してカウンターを再表示してください。
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
です。この値に応じて、子コンポーネントにはまた別のスタイルが割り当てられます。
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 />
タグがあります。これらは、それぞれ別のコンポーネントと扱われるのでしょうか。
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
条件文の中と外に分けてみました。
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>
要素を差し替えたとしましょう。
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)。
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
プロパティの異なる同じコンポーネントとみなすからです。
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: コンポーネントを異なる位置にレンダリングする
ふたつのCounter
を独立に扱いたい場合は、異なる位置にレンダリングしましょう。以下のコードでは、状態変数isPlayerA
の値に応じて、ふたつの位置のコンポーネントが切り替わります(サンプル008)。
-
isPlayerA
がtrue
: ひとつめの位置にCounter
の状態が含まれ、ふたつめは空です。 -
isPlayerA
がfalse
: ひとつめの位置はクリアされ、ふたつめにCounter
が加わります。
なお、論理積(&&
)演算子の左辺が値としてfalse
を返すとき、その式のJSXはレンダリングされません(「条件つきレンダリング(Conditional Rendering)」参照)。
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)。
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
です。
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
にプロパティで情報を渡します。
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
)。
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)。
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
を変えれば、サブツリーの状態は強制的にリセットできます。 - コンポーネントの定義を入れ子にしないでください。状態が意図せずリセットされることになります。