state の保持とリセット
state は複数のコンポーネント間で独立しています。React は UI ツリー内の各コンポーネントの位置に基づいて、どの state がどのコンポーネントに属するか管理します。再レンダーをまたいでどのようなときに state を保持し、どのようなときにリセットするのか、制御することができます。
このページで学ぶこと
- React にはコンポーネント構造がどのように「見える」のか
- React が state の保持とリセットを行うタイミング
- React にコンポーネントの state のリセットを強制する方法
- key とタイプが state の保持にどのように影響するか
UI ツリー
ブラウザは、UI をモデル化するために多くのツリー構造を使用します。DOM は HTML 要素を、CSSOM は CSS を表現します。アクセシビリティツリーというものもあります!
React もまた、ユーザが作成した UI を管理しモデリングするためにツリー構造を使用します。React は JSX から UI ツリーを作成します。次に React DOM が、その UI ツリーに合わせてブラウザの DOM 要素を更新します。(React Native の場合は UI ツリーをモバイルプラットフォーム固有の要素に変換します。)
state はツリー内の位置に結びついている
コンポーネントに state を与えると、その state はそのコンポーネントの内部で「生存」しているように思えるかもしれません。しかし、実際には state は React の中に保持されています。React は、「UI ツリー内でそのコンポーネントがどの位置にあるか」に基づいて、保持している各 state を正しいコンポーネントに関連付けます。
以下のコードには <Counter />
JSX タグは 1 つしかありませんが、それが 2 つの異なる位置にレンダーされています。
import { useState } from 'react'; export default function App() { const counter = <Counter />; return ( <div> {counter} {counter} </div> ); } function Counter() { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
ツリーとしては、これは以下のように見えます。
これらはツリー内の別々の位置にレンダーされているため、2 つの別々のカウンタとして動作します。React を使用する上でこのような位置のことについて考える必要はめったにありませんが、React がどのように機能するかを理解することは有用です。
React では、画面上の各コンポーネントは完全に独立した state を持ちます。例えば、Counter
コンポーネントを 2 つ横に並べてレンダーすると、それぞれが別個に、独立した score
および hover
という state を持つことになります。
両方のカウンタをクリックしてみて、互いに影響していないことを確かめてください。
import { useState } from 'react'; export default function App() { return ( <div> <Counter /> <Counter /> </div> ); } function Counter() { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
ご覧のように、カウンタのうち 1 つが更新されると、そのコンポーネントの state だけが更新されます。
React は、同じコンポーネントを同じ位置でレンダーしている限り、その state を保持し続けます。これを確認するため、両方のカウンタを増加させてから、“Render the second counter” のチェックボックスのチェックを外して 2 つ目のコンポーネントを削除し、再びチェックを入れて元に戻してみてください。
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={e => { setShowB(e.target.checked) }} /> Render the second counter </label> </div> ); } function Counter() { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
2 つ目のカウンタのレンダーをやめた瞬間、その state は完全に消えてしまいます。これは、React がコンポーネントを削除する際にその state も破棄するからです。
“Render the second counter” にチェックを入れると、2 つ目の Counter
とその state が初期化され (score = 0
)、DOM に追加されます。
React は、UI ツリーの中でコンポーネントが当該位置にレンダーされ続けている間は、そのコンポーネントの state を維持します。もし削除されたり、同じ位置に別のコンポーネントがレンダーされたりすると、React は state を破棄します。
同じ位置の同じコンポーネントは state が保持される
この例のコードには、<Counter />
タグが 2 つあります。
import { useState } from 'react'; 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={e => { setIsFancy(e.target.checked) }} /> Use fancy styling </label> </div> ); } function Counter({ isFancy }) { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } if (isFancy) { className += ' fancy'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
チェックボックスにチェックを入れたり消したりしても、カウンタの state はリセットされません。isFancy
が true
であろうと false
であろうと、ルートの App
コンポーネントが返す div
の最初の子は常に <Counter />
だからです。
同じコンポーネントが同じ位置にあるので、React の観点からは同じカウンタだというわけです。
同じ位置の異なるコンポーネントは state をリセットする
この例では、チェックボックスにチェックを入れると、<Counter>
が <p>
に置き換わります。
import { useState } from 'react'; 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={e => { setIsPaused(e.target.checked) }} /> Take a break </label> </div> ); } function Counter() { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
ここでは、同じ位置で異なる種類のコンポーネントを切り替えています。最初は <div>
の最初の子は Counter
でした。それを p
と入れ替えると、React は UI ツリーから Counter
を削除し、その state を破棄します。
また、同じ位置で異なるコンポーネントをレンダーすると、そのサブツリー全体の state がリセットされます。これがどのように動作するかを確認するために、以下でカウンタを増やしてからチェックボックスにチェックを入れてみてください:
import { useState } from 'react'; 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={e => { setIsFancy(e.target.checked) }} /> Use fancy styling </label> </div> ); } function Counter({ isFancy }) { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } if (isFancy) { className += ' fancy'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
チェックボックスをクリックするとカウンタの state がリセットされます。Counter
をレンダーしていることは同じでも、<div>
の最初の子が section
から div
に変わっています。子側の section
が DOM から削除されたとき、その下のツリー全体(Counter
とその state を含む)も破棄されたのです。
覚えておくべきルールとして、再レンダー間で state を維持したい場合、ツリーの構造はレンダー間で「合致」する必要があります。構造が異なる場合、React がツリーからコンポーネントを削除するときに state も破棄されてしまいます。
同じ位置で state をリセット
デフォルトでは、React はコンポーネントが同じ位置にある間、その state を保持します。通常、これがまさにあなたが望むものであり、デフォルト動作として妥当です。しかし時には、コンポーネントの state をリセットしたい場合があります。以下の、2 人のプレーヤに交替でスコアを記録させるアプリを考えてみましょう。
import { useState } from 'react'; export default function Scoreboard() { const [isPlayerA, setIsPlayerA] = useState(true); return ( <div> {isPlayerA ? ( <Counter person="Taylor" /> ) : ( <Counter person="Sarah" /> )} <button onClick={() => { setIsPlayerA(!isPlayerA); }}> Next player! </button> </div> ); } function Counter({ person }) { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{person}'s score: {score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
現在のところ、プレーヤを変更してもスコアが保持されています。2 つの Counter
は同じ位置に表示されるため、React は person
という props が変更されただけの同一の Counter
であると認識します。
しかし、概念的には、このアプリでは、この 2 つのカウンタは別物であるべきです。UI 上の同じ場所に表示されているにせよ、一方は Taylor のカウンタで、もう一方は Sarah のカウンタなのです。
これらを切り替えるときに state をリセットする方法は、2 つあります。
- コンポーネントを異なる位置でレンダーする
key
を使って各コンポーネントに明示的な識別子を付与する
オプション 1:異なる位置でコンポーネントをレンダー
これら 2 つの Counter
を互いに独立させたい場合、レンダーを 2 つの別の位置で行うことで可能です。
import { useState } from 'react'; export default function Scoreboard() { const [isPlayerA, setIsPlayerA] = useState(true); return ( <div> {isPlayerA && <Counter person="Taylor" /> } {!isPlayerA && <Counter person="Sarah" /> } <button onClick={() => { setIsPlayerA(!isPlayerA); }}> Next player! </button> </div> ); } function Counter({ person }) { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{person}'s score: {score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
- 最初
isPlayerA
はtrue
です。そのため、1 番目の位置に対してCounter
の state が保持され、2 番目は空です。 - “Next player” ボタンをクリックすると、最初の位置がクリアされ、2 番目の位置に
Counter
が入ります。
Counter
の state は DOM から削除されるたびに破棄されます。これがボタンをクリックするたびにカウントがリセットされる理由です。
この解決策は、同じ場所でレンダーされる独立したコンポーネントが数個しかない場合には便利です。この例では 2 つしかないので、両方を JSX 内で別々にレンダーしても大変ではありません。
オプション 2:key で state をリセットする
コンポーネントの state をリセットする一般的な方法がもうひとつあります。
リストをレンダーする際に key
を使ったのを覚えているでしょうか。key はリストのためだけのものではありません! どんなコンポーネントでも React がそれを識別するために使用できるのです。デフォルトでは、React は親コンポーネント内での順序(「1 番目のカウンタ」「2 番目のカウンタ」)を使ってコンポーネントを区別します。しかし、key
を使うことで、カウンタが単なる 1 番目のカウンタや 2 番目のカウンタではなく特定のカウンタ、例えば Taylor のカウンタである、と React に伝えることができます。このようにして、React は Taylor のカウンタがツリーのどこにあっても識別できるようになるのです。
この例では、2 つの <Counter />
は JSX の同じ場所にあるにもかかわらず、state を共有していません:
import { useState } from 'react'; export default function Scoreboard() { const [isPlayerA, setIsPlayerA] = useState(true); return ( <div> {isPlayerA ? ( <Counter key="Taylor" person="Taylor" /> ) : ( <Counter key="Sarah" person="Sarah" /> )} <button onClick={() => { setIsPlayerA(!isPlayerA); }}> Next player! </button> </div> ); } function Counter({ person }) { const [score, setScore] = useState(0); const [hover, setHover] = useState(false); let className = 'counter'; if (hover) { className += ' hover'; } return ( <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} > <h1>{person}'s score: {score}</h1> <button onClick={() => setScore(score + 1)}> Add one </button> </div> ); }
Taylor と Sarah を切り替えたときに state が保持されなくなりました。異なる key
を指定したからです。
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}
key
を指定することで、親要素内の順序ではなく、key
自体を位置に関する情報として React に使用させることができます。これにより、JSX で同じ位置にレンダーしても、React はそれらを異なるカウンタとして認識するため、state が共有されてしまうことはありません。カウンタが画面に表示されるたびに、新しい state が作成されます。カウンタが削除されるたびに、その state は破棄されます。切り替えるたびに、何度でも state がリセットされます。
key でフォームをリセットする
key を使って state をリセットすることは、フォームを扱う際に特に有用です。
このチャットアプリでは、<Chat>
コンポーネントがテキスト入力フィールド用の state を持っています。
import { useState } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; export default function Messenger() { const [to, setTo] = useState(contacts[0]); return ( <div> <ContactList contacts={contacts} selectedContact={to} onSelect={contact => setTo(contact)} /> <Chat contact={to} /> </div> ) } const contacts = [ { id: 0, name: 'Taylor', email: 'taylor@mail.com' }, { id: 1, name: 'Alice', email: 'alice@mail.com' }, { id: 2, name: 'Bob', email: 'bob@mail.com' } ];
入力フィールドに何か入力してから、“Alice” または “Bob” をクリックして別の送信先を選択してみてください。<Chat>
がツリーの同じ位置にレンダーされているため、入力した state が保持されたままになっていることが分かります。
多くのアプリではこれが望ましい動作でしょうが、チャットアプリでは違います! ミスクリックのせいで既に入力したメッセージを誤った相手に送ってしまうことは避けたいでしょう。これを修正するために、key
を追加します。
<Chat key={to.id} contact={to} />
これにより、異なる送信先を選択したときに、Chat
コンポーネントが、その下のツリーにあるあらゆる state も含めて最初から再作成されるようになります。React は DOM 要素についても再利用するのではなく再作成します。
これで、送信先を切り替えると常にテキストフィールドがクリアされるようになります。
import { useState } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; export default function Messenger() { const [to, setTo] = useState(contacts[0]); return ( <div> <ContactList contacts={contacts} selectedContact={to} onSelect={contact => setTo(contact)} /> <Chat key={to.id} contact={to} /> </div> ) } const contacts = [ { id: 0, name: 'Taylor', email: 'taylor@mail.com' }, { id: 1, name: 'Alice', email: 'alice@mail.com' }, { id: 2, name: 'Bob', email: 'bob@mail.com' } ];
さらに深く知る
実際のチャットアプリでは、ユーザが以前の送信先を再度選択したときに入力欄の state を復元できるようにしたいでしょう。表示されなくなったコンポーネントの state を「生かしておく」方法はいくつかあります。
- 現在のチャットだけでなくすべてのチャットをレンダーしておき、CSS で残りのすべてのチャットを非表示にすることができます。チャットはツリーから削除されないため、ローカル state も保持されます。この解決策はシンプルな UI では適していますが、非表示のツリーが大きく DOM ノードがたくさん含まれている場合は非常に遅くなります。
- state をリフトアップすることで、各送信先に対応する書きかけのメッセージを親コンポーネントで保持することができます。この方法では、重要な情報を保持しているのは親の方なので、子コンポーネントが削除されても問題ありません。これが最も一般的な解決策です。
- また、React の state に加えて別の情報源を利用することもできます。例えば、ユーザがページを誤って閉じたとしてもメッセージの下書きが保持されるようにしたいでしょう。これを実装するために、
Chat
コンポーネントがlocalStorage
から読み込んで state を初期化し、下書きを保存するようにできます。
どの戦略を選ぶ場合でも、Alice とのチャットと Bob とのチャットは概念的には異なるものなので、現在の送信先に基づいて <Chat>
ツリーに key
を付与することは妥当です。
まとめ
- React は、同じコンポーネントが同じ位置でレンダーされている限り、state を保持する。
- state は JSX タグに保持されるのではない。JSX を置くツリー内の位置に関連付けられている。
- 異なる key を与えることで、サブツリーの state をリセットするよう強制することができる。
- コンポーネント定義をネストさせてはいけない。さもないと state がリセットされてしまう。
チャレンジ 1/5: 入力テキストの消失を修正
この例では、ボタンを押すとメッセージが表示されます。が、ボタンを押すことでどういうわけか入力欄がリセットされてしまいます。なぜこれが起こるのでしょうか? ボタンを押しても入力テキストがリセットされないように修正してください。
import { useState } from 'react'; export default function App() { const [showHint, setShowHint] = useState(false); if (showHint) { return ( <div> <p><i>Hint: Your favorite city?</i></p> <Form /> <button onClick={() => { setShowHint(false); }}>Hide hint</button> </div> ); } return ( <div> <Form /> <button onClick={() => { setShowHint(true); }}>Show hint</button> </div> ); } function Form() { const [text, setText] = useState(''); return ( <textarea value={text} onChange={e => setText(e.target.value)} /> ); }