React 22. Preserving and Resetting State

뚜루미·2024년 3월 28일

React

목록 보기
22/39
post-thumbnail

상태는 컴포넌트 간에 격리됩니다. React는 UI 트리에서의 위치에 따라 어떤 상태가 어떤 컴포넌트에 속하는지 추적합니다. 상태를 보존할 시기와 리렌더링 사이에 재설정 시기를 제어할 수 있습니다.

State is tied to a position in the render tree

React는 UI의 컴포넌트 구조에 대한 렌더링 트리를 구축합니다.

컴포넌트 상태를 제공할 때 상태가 컴포넌트 내부에 있다고 생각할 수 있습니다. 그러나 상태는 실제로 React 내부에 유지됩니다. React는 보유하고 있는 각 상태를 해당 컴포넌트가 렌더링 트리의 위치에 따라 컴포넌트와 연관시킵니다.

여기에는 JSX 태그 <Counter /> 가 하나만 있지만 두 가지 다른 위치에서 렌더링됩니다.

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>
  );
}

트리 모양은 다음과 같습니다.

각각은 트리의 자체 위치에 렌더링되므로 이는 두 개의 별도 카운터입니다. 일반적으로 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>
  );
}

보시다시피 하나의 카운터가 업데이트되면 해당 컴포넌트의 상태만 업데이트됩니다.

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)
          }}
        />
        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>
  );
}

두 번째 카운터의 렌더링을 중지하는 순간 해당 상대가 완전히 사라집니다. React가 컴포넌트를 제거하면 해당 상태도 파괴되기 때문입니다.

"두 번째 카운터 렌더링"을 선택하면 두 번째 카운터 Counter 와 해당 상태가 처음부터 초기화되고( score = 0 ) DOM에 추가됩니다.

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>
  );
}

확인란을 체크하거나 언체크하여도 카운터 상태가 재설정되지 않습니다. isFancytrue 또는 false 의 여부에 관계없이 루트 App 컴포넌트에서 반환된 요소의 첫번째 하위 항목 div 는 항상 <Counter /> 를 갖습니다.

App 상태를 업데이트하는 것은 Counter 를 리셋하지 않습니다. Counter 는 같은 위치에 유지되기 때문입니다.

같은 위치에 있는 같은 컴포넌트이므로 React의 관점에서는 같은 카운터입니다.

React에 중요한 것은 JSX 마크업이 아닌 UI 트리의 위치라는 점을 기억하세요. 이 컴포넌트에는 내부와 외부에 서른 다른 JSX 태그 <Counter /> 가 있는 반환 if 절이 있습니다.

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 /> 태그가 모두 동일한 위치에 렌더링되기 때문입니다. React는 함수에서 조건을 어디에 배치하는지 모릅니다. 그것을 보는 것은 렌더링 트리 뿐입니다.

두 경우 모두 컴포넌트 App<Counter /> 를 첫번째 자식으로 하여 <div> 를 반환합니다. React에서 이 두 카운터는 동일한 주소를 갖습니다.이는 로직을 구성하는 방식에 관계없이 React가 이전 렌더링과 다음 렌더링 간에 일치시키는 방법입니다.

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>
  );
}

여기에서는 동일한 위치에서 다양한 컴포넌트 간 전환이 이루어집니다. 처음에는 <Counter> 를 포함하는 <div> 가 첫번째 자식으로 구성되지만 p 로 변경하면 React는 상태를 파괴하고 Counter 를 UI 트리에서 제거합니다.

Counterp 로 변경되면, Counter 는 지워지고 p 가 추가됩니다.

다시 전환하면, p가 지워지고 Counter가 추가됩니다.

또한, 같은 위치에 다른 컴포넌트를 렌더링하면, 이는 모든 서브트리의 상태를 리셋합니다. 이것이 어떻게 작동하지 보기 위하여 카운터를 증가시킨 다음 체크박스를 클릭하십시오.

체크박스를 클릭하면 카운터의 상태는 리셋됩니다. Counter 를 렌더링하더라도 div 의 첫번째 자식이 div 에서 section 으로 변경됩니다. 자식노드 div 는 DOM 에서 제거되고 모든 트리의 하위 항목(Counter 와 상태 포함)해서 파괴됩니다.

section을(를) div 로 변경 하면 section 이 삭제되고 새로운 div 가 추가됩니다.

다시 전환하면 div 가 삭제되고 새 항목 section이 추가됩니다.

재렌더링 사이에 상태를 유지하려면 트리 구조가 한 렌더에서 다른 렌더로 "일치"되어야 합니다. 구조가 다르면 React가 트리에서 컴포넌트를 제거할 때 상태를 파괴하기 때문에 상태가 파괴됩니다.

이것이 컴포넌트 함수 정의를 중첩하면 안되는 이유입니다.

여기에는 컴포넌트 MyComponent 내부에 함수 MyTextField 가 정의됩니다.

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가 생성되기 때문입니다. 동일한 위치에 다른 컴포넌트를 렌더링하므로 React는 아래의 모든 상태를 재설정합니다. 이로 인해 버그와 성능문제가 발생합니다. 이러한 문제를 방지하려면 항상 최상위 수준에서 컴포넌트 함수를 선언하고 해당 정의를 중첩하지 않아야 합니다.

Resetting state at the same position

기본적으로 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);
      }}>
        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 는 같은 위치에 나타나기 때문에 React는 person prop이 변경되어도 이들은 같은 Counter 로 인지합니다.

하지만, 개념적으로 해당 앱에서 이들은 두 개의 별개의 카운터여야 합니다. 이들은 UI 상 같은 위치에 나타나지만, 하나는 Taylor의 나머지는 Sarah의 카운터이기 때문입니다.

상태를 전환할 때 상태를 재설정하는 방법에는 두 가지가 있습니다.

  1. 다양한 위치에서 컴포넌트 렌더링
  2. key 를 사용하여 각 컴포넌트에 대한 명시적인 ID 부여

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>
  );
}
  • 초기에는 isPlayerAtrue 입니다. 따라서 첫 번째 위치에는 Counter 의 상태가 포함되고 두 번째 위치에는 비어 있습니다.
  • “다음 플레이어" 버튼을 클릭하면 첫 번째 위치가 지워지지만 Counter 를 포함하는 두번 째 Counter 가 생성됩니다.

Counter 의 상태는 DOM에서 제거될 때마다 파괴됩니다. 이것이 버튼을 클릭할 때마다 재설정되는 이유입니다.

이 솔루션은 동일한 위치에 렌더링된 독립 컴포넌트가 몇 개 뿐인 경우에 편리합니다. 이 에에서는 두 개만 있으므로 JSX에서 두 개를 별도로 렌더링하는 것이 번거롭지 않습니다.

Option 2: Resetting state with a key

컴포넌트의 상태를 재설정하는 또 다른 보다 일반적인 방법도 존재합니다.

목록을 렌더링할 때 key 를 본 적이 있을 것입니다. 키는 단지 목록만을 위한 것이 아니고 React에서 키를 사용하여 모든 컴포넌트를 구별하도록 할 수 있습니다. 기본적으로 React는 컴포넌트를 식별하기 위해 상위 항목 내의 순서를 사용합니다. 하지만 키를 사용하면 이것이 단순한 첫 번째 카운터나 두 번째 카운터가 아니라 특정 카운터임(예: Taylor의 카운터)을 React에게 알릴 수 있습니다. 이런 식으로 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);
      }}>
        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 를 지정하면 React는 상위 항목 내의 순서 대신 위치의 일부로 key 를 사용합니다. 이것이 바로 JSX에서 같은 위치에 렌더링하더라도 React가 이를 두 개의 다른 카운터로 간주하여 상태를 공유하지 않는 이유입니다. 카운터가 화면에 나타날때 마다 해당 상태가 생성됩니다. 제거될 때마다 해당 상태는 파괴되면 전환되면 상태가 계속해서 재설정됩니다.

키는 전역적으로 고유하지 않으며 상위 항목 내의 위치만 지정할 수 있습니다.

Resetting a form with a key

키를 사용하여 상태를 재설정하는 것은 양식을 처리할 때 특히 유용합니다.

이 채팅 앱의 <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 아래 트리의 모든 상태를 포함하여 컴포넌트가 처음부터 다시 생성됩니다. React는 DOM 요소를 재사용하는 대신 다시 생성합니다.

이제 수신자를 전환하면 텍스트 필드가 지워집니다.

Preserving state for removed components

실제 채팅 앱에서는 사용자가 이전 수신자를 다시 선택할 때 입력 상태를 복구하고 싶을 것입니다. 더 이상 표시되지 않는 컴포넌트이 상태를 “활성화 상태”로 유지하는 몇 가지 방법이 있습니다.

  • 현재 채팅 이외의 모든 채팅을 렌더링하면서 CSS를 사용하여 다른 모든 채팅을 숨길 수 있습니다. 채팅은 트리에서 제거되지 않으므로 지역 상태가 보존됩니다. 이 솔루션은 간단한 UI에는 적합할 수 있지만, 숨겨진 트리가 크고 많은 DOM 노드를 포함하면 속도가 매우 느려질 수 있습니다.
  • 상태를 하제하고 상위 컴포넌트의 각 수신자에 대하여 보류 주인 메시지를 보관하게 합니다. 이렇게 하면 하위 컴포넌트가 제거되더라고 중요한 정보를 상위 컴포넌트에서 유지할 수 있습니다. 이것이 가장 일반적인 솔루션입니다.
  • React 상태 외에 다른 소스를 사용할 수도 있습니다. 예를 들어 사용자가 실수로 페이지를 닫은 경우에도 메시지 초안이 유지되기를 원할 수 있습니다. 이를 구현하려면 localStorage 에서 Chat 을 읽어와 컴포넌트의 상태를 초기화하고 초안을 저장하도록 할 수 있습니다.

어떠한 전략을 선택하든 Alice와의 채팅은 개념적으로 Bob과의 채팅과는 다르므로 현재 수신자를 기준으로 <Chat> 트리에 key 를 전달하는 것이 합리적입니다.

0개의 댓글