상태는 컴포넌트 간에 격리된다. 리액트는 UI트리에서의 위치에 따라 어떤 상태가 어떤 컴포넌트에 속하는지 추적한다. 상태를 보존할 시기와 리렌더링 사이에 재설정할 시기에 제어할 수 있다.
브라우저는 UI를 모델링하기 위해 많은 트리 구조를 사용한다. DOM은 HTML 요소를 나타내고 CSSOM은 CSS에 대해 동일한 작업을 수행한다. Accessibility tree도 있다.
리액트는 또한 트리 구조를 사용하여 사용자가 만드는 UI를 관리하고 모델링한다. 리액트는 JSX에서 UI 트리를 만든다. 그런 다음 리액트 DOM은 해당 UI 트리와 일치하도록 브라우저 DIM 요소를 업데이트한다. (리액트 네이티브는 이러한 트리를 모바일 플랫폼에 특정한 요소로 변환한다.
컴포넌트 상태를 제공할 때 상태가 컴포넌트 내부에 “살아있다”고 생각할 수도 있다. 그러나 상태는 실제로 리액트 내부에 유지된다. 리액트는 보유하고 있는 각 상태를 해당 컴포넌트가 UI 트리의 위치에 따라 올바른 컴포넌트와 연결한다.
여기에는 <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)}>
Add one
</button>
</div>
);
}
트리 모양은 다음과 같다.
각각은 트리의 자체 위치에 렌더링되므로 이는 두개의 별도 카운터다. 일반적으로 리액트를 사용하기 위해 이러한 위치에 대해 생각할 필요는 없지만 그것이 어떻게 작동하는지 이해하는 것은 유용할 수 있다.
리액트에서 화면의 각 컴포넌트는 완전히 격리된 상태를 갖는다. 예를 들어 두 개의 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)}>
Add one
</button>
</div>
);
}
보다시피 하나의 카운터가 업데이트되면 해당 컴포넌트의 상태만 업데이트된다.
리액트는 동일한 위치에 동일한 컴포넌트를 렌더링하는 한 상태를 유지한다. 이를 확인하려면 두 카운터를 모두 증가시킨 다음 “두 번째 카운터 렌더링” 체크박스를 선택 취소하여 두 번째 컴포넌트를 제거한 다음 다시 선택하여 추가한다.
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>
);
}
Notice how the moment you stop rendering the second counter, its state disappears completely. That’s because when React removes a component, it destroys its state.
두 번째 카운터 렌더링을 중지하는 순간 해당 상태가 완전히 사라지는 것을 확인해라. 리액트가 컴포넌트를 제거하면 해당 상태도 파괴되기 때문이다.
“Render the second counter”을 선택하면 두 번째 Counter
와 해당 상태가 처음부터 초기화되고(score = 0
) DOM에 추가된다.
리액트는 UI 트리의 해당 위치에서 렌더링되는 동안 컴포넌트의 상태를 유지한다. 제거되거나 다른 컴포넌트가 동일한 위치에 렌더링되면 리액트는 해당 상태를 삭제한다.
이 예에는 두 가지 <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)
}}
/>
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>
);
}
체크박스를 선택하거나 선택 취소하면 카운터 상태가 재설정되지 않다. isFancy
가 true
인지 false
인지에 관계 없이 루트 App
컴포넌트에서 반환된 div
의 첫 번째 하위 항목으로 항상 <Counter />
가 있다.
같은 위치에 있는 같은 컴포넌트이므로 리액트의 관점에서는 같은 카운터이다.
💡 Pitfall리액트에 중요한 것은 JSX 마크업이 아닌 UI 트리의 위치라는 점을 기억해라. 이 컴포넌트에는 if
내부와 외부에 서로다른 <Counter />
JSX 태그가 있는 두 개의 return
절이 있다.
import { useState } from 'react';
export default function App() {
const [isFancy, setIsFancy] = useState(false);
if (isFancy) {
return (
<div>
<Counter isFancy={true} />
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => {
setIsFancy(e.target.checked)
}}
/>
Use fancy styling
</label>
</div>
);
}
return (
<div>
<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>
);
}
체크박스를 선택하면 상태가 재설정될 것으로 예상할 수 있지만 그렇지 않다. 이는 <Counter />
태그가 모두 동일한 위치에 렌더링되기 때문이다. 리액트는 함수에서 조건을 어디에 배치하는지 모른다. 그것이 “보는” 것은 너가 반환하는 트리일뿐이다.
두 경우 모두 App
컴포넌트는 첫 번째 하위 요소로 <Counter />
가 포함된 <div>
를 반환한다. 리액트에서 이 두 카운터는 동일한 “주소”(루트의 첫 번째 자식의 첫 번째 자식)를 갖는다. 이는 로직을 구성하는 방식에 관계없이 리액트가 이전 렌더링과 다음 렌더링 간에 일치시키는 방법이다.
이 예에서 체크박스를 선택하면 <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
를 교체하면 리액트는 UI트리에서 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)
}}
/>
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>
);
}
체크박스를 클릭하면 카운터 상태가 재설정된다. Counter
를 렌더링하더라도 div
의 첫 번째 하위 항목은 div
에서 section
으로 변경된다. 하위 div
가 DOM에서 제거되면 그 아래에 있는 전체 트리(Counter
및 해당 상태 포함)도 삭제된다.
경험상, 리렌더링 사이에 상태를 유지하려면 트리구조가 한 렌더에서 다른 렌더로 “일치”되어야 한다. 구조가 다르면 리액트가 트리에서 컴포넌트를 제거할 때 상태를 파괴하기 때문에 상태가 파괴된다.
💡 Pitfall이것이 바로 컴포넌트 함수 정의를 중첩해서는 안되는 이유다.
여기서 MyTextField
컴포넌트 함수는 MyComponent
내부에 정의된다.
import { useState } from 'react';
export default function MyComponent() {
const [counter, setCounter] = useState(0);
function MyTextField() {
const [text, setText] = useState('');
return (
<input
value={text}
onChange={e => setText(e.target.value)}
/>
);
}
return (
<>
<MyTextField />
<button onClick={() => {
setCounter(counter + 1)
}}>Clicked {counter} times</button>
</>
);
}
버튼을 클릭할 때마다 입력상태가 사라진다. 이는 MyComponent
를 렌더링할 때마다 다른 MyTextField
함수가 생성되기 때문이다. 동일한 위치에 다른 컴포넌트를 렌더링하므로 리액트는 아래의 모든 상태를 재설정한다. 이로인해 버그와 성능 문제가 발생한다. 이 문제를 방지하려면 항상 최상위 수준에서 컴포넌트 함수를 선언하고 해당 정의를 중첩하면 안된다.
기본적으로 리액트는 동일한 위치에 있는 동안 컴포넌트의 상태를 유지한다. 일반적으로 이는 정확히 원하는 것이므로 기본 동작으로 이해된다. 그러나 때로는 컴포넌트의 상태를 재설정하고 싶을 수도 있다. 두 명의 플레이어가 각 턴 동안 자신의 점수를 추적할 수 있는 다음 앱을 생각해봐라.
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>
);
}
현재는 플레이어를 변경해도 점수가 유지된다. 두 개의 Counter
는 동일한 위치에 나타나므로 리액트는 이를 person
prop이 변경된 동일한 Counter
로 간주한다.
하지만 개념적으로 이 앱에서는 두 개의 별도 카운터여야 한다. UI에서 같은 위치에 나타날 수 있지만 하나는 Taylor에 대한 카운터이고 다른 하나는 Sarah에 대한 카운터이다.
상태를 전환할 때 상태를 재설정하는 방법에는 두가지가 있다.
key
를 사용하여 각 컴포넌트에 명시적인 ID를 부여이 두 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);
}}>
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
이다. 따라서 첫 번째 위치에는 Counter
상태가 포함되고 두 번째 위치는 비어 있다.Counter
가 포함된다.각 Counter
의 상태는 DOM에서 제거될 때마다 파괴된다. 이것이 버튼을 클릭할 때마다 재설정되는 이유다.
이 솔루션은 동일한 위치에 렌더링된 독립 컴포넌트가 몇 개뿐인 경우에 편리하다. 이 예에서는 두 개만 있으므로 JSX에서 두 개를 별도로 렌더링하는 것이 번거롭지 않다.
컴포넌트의 상태를 재설정하는 또 다른 보다 일반적인 방법도 있다.
리스트를 렌더링할 때 key
를 본 적이 있을 것이다. 키는 단지 리스트만을 위한 것이 아니다. 키를 사용하여 리액트가 모든 컴포넌트를 구별하도록 할 수 있다. 기본적으로 리액트는 컴포넌트를 식별하기 위해 상위 항목(”첫 번째 카운터”, “두 번째 카운터”) 내의 순서를 사용한다. 하지만 키를 사용하면 이것이 단순한 첫 번째 카운터나 두 번째 카운터가 아니라 특정 카운터(예: Taylor의 카운터)임을 리액트에게 알릴 수 있다. 이런 식으로 리액트는 트리에 나타날 때마다 Taylor의 카운터를 알게된다.
이 예에서 두 <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);
}}>
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 사이를 전환해도 상태는 유지되지 않는다. 이는 서로 다른 key
를 제공했기 때문이다.
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}
key
를 지정하면 리액트는 부모 내의 순서 대신 키 자체를 위치의 일부로 사용하도록 지시한다. 이것이 바로 JSX에서 같은 위치에 렌더링하더라도 리액트가 이를 두 개의 다른 카운터로 간주하여 상태를 공유하지 않는 이유이다. 카운터가 화면에 나타날 때마다 해당 상태가 생성된다. 제거될 때마다 해당 상태가 파괴된다. 이들 사이를 전환하면 상태가 계속해서 재설정된다.
키는 전역적으로 고유하지 않다는 점을 유의해라. 부모 내의 위치만 지정한다.
키를 사용하여 상태를 재설정하는 것은 폼을 처리할 때 특히 유용하다.
이 채팅 앱에서 <Chat>
컴포넌트에는 텍스트 입력 상태가 포함되어 있다.
// App.js
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' }
];
// ContactList.js
export default function ContactList({
selectedContact,
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>
);
}
// Chat.js
import { useState } from 'react';
export default function Chat({ contact }) {
const [text, setText] = useState('');
return (
<section className="chat">
<textarea
value={text}
placeholder={'Chat to ' + contact.name}
onChange={e => setText(e.target.value)}
/>
<br />
<button>Send to {contact.email}</button>
</section>
);
}
입력란에 내용을 입력한 다음 “Alice” 또는 “Bob”을 눌러 다른 수신자를 선택해라. <Chat>
이 트리의 동일한 위치에 렌더링되기 때문에 입력 상태가 유지되는 것을 알 수 있다.
많은 앱에서 이는 바람직한 동작일 수 있지만 채팅 앱에서는 그렇지 않다. 실수로 클릭하여 사용자가 이미 입력한 메시지를 잘못된 사람에게 보내는 것을 원하지 않을 것이다. 이 문제를 해결하려면 key
를 추가하면 된다.
<Chat key={to.id} contact={to} />
이렇게 하면 다른 수신자를 선택할 때 아래 트리의 모든 상태를 포함하여 Chat
컴포넌트가 처음부터 다시 생성된다. 리액트는 DOM 요소를 재사용하는 대신 다시 생성한다.
이제 수신자를 전환하면 항상 텍스트 필드가 지워진다.
// App.js
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
제거된 컴포넌트의 상태보존
시
실제 채팅 앱에서는 사용자가 이전 수신자를 다시 선택할 때 입력 상태를 복구하고 싶을 것이다. 더 이상 표시되지 않는 컴포넌트의 상태를 “활성”으로 유지하는 몇 가지 방법이 있다.
Chat
component initialize its state by reading from the [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)
, and save the drafts there too.Chat
컴포넌트가 [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)
에서 읽어 상태를 초기화하고 거기에 초안도 저장하도록 할 수 있따.어떤 전략을 선택하든 Alice와의 채팅을 Bob과의 채팅과 개념적으로 구별되므로 현재 수신자를 기준으로 <Chat>
트리에 key
를 제공하는 것이 합리적이다.