状态在组件之间是隔离的。React 会根据它们在 UI 树中的位置来跟踪哪个状态属于哪个组件。你可以控制何时在重新渲染之间保留状态,以及何时重置状态。
You will learn
- 什么时候 React 会选择保留或重置状态
- 如何强制 React 重置组件的状态
- 键和类型如何影响状态是否被保留
状态与渲染树中的位置相关
React 会为你 UI 中的组件结构构建 渲染树。
当你给组件添加状态时,你可能会认为状态“存在”于组件内部。但实际上,状态是保存在 React 里面的。React 会根据组件在渲染树中的位置,将它持有的每一份状态与正确的组件关联起来。
这里,只有一个 <Counter /> JSX 标签,但它在两个不同的位置被渲染:
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)}> 加一 </button> </div> ); }
它们在树中的样子如下:


React 树
这实际上是两个独立的计数器,因为它们各自渲染在树中的不同位置。 你通常不需要为了使用 React 而考虑这些位置,但理解它的工作方式会很有帮助。
在 React 中,屏幕上的每个组件都拥有完全隔离的状态。例如,如果你并排渲染两个 Counter 组件,它们各自都会拥有独立的 score 和 hover 状态。
试着点击这两个计数器,注意它们不会互相影响:
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)}> 加一 </button> </div> ); }
如你所见,当一个计数器更新时,只有那个组件的状态会被更新:


更新状态
只要你持续在树中的同一位置渲染同一个组件,React 就会一直保留该状态。要看到这一点,请把两个计数器都加到某个数值,然后通过取消勾选“渲染第二个计数器”复选框来移除第二个组件,再重新勾选它把它加回来:
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) }} /> 渲染第二个计数器 </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)}> 加一 </button> </div> ); }
注意,当你停止渲染第二个计数器的那一刻,它的状态会完全消失。这是因为当 React 移除一个组件时,它会销毁其状态。


删除组件
当你勾选“渲染第二个计数器”时,第二个 Counter 及其状态会从头初始化(score = 0),并添加到 DOM 中。


添加组件
只要组件持续在 UI 树中的同一位置被渲染,React 就会保留它的状态。 如果它被移除,或者同一位置渲染了另一个不同的组件,React 就会丢弃它的状态。
同一个位置上的同一个组件会保留状态
在这个示例中,有两个不同的 <Counter /> 标签:
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) }} /> 使用精美样式 </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)}> 加一 </button> </div> ); }
当你勾选或取消勾选复选框时,计数器状态不会被重置。无论 isFancy 是 true 还是 false,你始终都会在根 App 组件返回的 div 的第一个子元素位置上拥有一个 <Counter />:


更新 App 的状态不会重置 Counter,因为 Counter 仍然处于同一位置
它是同一个位置上的同一个组件,所以从 React 的角度来看,它就是同一个计数器。
位于同一位置的不同组件会重置状态
在这个示例中,勾选复选框会把 <Counter> 替换成一个 <p>:
import { useState } from 'react'; export default function App() { const [isPaused, setIsPaused] = useState(false); return ( <div> {isPaused ? ( <p>待会儿见!</p> ) : ( <Counter /> )} <label> <input type="checkbox" checked={isPaused} onChange={e => { setIsPaused(e.target.checked) }} /> 休息一下 </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)}> 加 1 </button> </div> ); }
在这里,你在同一个位置切换了_不同_的组件类型。最初,<div> 的第一个子元素是一个 Counter。但当你换成 p 时,React 会从 UI 树中移除 Counter 并销毁它的状态。


当 Counter 变成 p 时,Counter 被删除,p 被添加


切换回来时,p 被删除,Counter 被添加
同样,当你在同一位置渲染不同的组件时,它会重置其整个子树的状态。 想看看这是怎么工作的,可以先增加计数器,然后勾选复选框:
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) }} /> 使用花哨样式 </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)}> 加 1 </button> </div> ); }
当你点击复选框时,计数器状态会被重置。虽然你渲染了一个 Counter,但 div 的第一个子元素从 section 变成了 div。当子元素 section 从 DOM 中移除时,其下方整棵树(包括 Counter 及其状态)也被销毁了。


当 section 变成 div 时,section 被删除,新的 div 被添加


切换回来时,div 被删除,新的 section 被添加
一般来说,如果你想在重新渲染之间保留状态,树的结构就需要在前后两次渲染中“对上”。如果结构不同,状态就会被销毁,因为 React 会在从树中移除组件时销毁状态。
在同一位置重置状态
默认情况下,只要 React 保持某个组件处于同一位置,就会保留它的状态。通常这正是你想要的,所以它作为默认行为是合理的。但有时,你可能想要重置组件的状态。考虑这个应用,它允许两个玩家在每一回合中记录自己的分数:
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); }}> 下一位玩家! </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} 的分数:{score}</h1> <button onClick={() => setScore(score + 1)}> 加 1 </button> </div> ); }
目前,当你切换玩家时,分数会被保留。两个 Counter 出现在同一位置,所以 React 把它们视为同一个 Counter,只是它的 person prop 改变了。
但从概念上说,在这个应用里它们应该是两个独立的计数器。它们可能在 UI 中出现在同一个位置,但一个是 Taylor 的计数器,另一个是 Sarah 的计数器。
切换它们时重置状态有两种方法:
- 将组件渲染在不同的位置
- 通过
key为每个组件赋予明确的身份
选项 1:将组件渲染在不同的位置
如果你希望这两个 Counter 相互独立,可以把它们渲染在两个不同的位置:
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); }}> 下一位玩家! </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} 的分数:{score}</h1> <button onClick={() => setScore(score + 1)}> 加 1 </button> </div> ); }
- 最初,
isPlayerA为true。所以第一个位置包含Counter状态,第二个位置为空。 - 当你点击“下一位玩家”按钮时,第一个位置被清空,但第二个位置现在包含一个
Counter。


初始状态


点击“next”


再次点击“next”
每个 Counter 在从 DOM 中移除时,其状态都会被销毁。这就是为什么每次点击按钮时它们都会重置。
当你只有少量在同一位置渲染的独立组件时,这个方案很方便。在这个例子里你只有两个,所以在 JSX 中分别渲染它们并不麻烦。
选项 2:使用 key 重置状态
还有另一种更通用的方法,可以用来重置组件状态。
你可能在渲染列表时见过 key。key 不只是用于列表!你可以用 key 让 React 区分任意组件。默认情况下,React 使用父组件中的顺序(“第一个计数器”“第二个计数器”)来区分组件。但 key 可以让你告诉 React,这不只是一个第一个计数器或第二个计数器,而是一个特定的计数器——例如,Taylor 的计数器。这样,无论 Taylor 的计数器出现在树中的哪个位置,React 都能识别出来!
在这个例子中,两个 <Counter /> 即使在 JSX 中出现在同一个位置,也不会共享状态:
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); }}> 下一位玩家! </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} 的分数:{score}</h1> <button onClick={() => setScore(score + 1)}> 加 1 </button> </div> ); }
在 Taylor 和 Sarah 之间切换不会保留状态。这是因为你给了它们不同的 key:
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}指定 key 会告诉 React 将 key 本身作为位置的一部分,而不是父组件中的顺序。这就是为什么,即使你在 JSX 中把它们渲染在同一个位置,React 也会把它们视为两个不同的计数器,因此它们永远不会共享状态。每次计数器出现在屏幕上时,都会创建它的状态。每次它被移除时,都会销毁它的状态。在它们之间切换会反复重置状态。
使用 key 重置表单
使用 key 重置状态在处理表单时特别有用。
在这个聊天应用中,<Chat> 组件包含文本输入状态:
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> 被渲染在树中的同一位置。
在很多应用中,这可能是期望的行为,但在聊天应用里就不是! 你不希望用户因为误点而把已经输入的消息发给错误的人。要修复这个问题,添加一个 key:
<Chat key={to.id} contact={to} />这样可以确保当你选择不同的收件人时,Chat 组件会从头重新创建,包括其下方树中的任何状态。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' } ];
Deep Dive
在真实的聊天应用中,当用户再次选择之前的收件人时,你大概希望恢复输入框状态。要让一个不再可见的组件的状态继续“存活”,有几种方法:
- 你可以渲染 所有 聊天内容,而不是只渲染当前聊天,同时用 CSS 把其他的都隐藏起来。这样聊天项不会从树中移除,因此它们的本地状态会被保留。这个方案对简单界面很有效。但如果隐藏的树很大并且包含很多 DOM 节点,它会变得很慢。
- 你可以将状态提升到父组件中,在父组件里保存每个收件人的待发消息。这样,当子组件被移除时就没关系了,因为真正保存关键信息的是父组件。这是最常见的解决方案。
- 你也可以在 React state 之外再使用别的数据源。例如,即使用户不小心关闭了页面,你可能仍然希望消息草稿被保留。为实现这一点,你可以让
Chat组件通过读取localStorage来初始化状态,并把草稿也保存到那里。
不管你选择哪种策略,给定 Alice 的聊天与给定 Bob 的聊天在概念上都是不同的,因此根据当前收件人给 <Chat> 树设置一个 key 是合理的。
Recap
- 只要同一个组件在同一位置被渲染,React 就会保留它的状态。
- 状态并不保存在 JSX 标签中。它与放入该 JSX 的树位置相关联。
- 你可以通过给子树不同的
key来强制重置其状态。 - 不要嵌套定义组件,否则你会不小心重置状态。
Challenge 1 of 5: 修复消失的输入文本
这个示例在你点击按钮时会显示一条消息。然而,点击按钮也会意外地重置输入框。为什么会这样?修复它,使点击按钮不会重置输入文本。
import { useState } from 'react'; export default function App() { const [showHint, setShowHint] = useState(false); if (showHint) { return ( <div> <p><i>提示:你最喜欢的城市?</i></p> <Form /> <button onClick={() => { setShowHint(false); }}>隐藏提示</button> </div> ); } return ( <div> <Form /> <button onClick={() => { setShowHint(true); }}>显示提示</button> </div> ); } function Form() { const [text, setText] = useState(''); return ( <textarea value={text} onChange={e => setText(e.target.value)} /> ); }