https://react.dev/learn/preserving-and-resetting-state를 읽고 정리한다.

state는 컴포넌트 사이에 격리된다.

리액트는 UI 트리에서 어떤 컴포넌트가 어떤 state에 속해 있는지를 추적한다.

개발자는 리렌더링 사이에 state 보존(preserve) 또는 초기화(reset) 시기를 제어할 수 있다.

UI tree

브라우저들은 UI를 모델링하기위해 많은 트리 구조를 사용한다.

리액트 또한 UI를 관리하고 모델링하기위해 트리 구조를 사용한다.

  1. React는 JSX로부터 UI 트리를 만든다.
  2. 그런 다음 React DOM은 해당 UI와 일치하도록 브라우저 DOM 요소들을 업데이트한다.

컴포넌트로 리액트는 UI 트리를 생성하며, DOM을 렌더링하기 위해 React DOM이 UI 트리를 사용한다.

state는 트리 내 위치와 엮여있다.

컴포넌트 state가 주어지면, 컴포넌트 내부에 state가 "존재"한다고 생각할 수 있다. 하지만 state는 실제로 리액트 내부에 보관된다. 리액트는 UI 트리에서 해당 컴포넌트의 위치에 따라 보유하고 있는 각 state를 올바른 컴포넌트와 연결한다.

 

아래의 코드에서 <Counter /> JSX 태그는 오직 한번만 존재하지만, 서로 다른 위치에 렌더링된다.

export default function App() {
  const counter = <Counter />;
  return (
    <div>
      {counter}
      {counter}
    </div>
  );
}

 

트리로 보면 아래와 같다.

 

트리에서 각각 고유한 위치에 렌더링되기 때문에, 두 가지 Counter들은 분리되어 있다.

이는 일반적으로 리액트를 사용하기 위해 필수적으로 알아야하는 개념은 아니지만, 어떻게 동작하는지를 이해하는 데 유용하다.

 

리액트에서 화면의 각 컴포넌트는 완전히 격리된 state를 가진다.

  • 예를 들어, 두 Counter 컴포넌트를 나란히 렌더링하게 되면, 각각 고유한 독립적인 scorehover state를 가진다.

그래서 위의 코드 결과에서 Counter를 각각 눌러보면 서로 영향을 주지 않는 것을 확인할 수 있다.

 

리액트는 같은 위치에서 같은 컴포넌트를 렌더링하는 한 state는 유지된다.

이를 확인하기 위해 (1) 두 Counter를 증가시키고, (2)"Render the secound counter" 체크 박스에 체크를 없애 두번째 컴포넌트만 지운 후, (3) 다시 체크를 해서 두번째 컴포넌트를 추가해보자.

  • (2) 두 번째 컴포넌트에 대한 렌더링을 중단하는 순간, 해당 state는 완전히 사라진다!
    • 리액트는 컴포넌트가 지워지면 그 state도 사라지기 때문에 이러한 현상이 발생한다.
  • (3) 다시 체크 박스를 클릭하면, 두 번째 컴포넌트가 렌더링되고, 그 state가 처음부터 초기화되고 DOM에 추가된다.

 

즉, 리액트는 컴포넌트가 UI 트리에서 해당 위치에 렌더링되는 한 그 state를 보존한다. 만약 제거되거나 같은 위치에 다른 컴포넌트가 렌더링된다면, 리액트는 해당 컴포넌트의 state를 버린다.

동일 위치에 같은 컴포넌트가 있을 경우, state가 보존(유지)한다.

이 예제에서 두가지 다른 Counter tag가 있다.

 

체크 박스를 여러번 눌러도, counter state는 리셋되지 않는다. isFancytrue인지 false인지와 상관없이, 최상위 App 컴포넌트로부터 반환된 div의 첫번째 자식으로 항상 <Counter />를 가지기 때문이다.

 

App state를 변경하더라도 Counter는 동일한 위치에 존재하기 때문에 그 상태를 초기화하지 않느다.

Pitfall : 중요한 것은 JSX 마크업이 아니라 UI 트리에서의 위치라는 것을 기억하자!

아래 컴포넌트는 if를 기준으로 두가지 다른 <Counter />를 반환한다.

체크 박스를 누르면 state가 리셋되기를 기대하겠지만, 이는 틀렸다! 두 <Counter /> 태그가 모두 같은 위치에 렌더링되기 때문이다.

리액트는 함수 내 조건에 따라 어디에 위치하는 지를 알 수 없다. 리턴하는 tree 만 "볼 수"있다.

두 경우 모두 App 컴포넌트가 첫번째 자식으로 <Counter />를 가진 <div>를 리턴하기 때문에 리액트가 동일한 <Counter />로 간주한다.

루트의 첫번째 자식의 첫번째 자식이 같은 "주소"를 갖는다고 생각하면 된다.

이는 로직을 어떻게 구성하든 상관없이 리액트가 이전과 다음 렌더링을 매칭하는 방법이다.

동일한 위치에 있는 다른 컴포넌트가 있을 경우, state가 초기화된다.

동일한 위치에서 다른 컴포넌트(<p>)로 전환하는 것은 기존 컴포넌트를 제거 후 다른 컴포넌트를 렌더링하는 것과 같다.

  • 기존 컴포넌트를 제거할 때, 그 아래의 전체 트리(컴포넌트, state, ...)도 모두 제거된다.

즉, 동일한 위치에서 다른 컴포넌트로 전환하면 기존 컴포넌트의 state가 초기화된다.

  • 컴포넌트 타입이 달라도 동일하게 적용된다.

리렌더링 사이에 state를 유지하려면, 트리 구조가 렌더링할 때마다 일치해야한다.

  • 만약 구조가 다르다면, 위에서 말한 것처럼 리액트는 트리에서 컴포넌트가 제거될 때 state를 제거하므로 state는 제거된다.

예) 체크 박스를 클릭시 <Counter><p> 전환

카운터 내 버튼을 통해 카운터의 점수를 올린 후 체크 박스를 두 번 클릭하면, 카운터가 새로 렌더링될 때 점수는 0으로 초기화되는 것을 볼 수 있다.

pitfall : 컴포넌트 함수 정의를 중첩시키면 안된다.

  • 컴포넌트 함수 정의 중첩이란?
    • 컴포넌트 함수 정의 내부에 다른 컴포넌트 함수를 정의하는 것

문제점 : 의도치 않은 버그가 생기거나 성능 문제가 발생한다.

  • 컴포넌트 함수 정의가 중첩되면 상위 컴포넌트의 렌더링될 때(=상태 변경될 때)마다 내부에 정의된 컴포넌트 함수가 새로 생성된다. 이로 인해 내부에 정의된 컴포넌트의 상태가 초기화된다.이 때문에 버그나 성능 문제가 발생하게 된다.
  • 예) MyTextField 컴포넌트가 MyComponent 컴포넌트 내부에 정의
    • 버튼을 누르면 MyComponent 컴포넌트의 counter state가 변경되며 리렌더링이 발생한다. 이때 MyComponent가 새로 생성되면서, 내부의 input state가 초기화된다. 따라서 버튼을 누를 때마다 input state가 사라진다.

해결 방법 : 컴포넌트 함수를 최상위 레벨에서 선언하고 정의를 중첩시키지 않아야 한다.

 

동일한 위치에서 state를 초기화하기

기본적으로 리액트는 동일한 위치에 있는 컴포넌트의 state를 보존한다. 가끔 동일한 위치의 컴포넌트의 state를 초기화하고 싶은 때가 있다.

 

예) 두 명의 플레이어가 매 턴 동안 점수를 추적할 수 있는 앱

  • 현재 플레이어가 변경되어도 점수가 변경되지 않고 유지되는 문제가 있다.
    • Counter 모두 동일한 위치에 나타나기 때문에, 리액트에서는 동일한 Counterperson prop만 변경되는 것으로 인식하기 때문이다.

 

만약 컴포넌트 state를 초기화하고 싶으면, 아래 두 가지 방법 중 하나를 사용할 수 있다.

  1. 다른 위치에 컴포넌트를 렌더링한다.
  2. 각 컴포넌트에 명시적 고윳값인 key를 부여한다.

옵션 1 : 다른 위치에 컴포넌트 렌더링하기

두 개의 Counter를 독립적으로 만드려면, 서로 다른 위치에 렌더링하면된다.

 

즉, 위 예시를 해결하기위해 아래와 같이 코드를 수정하면 된다.(전체 코드 보기)

{isPlayerA &&
  <Counter person="Taylor" />
}
{!isPlayerA &&
  <Counter person="Sarah" />
}
  • isPlayerAtrue이면, 첫 번째 위치에는 Counter가 포함되고, 두 번째 위치는 비어있다.
  • "Next player" 버튼을 클릭하면, 첫 번째 위치는 지워지고, 두 번째 위치에 Counter가 포함된다.

Counter의 state는 DOM에서 제거될 때마다 소멸되기 때문에 버튼을 클릭할 때마다 state가 초기화된다.

 

이 해결 방법은 동일한 위치에서 소수의 독립적인 컴포넌트를 렌더링하고 싶을 때 편리하다.

  • 예시에서는 독립적으로 렌더링할 컴포넌트가 두개 뿐이여서 이 방법을 사용하는 것이 번거롭지 않다.

옵션 2 : key를 사용해 state를 초기화하기

이 방법은 특히 form을 다룰 때 유용하다.

 

예) 왼쪽(ContactList)에 수신자를 선택하고 오른쪽(Chat)에 input과 보내기 버튼이 존재하는 채팅 앱

  • input에 내용을 추가한 후, 왼쪽에 수신자를 변경할 때 input의 내용이 초기화되지 않는 문제가 있다.

key를 추가해 문제를 해결할 수 있다.

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

다른 수신자를 선택할 때, Chat 컴포넌트가 그 아래 트리의 모든 state를 포함해 처음부터 다시 생성된다. 리액트는 DOM 엘리먼트를 재사용하는 대신 다시 생성한다.

  • 현재 예제는 수신자에 따라 input state를 가지므로, 수신자를 기준으로 key를 부여하는 것이 합리적이다.

DEEP DIVE : 컴포넌트가 지워지더라도 state를 유지하기

실제 채팅 앱에서는 사용자가 이전 수신자를 다시 선택할 때 input state를 복구하고 싶을 수도 있다. 이를 가능하게 하는 몇 가지 방법이 있다.

  • 모든 채팅을 다 렌더링하고, CSS를 사용해 숨길 수 있다.
    • 트리에서 채팅이 지워지지 않아 각 state도 유지된다.
    • 특징
      • 간단한 UI를 만들고 싶을 때 좋다.
      • 숨겨진 트리가 많거나 많은 DOM 노드를 포함할 때 매우 느릴 수 있다.
  • 각 수신자에서 작성한 메시지의 state를 상위 컴포넌트로 끌어올려서 보관할 수 있다.
    • 상위 컴포넌트가 정보를 보관하므로 하위 컴포넌트가 지워더라도 state가 유지된다.
    • 특징
      • 가장 일반적인 해결책
  • 리액트 state외 그 밖의 소스를 사용할 수 있다.
    • 예를 들어 사용자가 실수로 페이지를 닫아도 메시지가 유지되기를 원할 수 있다. 이를 구현하기 위해 localStorage를 읽어 상태를 초기화하고 초안도 저장하도록 만들 수 있다.

+ Recent posts