[React] React가 state를 리셋하는 기준

yongkini ·2023년 7월 18일
0

React

목록 보기
18/19

1) 리액트는 같은 위치에 같은 컴포넌트가 있는지를 바탕으로 state를 유지할지 지울지를 결정한다.

Same component at the same position preserves state

It’s the same component at the same position, so from React’s perspective, it’s the same 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>
  );
}

출처 : 리액트 공식 문서

위와 같은 코드가 있을 때

    <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>

Counter 컴포넌트를 보면 같은 컴포넌트를 import해서 쓰고 있지만, 저렇게 분기 처리를 해놓으면 리액트가 서로 다르게 인식하지 않을까? 그래서 만약 isFancy라는 state가 변해서 리렌더링을 할 때, 아예 Counter 컴포넌트 내의 state가 reset이 되지 않을까? 라고 의심해볼 수 있다. 하지만, Counter 컴포넌트는 div 태그 아래에(first child) 위치하고 있고, 리액트는 이 위치와 해당 위치에 있는 컴포넌트가 동일하다고 판단하면 state 리셋을 시키지 않고 보존한다. 그래서 개발자의 코딩 상으로는 state가 리셋될 것 같이 보여도 실제로는 리셋되지 않는거다. 이말은 달리말하면 같은 포지션에 다른 컴포넌트가 있다면 rerendering 될 때 state도 리셋됨을 의미한다.

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

위 코드처럼 말이다. 실제로 score를 ++ 해준 다음에 Counter 컴포넌트를 날렸다가 다시 돌려놔도 이전의 state값(score)은 0으로 돌아가있다. 이 때, 한번더 강조하지만, 리액트는 UI Tree(자체적으로 만든)를 바탕으로 컴포넌트의 포지션을 계속해서 보고 있다가 그 포지션 값이 바뀌거나 같은 포지션에 다른 컴포넌트가 있으면 reset 한다. 이 때, 해당 포지션에 다른 컴포넌트라는 것도 포인트인데 예를 들어, 이런 경우에는 전체 state가 리셋된다.

	<div>
      {isFancy ? (
        <div>
          <Counter isFancy={true} /> 
        </div>
      ) : (
        <section>
          <Counter isFancy={false} />
        </section>
      )}
   </div>

왜일까?. 아까 말했듯이 Counter 컴포넌트의 state가 리셋이 되지 않으려면 div의 first child 위치를 고수하면서, Counter 컴포넌트를 유지해야한다. 하지만, 이 경우에는 div의 first child가 div 였다가 -> section으로 바뀌었다. Counter는 div의 child였다가 section의 child로 바뀌었다. 즉, 포지션 자체가 바뀐거다. 그래서 이경우에는 Counter 컴포넌트를 그대로 쓰고 있음에도 위치값이 바뀌었으므로 state가 reset 된다.

As a rule of thumb, if you want to preserve the state between re-renders, the structure of your tree needs to “match up” from one render to another. If the structure is different, the state gets destroyed because React destroys state when it removes a component from the tree.

결국 state를 유지하면서 위의 구조로 코드를 짜고 싶다면, 구조를 그대로 가져가는 방식으로 만들어야한다. 왜? 리액트는 구조를 바탕으로 구조가 바뀌었을 경우 해당 부분의 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>
    </>
  );
}

이렇게 된다고 했을 때(컴포넌트 안에 컴포넌트를 둔 형태), input text에 input을 입력해놓고 counter 버튼을 누르게 되면 input의 text state가 날아간다. 이건 왜그럴까?. 이는 setCounter를 하는 순간 MyComponent가 리렌더링되면서 그 안에 함수 등을 다시 만들게 되는데, 이 때, MyTextField도 재생성된다(즉 내부 로직은 같아도 주소값이 다른 함수 객체가 되는거다). 이에 따라 MyTextField 자체는 이전과 position은 똑같지만, 이전과 다른 함수객체(주소값이 다른)가 됐으므로 React는 이를 같은 포지션이지만, 다른 컴포넌트라고 판단해 MyTextField 내부의 state를 날려버리는거다(reset).

그럼 이번엔

import { useState, useEffect } 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({"Taylor":0, "Sarah":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[[person]]}</h1>
      <button onClick={() => setScore(prev => {
        return {...prev, [person] : prev[[person]]++};
      })}>
        Add one
      </button>
    </div>
  );
}

위 코드를 보자.

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

포인트는 여기다.

UI 상으로 비춰질걸 생각하면 Taylor, Sarah 둘의 score state가 따로 저장되면서 화면에 보여져야 한다. 하지만, 오늘 배운 내용과 위의 코드를 바탕으로 보면 리액트는 포지션과 같은 컴포넌트인지 여부로 state를 날릴지 판단하기 때문에 위와 같이 작성을 하면 여태까지와는 다르게 state를 날리지 않아서 문제가 된다. 즉, 하고 싶은 기능은 Taylor의 score를 측정하다가 끝나면 switching 후에 Sarah의 score를 0부터 측정하고 싶은건데, 저렇게 구현하면 결국 리액트가 같은 포지션에 같은 컴포넌트로 인식해 state를 날리지 않는다. 이 때는 두가지 방법이 있다.

  • position을 분리한다.
  • key값을 줘서 parent 인 div 아래에 다른 두개의 컴포넌트로 리액트가 인식하도록 한다.

일단

  • position을 분리한다
    <div>
      {isPlayerA &&
        <Counter person="Taylor" />
      }
      {!isPlayerA &&
        <Counter person="Sarah" />
      }
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>

는 위와 같이 해주면 된다. 하지만, 저 방법은 저렇게 컴포넌트가 두개인 경우에는 간단하지만, 여러개일 경우 코드가 좀 지저분해보이고 어려울 수 있다.

  • key값을 줘서 parent 인 div 아래에 다른 두개의 컴포넌트로 리액트가 인식하도록 한다.
      {isPlayerA ? (
        <Counter key="Taylor" person="Taylor" />
      ) : (
        <Counter key="Sarah" person="Sarah" />
      )}

이런식으로 key 값을 다르게 준다. 이렇게 컴포넌트에 key를 다르게 주면 리액트가 해당 jsx의 parent인 div를 기준으로 아래에 있는 컴포넌트들을 구분하게 된다. 즉, 전체 플젝 단위로 unique 해지는건 아니고, parent를 기준으로 unique하게 판단하게 된다. 따라서, 같은 컴포넌트지만(본래 같은 컴포넌트로 리액트가 인식하는 단위지만) key값을 다르게 줌으로써 다른 컴포넌트로 인식해서 두번째 기준인 같은 컴포넌트 인가의 기준에 맞지 않아 위의 경우에는 본래 의도대로 state를 reset 할 수 있게 된다. 이건 array.map(el => <SomeJsx key={el.key} /> ;) 이런식으로 렌더링할 떄도 쓰는 개념이고 그와 같다.

You can force a subtree to reset its state by giving it a different key.

** 이 때, 알아야할 것은 rerender와 state를 날리는건 다른 개념이라는거다. 확인해보면 분명히 isPlayerA라는 state가 변하면서 ChildComponent인 Counter도 자연스럽게 리렌더링된다. 하지만, 단지 state를 리셋하는지 여부가 다를 뿐이다. 리렌더링 자체도 안한다고 생각하면 안된다.

State is not kept in JSX tags. It’s associated with the tree position in which you put that JSX.

profile
완벽함 보다는 최선의 결과를 위해 끊임없이 노력하는 개발자

1개의 댓글

comment-user-thumbnail
2023년 7월 18일

훌륭한 글이네요. 감사합니다.

답글 달기