Preserving and Resetting State

김동현·2026년 3월 15일

title: 상태 보존 및 초기화 (Preserving and Resetting State)

React를 배우시면서 정말 중요한 개념 중 하나를 만나셨네요! 상태(State)는 각 컴포넌트 간에 완벽하게 격리되어 있답니다. React는 UI 트리 내에서 컴포넌트가 어느 위치에 있는지를 바탕으로 어떤 상태가 어떤 컴포넌트에 속하는지 추적해요. 이번 장에서는 리렌더링 과정에서 언제 상태를 보존하고, 언제 상태를 초기화할지 여러분이 직접 제어하는 방법을 차근차근 배워볼 거예요.

  • React가 상태를 언제 보존하고 언제 초기화하는지 판단하는 기준
  • React가 컴포넌트의 상태를 강제로 초기화하도록 만드는 방법 (실무에서 폼 초기화할 때 정말 유용해요!)
  • key 속성과 컴포넌트 타입이 상태 보존 여부에 미치는 영향

상태는 렌더 트리 내의 위치와 연결되어 있어요 {/state-is-tied-to-a-position-in-the-tree/}

React는 여러분이 만든 UI의 컴포넌트 구조를 바탕으로 렌더 트리(render trees)를 만들어서 관리해요. 쉽게 말해 눈에 보이는 구조를 뼈대로 만들어둔다고 생각하시면 돼요.

컴포넌트에 상태를 부여할 때, 많은 분들이 상태가 컴포넌트 "안에" 살고 있다고 생각하시기 쉬워요. 하지만 실제로는 그렇지 않아요! 상태는 실제로 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)}>
        Add one
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

이 구조를 트리 형태로 보면 다음과 같아요:

React 트리 (React tree)

이 둘은 트리 내에서 각자 고유한 위치에 렌더링되기 때문에 완전히 별개의 카운터예요. 보통 React를 사용할 때 이런 위치까지 세세하게 신경 쓸 필요는 없지만, 원리를 이해해 두면 예상치 못한 버그를 막는 데 큰 도움이 된답니다.

React에서 화면에 표시되는 각 컴포넌트는 완전히 격리된 상태를 가져요. 예를 들어, 두 개의 Counter 컴포넌트를 나란히 렌더링하면, 각각의 컴포넌트는 서로 독립적인 scorehover 상태를 갖게 됩니다.

두 카운터를 각각 클릭해 보고 서로 전혀 영향을 주지 않는 것을 확인해 보세요:

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>
  );
}
.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

보시다시피 하나의 카운터가 업데이트될 때, 해당 컴포넌트의 상태만 딱 업데이트돼요. 옆집 컴포넌트의 상태는 전혀 건드리지 않죠.

상태 업데이트하기 (Updating state)

React는 렌더 트리 안의 같은 위치에서 동일한 컴포넌트를 렌더링하는 동안에는 그 상태를 계속 유지해요. 이걸 확인해 보려면 두 카운터의 값을 올린 다음, "Render the second 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={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>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

어떠신가요? 두 번째 카운터를 렌더링하지 않는 순간 해당 카운터의 상태가 완전히 날아가 버리는 걸 눈치채셨나요? 왜냐하면 React가 화면(트리)에서 컴포넌트를 제거할 때, 그 컴포넌트가 가지고 있던 상태도 함께 파괴해 버리기 때문이에요.

컴포넌트 삭제하기 (Deleting a component)

다시 "Render the second counter"에 체크하면, 두 번째 Counter와 그 상태가 완전히 처음부터 다시 초기화되어 (score = 0) DOM에 새로 추가됩니다.

컴포넌트 추가하기 (Adding a component)

꼭 기억하세요! React는 UI 트리 상의 같은 위치에 컴포넌트가 렌더링되고 있는 동안만 상태를 보존합니다. 만약 그 컴포넌트가 제거되거나, 같은 자리에 완전히 다른 종류의 컴포넌트가 렌더링되면 React는 기존 상태를 버리게 됩니다.

같은 위치에 같은 컴포넌트가 오면 상태는 보존됩니다 {/same-component-at-the-same-position-preserves-state/}

아래 예시에는 두 개의 서로 다른 <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>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

체크박스를 클릭하거나 해제해 보세요. 카운터의 상태(숫자)가 초기화되지 않죠? 코드를 보면 if-else 분기로 나뉘어 있지만, isFancytrue이든 false이든 간에 최상위 App 컴포넌트가 반환하는 div첫 번째 자식 자리는 언제나 <Counter />가 차지하고 있기 때문이에요.

App 상태를 업데이트해도 Counter는 같은 위치에 머무르기 때문에 초기화되지 않아요.

결국 React의 관점에서는 같은 자리에 앉아있는 동일한 컴포넌트 타입이기 때문에 "아, 똑같은 카운터구나!" 하고 인식하는 거예요.

잠깐! 여기서 초보자분들이 정말 많이 헷갈려하시는 함정이 하나 나옵니다. React에게 중요한 건 'UI 렌더 트리 상에서의 위치'이지, 'JSX 코드 내에서의 작성 위치'가 아니라는 점을 명심하세요!

아래 코드를 보면 if문 안과 밖에 각각 다른 return 절을 사용해서 <Counter /> JSX 태그를 작성했어요.

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>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

코드상으로는 아예 다른 줄, 다른 덩어리에 있으니까 체크박스를 누르면 카운터가 리셋될 거라고 예상하셨나요? 하지만 리셋되지 않습니다! 왜 그럴까요? 이 두 <Counter /> 태그가 결국 렌더링되는 트리의 위치는 똑같기 때문이에요.

React는 여러분이 함수 안에서 어떤 조건문을 어떻게 썼는지는 알지 못해요. React가 "보는" 것은 여러분이 최종적으로 반환(return)한 트리의 형태뿐입니다. 두 경우 모두 App 컴포넌트는 최상위 자식으로 <div>를 반환하고, 그 <div>의 첫 번째 자식으로 <Counter />를 반환하고 있죠.

즉, React에게 이 두 카운터는 "루트 컴포넌트의 첫 번째 자식(div)의 첫 번째 자식"이라는 동일한 주소를 가지는 거예요. 코드 구조와 상관없이, 이전 렌더링과 다음 렌더링 사이의 위치 구조가 일치하기 때문에 상태가 그대로 연결됩니다.

같은 위치에 다른 컴포넌트가 오면 상태는 초기화됩니다 {/different-components-at-the-same-position-reset-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>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

여기서는 똑같은 자리에서 완전히 다른 종류의 컴포넌트 타입 간에 교체가 일어나고 있어요. 처음에는 <div>의 첫 번째 자식으로 Counter가 있었죠. 하지만 그 자리를 p 태그로 바꿔치기하는 순간, React는 기존의 Counter를 UI 트리에서 빼버리면서 그 녀석이 가지고 있던 상태도 모조리 파괴해 버려요.

Counter가 p 태그로 바뀌면, Counter는 삭제되고 p 태그가 새로 추가됩니다.

다시 스위치를 끄면, p 태그가 삭제되고 Counter가 새로 추가되면서 상태가 0이 됩니다.

추가로 알아두셔야 할 점! 어떤 위치에 다른 종류의 컴포넌트를 렌더링하면, 그 아래에 딸려 있던 자식 컴포넌트 트리의 상태까지 전부 다 초기화돼요. 어떻게 작동하는지 보려면 아래 예시에서 카운터를 올린 다음 체크박스를 틱! 눌러보세요.

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>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

분명 <Counter /> 컴포넌트 자체는 똑같이 렌더링하고 있는데 상태가 날아갔죠? 이유가 뭘까요? 자세히 보시면 바깥을 감싸고 있는 최상위 div의 첫 번째 자식이 section 태그에서 div 태그로 바뀌었기 때문이에요. 상위 부모인 section이 DOM에서 삭제되는 순간, 그 아래에 주렁주렁 매달려 있던 전체 트리 (당연히 안쪽의 Counter와 그 상태까지 포함해서요!)가 한꺼번에 파괴되어 버리는 거랍니다.

section이 div로 교체될 때, section은 물론 그 하위 트리가 파괴되고 새로운 div 트리가 추가됩니다.

다시 원래대로 전환할 때, div가 파괴되고 새로운 section이 자식과 함께 추가됩니다.

정리하자면! 리렌더링 간에 상태를 온전히 보존하고 싶다면, 이전 렌더링과 다음 렌더링에서 만들어지는 트리의 '구조'가 딱 맞아떨어져야 해요. 구조가 조금이라도 달라지면? React는 트리의 요소를 지울 때 상태도 쿨하게 지워버리기 때문에 상태가 날아가는 거예요.

이 부분은 실무에서도 신입 개발자분들이 정말 많이 하는 치명적인 실수예요! 바로 컴포넌트 함수를 중첩해서 정의하지 마세요.

아래 코드를 보세요. MyTextField라는 컴포넌트 함수가 MyComponent 함수 안에 쏙 들어가 있죠? (이런 구조는 절대 피하셔야 해요!)

// {expectedErrors: {'react-compiler': [7]}}
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>
    </>
  );
}

버튼을 누를 때마다 힘들게 입력한 input의 텍스트가 휙휙 날아가는 마술을 보셨나요? 왜냐하면 MyComponent가 리렌더링될 때마다 그 내부에 정의된 MyTextField 함수가 완전히 새로 만들어지기 때문이에요. React 입장에서는 이전 렌더링의 MyTextField와 방금 새로 만든 MyTextField를 '같은 자리에 있는 서로 다른 종류의 컴포넌트'로 인식해 버려요. 그래서 그 아래의 모든 상태를 싹 리셋해버리는 거죠. 이건 치명적인 버그와 성능 저하의 주범입니다.

이런 불상사를 피하려면 언제나 컴포넌트 함수는 파일의 최상위 레벨(top level)에서 선언하시고, 절대 다른 컴포넌트 안에 중첩해서 정의하지 마세요.

같은 위치에서 상태 초기화하기 {/resetting-state-at-the-same-position/}

기본적으로 React는 같은 위치에 동일한 컴포넌트가 머물러 있다면 상태를 보존해 준다고 했죠. 사실 99%의 경우에는 이 기본 동작이 우리가 원하는 바이기 때문에 아주 합리적입니다. 하지만 때로는 우리가 의도적으로 "아니야, 같은 컴포넌트라도 상태를 싹 초기화해줘!" 라고 지시하고 싶을 때가 있어요.

두 명의 플레이어가 각자의 턴마다 점수를 기록하는 앱을 예로 들어볼게요:

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>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}

지금은 Next player 버튼을 눌러도 점수가 그대로 유지되고 있죠? 왜냐하면 두 Counter 컴포넌트가 UI 트리의 딱 같은 위치에 등장하고 있기 때문이에요. React는 "오, 똑같은 Counter네. 전달받는 person prop만 바뀌었을 뿐이야!" 라고 생각하고 상태를 그대로 유지합니다.

하지만 기획 의도상으로는 이 두 카운터가 완전히 독립적이어야 맞아요. 비록 UI상에서 화면의 같은 자리에 렌더링되지만, 하나는 Taylor의 점수판이고 다른 하나는 Sarah의 점수판이니까요!

플레이어를 전환할 때 이 점수를 초기화시키는 데는 2가지 방법이 있습니다. 같이 살펴볼까요?

  1. 컴포넌트를 아예 다른 위치에 렌더링하기
  2. 각 컴포넌트에게 명시적인 신분증인 key 부여하기

방법 1: 컴포넌트를 서로 다른 위치에 렌더링하기 {/option-1-rendering-a-component-in-different-positions/}

만약 두 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>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}

코드를 살짝 바꿨을 뿐인데 잘 동작하죠?

  • 처음에는 isPlayerAtrue입니다. 그래서 첫 번째 위치에는 Counter가 들어가서 상태를 가지지만, 두 번째 위치는 텅 비어있어요.
  • "Next player" 버튼을 누르면 반대로 첫 번째 위치가 텅 비고, 두 번째 위치에 Counter가 새롭게 등장합니다.

초기 상태 (Initial state)

"next" 버튼 클릭 시

"next" 버튼 다시 클릭 시

Counter는 화면의 DOM에서 제거될 때마다 자기 상태를 함께 파괴해버려요. 그래서 버튼을 누를 때마다 상태가 0으로 깨끗하게 초기화되는 겁니다.

이 해결책은 같은 자리에 렌더링해야 할 독립적인 컴포넌트가 두세 개 정도로 적을 때 아주 간편하고 좋아요. 지금은 2개뿐이니까 JSX에서 두 자리를 따로 만들어줘도 전혀 부담스럽지 않죠.

방법 2: key를 이용해서 상태 초기화하기 {/option-2-resetting-state-with-a-key/}

자, 여기서 React의 정말 강력하고 포괄적인 무기가 등장합니다. 바로 컴포넌트 상태를 초기화할 수 있는 또 다른 방법이에요.

아마 리스트 렌더링하기(rendering lists) 파트에서 key를 보신 적이 있을 텐데요. key는 단순히 리스트 항목을 나열할 때만 쓰는 게 아니랍니다! 여러분은 key를 통해서 React에게 "이 컴포넌트는 다른 컴포넌트와 확실히 구분되는 녀석이야"라고 알려줄 수 있어요.

기본적으로 React는 "부모 안에서의 순서 (첫 번째 카운터, 두 번째 카운터)"를 기준으로 컴포넌트를 식별해요. 하지만 key를 부여하면, 이 카운터가 단순한 첫 번째 카운터나 두 번째 카운터가 아니라 특정한 고유 카운터, 예를 들어 Taylor 전용 카운터라는 걸 React에게 단단히 못 박아 알려줄 수 있습니다. 이렇게 하면, 트리 안 어디에 나타나더라도 React는 "아! Taylor의 카운터구나!" 하고 알게 됩니다.

아래 예시를 볼까요? 두 <Counter />가 코드상 완전히 같은 자리에 있지만 상태를 서로 공유하지 않습니다. 바로 key 때문이죠:

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>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}

Taylor와 Sarah를 왔다 갔다 전환해 봐도 점수가 유지되지 않고 깔끔하게 0부터 시작하죠? 그 비밀은 바로 서로 다른 key 값을 부여했기 때문이에요!

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

key를 명시적으로 주면, React는 부모 안에서의 '순서' 대신 여러분이 준 그 key 자체를 컴포넌트의 고유 위치를 식별하는 데 사용합니다. 그래서 JSX 코드상 같은 자리에 적혀 있더라도 React 눈에는 이 둘이 완전히 다른 카운터로 보이게 되고, 절대 상태를 공유하지 않게 되는 거죠. 특정 카운터가 화면에 나타날 때마다 상태가 새로 만들어지고, 화면에서 사라질 때마다 파괴됩니다. 그래서 전환할 때마다 계속 리셋되는 거예요.

이 점은 꼭 알아두세요! 여기서 사용하는 key는 애플리케이션 전체에서 전역적으로 고유할 필요는 없어요. 오직 특정 부모 컴포넌트 내부에서의 위치를 구별하는 데만 쓰인답니다.

key를 사용해서 폼(Form) 상태 초기화하기 {/resetting-a-form-with-a-key/}

key를 통해 상태를 초기화하는 이 기법은 특히 실무에서 폼(Form)을 다룰 때 그야말로 빛을 발합니다.

아래 채팅 앱 예시를 보시면, <Chat> 컴포넌트는 사용자가 입력하는 텍스트 입력 상태를 관리하고 있어요:

// src/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' }
];
// src/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>
  );
}
// src/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>
  );
}
.chat, .contact-list {
  float: left;
  margin-bottom: 20px;
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

자, 채팅창에 뭔가 입력해 보시고, 수신자를 "Alice"나 "Bob"으로 바꿔보세요. 엇! 분명 사람을 바꿨는데 방금 적은 텍스트가 그대로 남아있죠? <Chat> 컴포넌트가 트리 내 같은 위치에서 렌더링되고 있어서 상태가 쭉 유지되기 때문이에요.

많은 앱에서는 이런 동작이 좋은 경험일 수 있지만, 채팅 앱에서는 최악의 경험입니다! 자칫 잘못 클릭했다가 내가 썼던 비밀 메시지를 엉뚱한 사람에게 전송해버리는 대형 사고가 날 수 있잖아요. 이 문제를 깔끔하게 해결하려면 key를 하나 톡 넣어주시면 됩니다:

<Chat key={to.id} contact={to} />

key 한 줄만 있으면, 수신자를 바꿀 때마다 Chat 컴포넌트가 말 그대로 처음부터 다시 만들어집니다. 그 아래에 딸려 있는 트리 전체의 상태도 당연히 싹 지워지겠죠. 게다가 React가 이전 DOM 요소들을 재사용하지 않고 쿨하게 새로 DOM 요소들을 만들어 줍니다.

이제 수신자를 바꾸면 텍스트 필드가 아주 깔끔하게 비워지는 걸 확인해 보세요:

// src/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' }
];
// src/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>
  );
}
// src/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>
  );
}
.chat, .contact-list {
  float: left;
  margin-bottom: 20px;
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

화면에서 사라진 컴포넌트의 상태를 어떻게 보존할 수 있을까요? {/preserving-state-for-removed-components/}

진짜 실무에서 쓰이는 잘 만든 채팅 앱이라면, 사용자가 다른 사람과 채팅하다가 원래 채팅하던 사람으로 다시 돌아왔을 때 이전에 입력 중이던 메시지를 기특하게 복구해 주면 정말 좋겠죠? 화면에 더 이상 보이지 않게 된 컴포넌트의 상태를 "살려두는" 몇 가지 유용한 전략을 소개해 드릴게요:

  • CSS로 숨기기: 현재 채팅 중인 창 하나만 띄우는 게 아니라, 보이지 않더라도 모든 채팅창을 다 렌더링해 둔 다음 CSS(예: display: none)로 숨겨두는 방법이에요. 트리에 컴포넌트가 계속 남아있기 때문에 각자의 지역 상태(local state)가 무사히 보존되죠. 구조가 단순한 앱이라면 아주 훌륭한 방법입니다. 하지만 숨겨진 트리가 무겁고 DOM 노드가 엄청 많다면 앱이 꽤 느려질 수 있어요.
  • 상태 끌어올리기 (Lifting State Up): 이게 현업에서 가장 많이 쓰는 보편적인 방법이에요! 상태를 부모 컴포넌트로 한 단계 끌어올려서 각 수신자별로 작성 중인 메시지를 부모가 대신 기억하게 하는 거예요. 이렇게 하면 자식 컴포넌트가 화면에서 파괴되더라도 전혀 문제가 없죠. 중요한 데이터는 든든한 부모가 꽉 쥐고 있으니까요!
  • 외부 저장소 사용하기: React의 state 말고 완전히 다른 외부 소스를 활용하는 방법도 있습니다. 만약 사용자가 실수로 브라우저 탭을 꺼버려도 작성 중이던 메시지가 남아있기를 바란다면 어떻게 해야 할까요? 브라우저의 localStorage 같은 곳에 데이터를 저장해 두고, Chat 컴포넌트가 마운트될 때 거기서 데이터를 읽어와 상태를 초기화하도록 구현하면 됩니다.

여러분이 어떤 멋진 전략을 선택하시든, Alice와의 채팅Bob과의 채팅은 개념적으로 완전히 다른 독립된 공간이에요. 그러니까 현재 수신자를 바탕으로 <Chat> 트리에 key를 꼭 달아주는 게 구조적으로 올바른 선택이랍니다.

자, 오늘 배운 내용의 핵심을 가볍게 요약해 볼까요!

  • React는 같은 위치에 동일한 컴포넌트가 렌더링되는 한 그 상태를 끈질기게 보존해 줍니다.
  • 상태는 여러분이 작성한 JSX 코드 조각(태그) 안에 들어있는 게 아니에요. 여러분이 렌더 트리 상에 그 JSX를 툭 던져놓은 바로 그 '위치'와 연결되어 있는 겁니다.
  • 컴포넌트에 다른 key를 슬쩍 넘겨주면, 그 아래의 전체 자식 트리까지 싹 다 리셋하라고 강제할 수 있습니다. 폼 초기화할 때 꿀팁이죠!
  • 절대, 네버! 컴포넌트 안에 다른 컴포넌트를 중첩해서 선언하지 마세요. 의도치 않게 계속 상태가 날아가는 무서운 버그를 만나게 될 거예요.

자, 배운 걸 테스트해 볼 시간입니다!

사라지는 입력창 텍스트 수정해 보기 {/fix-disappearing-input-text/}

아래 예시는 버튼을 누르면 힌트 메시지가 뿅 나타나는 기능입니다. 그런데 문제는... 버튼을 누르면 입력창에 애써 적어둔 텍스트도 같이 사라져버린다는 거예요! 왜 이런 일이 발생하는 걸까요? 힌트 버튼을 눌러도 입력한 텍스트가 사라지지 않도록 코드를 직접 수정해 보세요.

// src/App.js
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)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

원인은 간단해요. 두 if-else 분기에서 Form 컴포넌트가 트리 상 서로 다른 위치에 렌더링되고 있기 때문입니다.

if 분기에서는 Form<div>의 두 번째 자식 위치에 있죠 (첫 번째는 p 태그니까요). 반면 else 분기에서는 Form<div>의 첫 번째 자식 위치를 차지하고 있어요. 즉, 버튼을 누를 때마다 첫 번째 자식 위치에는 pForm이 번갈아 등장하고, 두 번째 자식 위치에는 Formbutton이 번갈아 등장하며 컴포넌트 타입이 바뀌고 있었던 거예요. 이러면 React는 얄짤없이 컴포넌트가 변경되었다고 생각하고 매번 상태를 다 날려버립니다.

가장 깔끔한 해결책은 분기를 합쳐서 Form 컴포넌트가 언제나 정확히 같은 위치에 렌더링되게 만들어주는 거예요. 이렇게 말이죠!

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

export default function App() {
  const [showHint, setShowHint] = useState(false);
  return (
    <div>
      {showHint &&
        <p><i>Hint: Your favorite city?</i></p>
      }
      <Form />
      {showHint ? (
        <button onClick={() => {
          setShowHint(false);
        }}>Hide hint</button>
      ) : (
        <button onClick={() => {
          setShowHint(true);
        }}>Show hint</button>
      )}
    </div>
  );
}

function Form() {
  const [text, setText] = useState('');
  return (
    <textarea
      value={text}
      onChange={e => setText(e.target.value)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

기술적으로 본다면, else 분기의 <Form /> 태그 바로 앞에 억지로 null을 끼워 넣어서 if 분기와 구조를 똑같이 맞추는 꼼수도 가능은 해요:

// src/App.js
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>
      {null}
      <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)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

이렇게 하면 Form이 항상 무사히 두 번째 자식 자리를 차지하게 되니까 상태가 유지됩니다. 하지만 이 방식은 코드를 읽었을 때 "도대체 여기서 null이 왜 들어간 거지?" 하고 굉장히 헷갈리게 만들어요. 누군가 무심코 저 null을 지워버리면 다시 버그가 발생하는 무서운 시한폭탄이 되니 권장하지 않습니다!

폼 필드 두 개 위치 바꿔보기 {/swap-two-form-fields/}

아래 폼은 이름(First name)과 성(Last name)을 입력받는 폼이에요. 그리고 체크박스를 누르면 두 필드의 렌더링 순서가 위아래로 바뀌게 되어 있어요.

거의 완벽하게 작동하는 것 같은데 치명적인 버그가 숨어 있습니다. 만약 "First name" 필드에 글자를 막 입력한 다음 체크박스를 틱 누르면... 내가 쓴 글자는 그대로 첫 번째 입력창에 남아버리고 필드 이름만 "Last name"으로 바뀌어 버려요. 내 글자가 엉뚱한 필드로 이사 가버린 셈이죠!

체크박스로 위아래 순서를 바꿀 때, 내가 입력했던 텍스트도 필드에 맞게 같이 이동하도록 버그를 고쳐주세요.

단순히 부모 컴포넌트 내부의 '위치 순서'만으로는 이 두 필드의 상태를 올바르게 매칭할 수 없는 상황이네요. 혹시 React에게 "이 녀석은 First name 필드고, 쟤는 Last name 필드야"라고 확실하게 귀띔해 줄 방법이 떠오르지 않으시나요?

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

export default function App() {
  const [reverse, setReverse] = useState(false);
  let checkbox = (
    <label>
      <input
        type="checkbox"
        checked={reverse}
        onChange={e => setReverse(e.target.checked)}
      />
      Reverse order
    </label>
  );
  if (reverse) {
    return (
      <>
        <Field label="Last name" /> 
        <Field label="First name" />
        {checkbox}
      </>
    );
  } else {
    return (
      <>
        <Field label="First name" /> 
        <Field label="Last name" />
        {checkbox}
      </>
    );    
  }
}

function Field({ label }) {
  const [text, setText] = useState('');
  return (
    <label>
      {label}:{' '}
      <input
        type="text"
        value={text}
        placeholder={label}
        onChange={e => setText(e.target.value)}
      />
    </label>
  );
}
label { display: block; margin: 10px 0; }

빙고! 두 분기의 <Field> 컴포넌트들에 각각 고유한 key를 부여해 주면 됩니다. 이렇게 key를 달아주면, 부모 안에서 이 컴포넌트들의 위치(순서)가 뒤바뀌더라도 React가 어떤 컴포넌트의 상태를 어디로 연결해야 할지 확실하게 찾아갈 수 있거든요:

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

export default function App() {
  const [reverse, setReverse] = useState(false);
  let checkbox = (
    <label>
      <input
        type="checkbox"
        checked={reverse}
        onChange={e => setReverse(e.target.checked)}
      />
      Reverse order
    </label>
  );
  if (reverse) {
    return (
      <>
        <Field key="lastName" label="Last name" /> 
        <Field key="firstName" label="First name" />
        {checkbox}
      </>
    );
  } else {
    return (
      <>
        <Field key="firstName" label="First name" /> 
        <Field key="lastName" label="Last name" />
        {checkbox}
      </>
    );    
  }
}

function Field({ label }) {
  const [text, setText] = useState('');
  return (
    <label>
      {label}:{' '}
      <input
        type="text"
        value={text}
        placeholder={label}
        onChange={e => setText(e.target.value)}
      />
    </label>
  );
}
label { display: block; margin: 10px 0; }

상세 정보 폼 리셋하기 {/reset-a-detail-form/}

이 예시는 선택한 연락처의 상세 정보를 수정할 수 있는 멋진 연락처 관리 앱입니다. 연락처를 선택해서 정보를 수정한 다음 "Save"를 누르면 반영되고, "Reset"을 누르면 수정한 내역이 취소되죠.

그런데 문제가 있어요. 한 사람(예: Taylor)의 정보를 수정하다가 갑자기 마음이 바뀌어 옆 사람(예: Alice)을 클릭하면, 선택된 상태는 Alice로 바뀌지만 아래쪽 수정 폼에는 방금 Taylor 쪽에 입력하다 만 텍스트가 그대로 남아 있습니다.

선택된 연락처가 바뀔 때마다 아래쪽 수정 폼이 깔끔하게 리셋되도록 수정해 보세요!

// src/App.js
import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        initialData={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Taylor', email: 'taylor@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];
// src/ContactList.js
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
// src/EditContact.js
import { useState } from 'react';

export default function EditContact({ initialData, onSave }) {
  const [name, setName] = useState(initialData.name);
  const [email, setEmail] = useState(initialData.email);
  return (
    <section>
      <label>
        Name:{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        Email:{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: initialData.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Save
      </button>
      <button onClick={() => {
        setName(initialData.name);
        setEmail(initialData.email);
      }}>
        Reset
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

앞에서 배운 채팅 앱 예시와 완벽하게 똑같은 원리입니다! EditContact 컴포넌트에 key={selectedId}를 살포시 추가해 보세요. 이렇게 하면 선택된 사람이 바뀔 때마다 React가 "아, 전혀 다른 연락처의 수정 폼이구나!" 하고 폼 전체를 시원하게 리셋해 줄 거예요.

// src/App.js
import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        key={selectedId}
        initialData={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Taylor', email: 'taylor@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];
// src/ContactList.js
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
// src/EditContact.js
import { useState } from 'react';

export default function EditContact({ initialData, onSave }) {
  const [name, setName] = useState(initialData.name);
  const [email, setEmail] = useState(initialData.email);
  return (
    <section>
      <label>
        Name:{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        Email:{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: initialData.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Save
      </button>
      <button onClick={() => {
        setName(initialData.name);
        setEmail(initialData.email);
      }}>
        Reset
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

이미지가 로드되는 동안 기존 이미지 지우기 {/clear-an-image-while-its-loading/}

갤러리 앱에서 "Next" 버튼을 누르면 브라우저가 다음 이미지를 로드하기 시작하죠. 그런데 동일한 <img> 태그를 재사용하고 있기 때문에, 기본적으로는 다음 이미지가 인터넷을 타고 다 넘어올 때까지 이전 화면의 이미지가 멍하니 그대로 떠 있게 됩니다. 만약 텍스트 설명과 이미지가 찰떡같이 맞아떨어져야 하는 앱이라면 이 딜레이 때문에 사용자에게 혼동을 줄 수 있어요.

"Next" 버튼을 누르는 즉시, 예전 이미지가 싹 사라지고 깔끔하게 비워진 상태에서 다음 이미지를 기다리게 만들어 보세요.

React가 DOM 요소를 굳이 재사용하지 않고 말끔히 부순 다음 새로 만들게(re-create) 강제하는 마법의 키워드, 기억하시죠?

import { useState } from 'react';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const hasNext = index < images.length - 1;

  function handleClick() {
    if (hasNext) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  let image = images[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h3>
        Image {index + 1} of {images.length}
      </h3>
      <img src={image.src} />
      <p>
        {image.place}
      </p>
    </>
  );
}

let images = [{
  place: 'Penang, Malaysia',
  src: '[https://i.imgur.com/FJeJR8M.jpg](https://i.imgur.com/FJeJR8M.jpg)'
}, {
  place: 'Lisbon, Portugal',
  src: '[https://i.imgur.com/dB2LRbj.jpg](https://i.imgur.com/dB2LRbj.jpg)'
}, {
  place: 'Bilbao, Spain',
  src: '[https://i.imgur.com/z08o2TS.jpg](https://i.imgur.com/z08o2TS.jpg)'
}, {
  place: 'Valparaíso, Chile',
  src: '[https://i.imgur.com/Y3utgTi.jpg](https://i.imgur.com/Y3utgTi.jpg)'
}, {
  place: 'Schwyz, Switzerland',
  src: '[https://i.imgur.com/JBbMpWY.jpg](https://i.imgur.com/JBbMpWY.jpg)'
}, {
  place: 'Prague, Czechia',
  src: '[https://i.imgur.com/QwUKKmF.jpg](https://i.imgur.com/QwUKKmF.jpg)'
}, {
  place: 'Ljubljana, Slovenia',
  src: '[https://i.imgur.com/3aIiwfm.jpg](https://i.imgur.com/3aIiwfm.jpg)'
}];
img { width: 150px; height: 150px; }

간단합니다! <img> 태그에 key 속성을 쥐여주면 해결돼요. key 값이 변경될 때마다 React는 부지런하게 이전 <img> DOM 노드를 부수고 완전히 새로운 빈 태그부터 다시 시작합니다. 이미지가 넘어오는 동안 잠깐 화면이 번쩍 깜빡이게(flash) 되기 때문에 보통 앱의 모든 이미지에 이런 방식을 쓰진 않아요. 하지만 지금처럼 화면의 텍스트와 이미지가 꼬이지 않게 동기화하는 것이 매우 중요한 상황이라면 이 방법이 아주 훌륭한 해답이 됩니다.

import { useState } from 'react';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const hasNext = index < images.length - 1;

  function handleClick() {
    if (hasNext) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  let image = images[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h3>
        Image {index + 1} of {images.length}
      </h3>
      <img key={image.src} src={image.src} />
      <p>
        {image.place}
      </p>
    </>
  );
}

let images = [{
  place: 'Penang, Malaysia',
  src: '[https://i.imgur.com/FJeJR8M.jpg](https://i.imgur.com/FJeJR8M.jpg)'
}, {
  place: 'Lisbon, Portugal',
  src: '[https://i.imgur.com/dB2LRbj.jpg](https://i.imgur.com/dB2LRbj.jpg)'
}, {
  place: 'Bilbao, Spain',
  src: '[https://i.imgur.com/z08o2TS.jpg](https://i.imgur.com/z08o2TS.jpg)'
}, {
  place: 'Valparaíso, Chile',
  src: '[https://i.imgur.com/Y3utgTi.jpg](https://i.imgur.com/Y3utgTi.jpg)'
}, {
  place: 'Schwyz, Switzerland',
  src: '[https://i.imgur.com/JBbMpWY.jpg](https://i.imgur.com/JBbMpWY.jpg)'
}, {
  place: 'Prague, Czechia',
  src: '[https://i.imgur.com/QwUKKmF.jpg](https://i.imgur.com/QwUKKmF.jpg)'
}, {
  place: 'Ljubljana, Slovenia',
  src: '[https://i.imgur.com/3aIiwfm.jpg](https://i.imgur.com/3aIiwfm.jpg)'
}];
img { width: 150px; height: 150px; }

엉뚱하게 따라가는 리스트의 상태 고치기 {/fix-misplaced-state-in-the-list/}

이 예시는 연락처 리스트인데, 각 Contact 안에는 이메일 보기 버튼을 눌렀는지 여부를 기억하는 작은 상태(state)가 들어 있어요.

먼저 Alice의 "Show email" 버튼을 눌러서 이메일 주소를 열어보세요. 그런 다음, 위쪽의 "Show in reverse order" 체크박스를 눌러서 정렬 순서를 뒤집어 보세요. 자, 어떻게 되나요? 분명 Alice를 열었는데 엉뚱하게 Taylor의 이메일 창이 열려 있고, 리스트 맨 아래로 내려간 정작 Alice의 창은 굳게 닫혀 있습니다!

배열의 순서가 요리조리 바뀌더라도, "열림" 상태가 엉뚱한 사람에게 씌워지지 않고 진짜 그 연락처 주인을 찾아가도록 코드를 수정해 보세요.

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

export default function ContactList() {
  const [reverse, setReverse] = useState(false);

  const displayedContacts = [...contacts];
  if (reverse) {
    displayedContacts.reverse();
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={reverse}
          onChange={e => {
            setReverse(e.target.checked)
          }}
        />{' '}
        Show in reverse order
      </label>
      <ul>
        {displayedContacts.map((contact, i) =>
          <li key={i}>
            <Contact contact={contact} />
          </li>
        )}
      </ul>
    </>
  );
}

const contacts = [
  { id: 0, name: 'Alice', email: 'alice@mail.com' },
  { id: 1, name: 'Bob', email: 'bob@mail.com' },
  { id: 2, name: 'Taylor', email: 'taylor@mail.com' }
];
// src/Contact.js
import { useState } from 'react';

export default function Contact({ contact }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <>
      <p><b>{contact.name}</b></p>
      {expanded &&
        <p><i>{contact.email}</i></p>
      }
      <button onClick={() => {
        setExpanded(!expanded);
      }}>
        {expanded ? 'Hide' : 'Show'} email
      </button>
    </>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li {
  margin-bottom: 20px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

원인이 뭘까요? 범인은 바로 배열의 인덱스(i)를 key로 사용했기 때문입니다:

{displayedContacts.map((contact, i) =>
  <li key={i}>

리스트의 순서를 뒤집었으니 Alice는 배열의 인덱스 0번에서 2번으로, Taylor는 인덱스 2번에서 0번으로 순서가 바뀌었겠죠? 그런데 React는 "음, 인덱스 번호 key=0의 상태는 열림(expanded=true)이었지. 그러니까 0번 자리에 새로 들어온 Taylor를 열어줘야겠다!" 하고 착각해버린 겁니다. 우리가 원하는 건 그저 인덱스 번호가 아니라 그 연락처 고유의 상태를 유지하는 것인데 말이죠.

해결책은 배열 순서에 휘둘리는 인덱스 대신, 변하지 않는 고유한 값인 연락처의 idkey로 사용하는 것입니다. 이러면 문제가 깔끔하게 싹 해결돼요:

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

export default function ContactList() {
  const [reverse, setReverse] = useState(false);

  const displayedContacts = [...contacts];
  if (reverse) {
    displayedContacts.reverse();
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={reverse}
          onChange={e => {
            setReverse(e.target.checked)
          }}
        />{' '}
        Show in reverse order
      </label>
      <ul>
        {displayedContacts.map(contact =>
          <li key={contact.id}>
            <Contact contact={contact} />
          </li>
        )}
      </ul>
    </>
  );
}

const contacts = [
  { id: 0, name: 'Alice', email: 'alice@mail.com' },
  { id: 1, name: 'Bob', email: 'bob@mail.com' },
  { id: 2, name: 'Taylor', email: 'taylor@mail.com' }
];
// src/Contact.js
import { useState } from 'react';

export default function Contact({ contact }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <>
      <p><b>{contact.name}</b></p>
      {expanded &&
        <p><i>{contact.email}</i></p>
      }
      <button onClick={() => {
        setExpanded(!expanded);
      }}>
        {expanded ? 'Hide' : 'Show'} email
      </button>
    </>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li {
  margin-bottom: 20px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

이렇듯 상태는 언제나 렌더 트리의 위치와 결합되어 있어요. key를 달아주면 단순히 겉보기에 나타난 순서에 의존하는 대신, 우리가 직접 React에게 "이 녀석은 고유한 이 위치의 녀석이야"라고 이름을 붙여줄 수 있다는 사실, 잊지 마세요!


사이트맵

모든 문서 페이지 개요

profile
프론트에_가까운_풀스택_개발자

0개의 댓글