(번역) State 유지 / 리셋하기 - NEW 리액트 공식문서

hongregii·2023년 3월 9일

State는 컴포넌트에 고립돼 있다. 리액트는 UI 트리에서 컴포넌트의 위치를 기반으로 어떤 state가 어떤 컴포넌트에 소속돼 있는 녀석인지를 계속 추적한다. 개발자는 리렌더링 사이에서 언제 state를 보존할지, 리셋할지 컨트롤할 수 있다.

이 문서에서는

  • 리액트가 컴포넌트 구조를 어떻게 "보는지"
  • 리액트가 state를 보존할지 / 리셋할지를 언제 선택하는지
  • 강제로 state 리셋시키는 법
  • keys와 types가 state의 보존 여부에 미치는 영향

을 배워보겠다.

UI 트리

브라우저는 UI를 모델링하기 위해 많은 트리 구조를 사용한다. DOM은 HTML을 대표하고, CSSOM은 CSS를 대표한다. 심지어 Accessibility tree 도 있다!

리액트 역시 UI를 관리하고 모델링하기 위한 트리를 사용한다. 리액트는 JSX로부터 UI 트리를 만듬. 그 다음 Recat DOM은 그 UI 트리에 맞게끔 브라우저 DOM 요소 를 업데이트한다. (React Native는 이 트리를 각 mobile platform에 맞게 업데이트함 ㄷㄷ.)

리액트 공식문서에서 virtual dom 이라는 표현은 사용되지 않는다. 이 문서의 리액트 DOM이 보통 우리가 알고 있는 virtual dom에 대한 이야기인듯.

State는 tree의 위치에 묶여있다!

컴포넌트에게 state를 부여하면, state가 컴포넌트 안에서 "살고" 있다고 생각할 수 있다. 아니다! state는 리액트 안에 매어있다. 그리고 리액트가 각 state를 컴포넌트에 연결해주는 것임. UI 트리의 어느 부분에 있는지 (위치)를 기반으로!

Counter 컴포넌트에는 score, hover 두 state가 있다. 이 컴포넌트를 두번 렌더링하면 :

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

왼쪽, 오른쪽 컴포넌트는 서로 다른 녀석들이다. 트리에서 다른 위치에 렌더링됐기 때문. 리액트를 사용할 때 이런 위치는 보통 몰라도 되지만, 작동을 이해하는데 도움이 된다.

리액트에서 화면의 각 컴포넌트는 완전히 고립된 state를 가지고 있다. 오른쪽 컴포넌트를 눌러서 score state를 바꾼다고 왼쪽 score가 바뀌지 않음.

State는 같은 자리에 렌더링 됐을 때만 살아있다

오른쪽 컴포넌트를 없앴다 다시 그려보자.

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

체크박스를 만들어서 누르면 두번째 컴포넌트를 없애봤다. ( {showB && <Counter /> )
원래 score3 이라는 값이 있었어도, 체크박스를 누르면 state가 사라진다. 다시 누르면 0으로 새로 생김.

리액트는 state를 UI 트리에서 자기 자리에 그려져 있을 때만 보존한다. 컴포넌트가 사라지거나 다른 컴포넌트가 그 자리에 그려지면, 리액트는 그 state를 버림.

같은 자리의 같은 컴포넌트 → 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>
  );
}

체크박스를 누르면 isFancy가 바뀌어서 삼항연산자가 작동한다.
삼항연산자에 의해 그 자리에 다른 Counter 컴포넌트가 렌더링되는데, 어찌됐건 UI 트리에서 같은 자리에 같은 함수가 호출되기 때문에, 리액트는 state를 보존함.

마치 같은 "주소 : root의 N번째 자식의 M번째 자식..."를 가진 것과 같다. 리액트가 JSX 값이나 컴포넌트 함수 그 자체를 읽고 판단하는 것이 아님!

같은 자리에 다른 컴포넌트 → state 리셋

삼항연산자 자리에 같은 컴포넌트가 아니라, false 일 경우 <p> 태그를 렌더링한다고 치면, state는 버려진다.

물론 해당 컴포넌트가 자식이 있는 경우, 그 subtree의 state가 모두 버려진다.

이래서 함수형 컴포넌트를 다른 컴포넌트 안에서 선언하면 안되는 것임.

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는 자식 컴포넌트.
  • 부모의 button을 누르면 setState가 작동하여 렌더링이 다시 일어남.
  • 자식도 다시 렌더링 되는데, 리액트는 같은 자리에 다른 MyTextField 컴포넌트가 렌더링 된다고 생각함.
  • 그래서 부모 컴포넌트가 리렌더링 될 때마다 자식 state도 리셋됨.

같은 위치에서 state 리셋하기

Default : 같은 위치 같은 컴포넌트면 state 보존. 보통은 이렇게 하기를 원할 것임.

그런데 리셋하고 싶으면 어떡함? 다음 예시를 보자.
플레이어가 두명인데, 턴 돌아가면서도 자신의 score를 기억한다.

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>
  );
}
  • 버튼을 누르면 플레이어 (person props)가 바뀌는데, 컴포넌트 위치는 그대로다.
  • 플레이어가 바뀌면 바뀐 플레이어의 score state를 렌더링해야 함.
  • 그러나 같은 위치 같은 컴포넌트라서 score 유지됨.
  • Taylor가 점수 올리든, Sarah가 점수를 올리든 같은 score state가 하나씩 올라감.

그러니까 score state가 두개여야 한다는 말.
state를 리셋하는 방법은 두가지가 있다 :

  1. 다른 위치에 컴포넌트를 그리거나
  2. 각 컴포넌트를 unique하게 만들자. 어떻게 ? key 부여.

옵션 1. 다른 위치에 그리자.

화면 상 다른 위치에 그리자는 말이 아님.

<div>
      {isPlayerA &&
        <Counter person="Taylor" />
      }
      {!isPlayerA &&
        <Counter person="Sarah" />
      }
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

조건부 렌더링을 활용하면, react DOM 내에서의 위치를 바꿀 수 있는 것이다.

이러면 각 컴포넌트의 state가 DOM에서 내려갈 때마다 삭제된다.
버튼을 누를 때마다 score state는 초기값 0으로 리셋될 것.

화면상 같은 위치에 렌더링되는 컴포넌트 개수가 적으면 좋은 방법이다. 이 경우 두개니까 이렇게 해도 괜찮음.

옵션 2. key로 state 리셋하기

state를 리셋하는데 더 범용성 있는 방법이 있다.
key는 리스트 렌더링할 때 각 element를 구분하기 위해 사용한 적이 있을 것이다. 그러나 key는 리스트에만 쓰는 것이 아니라 리액트에서 구분이 필요한 모든 것에 사용함! Default로 리액트는 부모의 N번째 자식으로 컴포넌트를 구별한다. 그러나 key를 사용하면 이 컴포넌트가 "그냥 첫번째 자식", "두번째 자식" 이 아니라, Taylor의 컴포넌트라는 것을 명시하게 된다!

이러면 트리 내 어디에 그려지든 리액트가 이 컴포넌트를 구별하여 인지할 수 있다.

예시 코드는 이렇다

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

key를 지정해 주면 리액트한테 key 그 자체를 위치의 일부로 사용하라고 알려주는 셈이다. 그래서 리액트가 같은 위치에 같은 컴포넌트가 렌더링 돼도, 서로 다른 컴포넌트라는 것을 알 수 있다. 이제 컴포넌트가 내려가면 state는 즉시 삭제되고, 그려질 때마다 새로 선언됨.

단! key가 전역에서 unique한 것은 아님!
부모 내의 위치만 특정해주는 것임! = 같은 부모 내에서만 unique 유효함

key로 form 리셋하기

key로 state를 리셋하는 것이 Form에서 특히 좋다!
는 생략

컴포넌트가 사라져도 state를 보존하려면?

  • 모든 컴포넌트를 렌더링은 하되, CSS로 숨겨놓기
    ex. display : none
    이렇게 해도 react tree에서 사라지지는 않는다!
  • State 끌어올리기 : 공통 부모에서 state를 선언하면, 자식이 없어져도 prop이 사라지는 것 뿐, state는 부모 컴포넌트에 잘 살아있을 것이다. 가장 많이 쓰는 방법!
  • state 외에 다른 source 쓰기 :
    ex. localStorage. 사용자가 브라우저를 껐다 켜도 살아있을 수 있다...
profile
잡식성 누렁이 개발자

0개의 댓글