15

props를 useState에 전달하기

이 글은 TkDodoPutting props to useState 포스트를 번역한 글입니다.

useState의 함정


useState의 함정 시리즈의 첫 번째 파트에서는 파생된 state를 다룰 때 또 다른 state를 선언하는 것을 피하라고 말했습니다.

이번 파트는 props로 받은 값으로 state를 초기화하는 상황에 대해 다룹니다. 아마 흔하게 이용하는 방법이고 그 자체로 잘못된 방법은 아니지만 이 방법에는 우리가 알고 있어야 할 잠재적인 문제가 있습니다.

예시

전형적인 목록 / 상세 사용 사례를 예시로 들도록 하겠습니다. 사람들로 이루어진 목록이 있고 그들 중 한 명을 선택하면 그 사람에 대한 정보로 채워진 상세 폼이 나오는 방식입니다. 상세 폼에서는 사람의 이메일 주소를 보여주고 apply 버튼을 클릭하면 데이터를 변경하려고 합니다.

인터렉티브한 예시이니 마음껏 클릭해 보세요(코드를 변경할 수도 있습니다🚀)

import React from 'react';
import DetailView from './DetailView';

const persons = [
  {
    id: 1,
    name: "Dominik",
    email: "dominik@dorfmeister.cc",
  },
  {
    id: 2,
    name: "John",
    email: "john@doe.com",
  },
];

function App() {
  const [selected, setSelected] = React.useState(persons[0]);

  return (
    <div>
      {persons.map((person) => (
        <button
          type="button"
          key={person.id}
          onClick={() => setSelected(person)}
        >
          {person.id === selected.id ? person.name.toUpperCase() : person.name}
        </button>
      ))}
      <DetailView initialEmail={selected.email} />
    </div>
  );
}

export default App;

useState 초기값

위 예시가 동작하지 않음을 바로 눈치채셨을 겁니다. 이메일 주소를 변경하고 John을 클릭해도 입력 요소는 변경되지 않습니다.

리액트가 state에 관해서라면 생명주기가 아닌 hook으로 생각하기를 기대하는 만큼 첫 번째 렌더(마운트) 그리고 추가 렌더(리렌더) 사이에는 큰 차이가 있습니다.

리렌더 시에는 useState hook의 초기값이 항상 버려집니다 - 초기값은 오직 컴포넌트가 마운트 될 때만 사용됩니다.

John을 클릭했을 때 DetailView 컴포넌트는 리렌더될 것이며(이미 스크린에 존재했기 때문에) 이는 John의 이메일이 초기값으로서 state에 담기지 않을 것임을 의미합니다. 여전히 이메일 주소를 변경하기 위해 지역 state를 필요로 하기 때문에 사람으로 이뤄진 배열을 직접 변경하기를 원하진 않습니다. (apply 버튼을 눌렀을 때만 배열의 값이 변경되길 원하므로)

이런 경우 또는 비슷한 경우를 다루기 위한 3가지 방법이 있습니다.

1. DetailView 컴포넌트 조건부 렌더

모달 또는 스크린에 나타나는 다른 컴포넌트들을 사용할 때 이 방식을 많이 사용합니다.

모달 안에서 DetailView 컴포넌트를 보여준다면 조건부로 렌더링 되기 때문에 마법처럼 위의 코드 예시가 동작할 것입니다. John을 클릭하면 Modal과 DetailView가 마운트 되면서 useState 초기값이 사용될 것입니다. 모달을 닫으면 언마운트 될 것이고 다음에 사람이 클릭되면 다시 마운트 될 것입니다.

어떻게 그렇게 할 수 있을지 보여드리겠습니다.

import React from 'react';
import DetailView from './DetailView';

const persons = [
  {
    id: 1,
    name: "Dominik",
    email: "dominik@dorfmeister.cc",
  },
  {
    id: 2,
    name: "John",
    email: "john@doe.com",
  },
];

function App() {
  const [selected, setSelected] = React.useState();

  const close = () => setSelected(undefined);

  return (
    <div>
      {persons.map((person) => (
        <button
          type="button"
          key={person.id}
          onClick={() => setSelected(person)}
        >
          {person.name}
        </button>
      ))}
      {selected && (
        <div
          style={{
            position: "fixed",
            top: "0",
            left: "0",
            paddingTop: "100px",
            width: "100%",
            height: "100%",
            backgroundColor: "rgba(0,0,0,0.4)",
          }}
        >
          <div
            style={{
              display: "flex",
              justifyContent: "center",
              width: "80%",
              height: "50vh",
              margin: "auto",
              backgroundColor: "white",
            }}
          >
            <DetailView initialEmail={selected.email} close={close} />
            <span style={{ cursor: "pointer", color: "black" }} onClick={close}>
              &times;
            </span>
          </div>
        </div>
      )}
    </div>
  );
}

export default App;

css는 양해 부탁드립니다. 웹 개발에서 가장 약한 부분입니다. 😅

모달이 조건부로 DetailView를 렌더 하기 때문에(리렌더가 아닌 마운트 시키기 때문에) 위 예시가 이제 잘 동작합니다.

많은 분들이 이렇게 해왔다고 생각하며 유효한 해결 방법이라고 확신합니다. 그러나 이 방법은 모달 안에서 DetailView를 렌더링 할 때만 동작합니다. 만약에 DetailView가 어디에서나 렌더 가능하게 하려면 다른 해결 방법이 필요합니다.

2. State 끌어올리기

아마 이 구절을 들어보셨을 겁니다. React 공식 문서에서도 이 주제에 대한 섹션이 있습니다.

이 예시에서는 DetailView에 있던 초안(draft) state를 App으로 끌어올려서 DetailView를 완전히 제어 컴포넌트로 만듭니다. DetailView는 이제 지역 state가 필요하지 않기 때문에 더 이상 props를 state에 넣어서 문제가 생길 일이 없습니다.

import React from 'react';
import DetailView from './DetailView';

const persons = [
  {
    id: 1,
    name: "Dominik",
    email: "dominik@dorfmeister.cc",
  },
  {
    id: 2,
    name: "John",
    email: "john@doe.com",
  },
];

function App() {
  const [selected, setSelected] = React.useState(persons[0]);
  const [email, setEmail] = React.useState(selected.email);

  return (
    <div>
      {persons.map((person) => (
        <button
          type="button"
          key={person.id}
          onClick={() => {
            setSelected(person);
            setEmail(person.email);
          }}
        >
          {person.id === selected.id ? person.name.toUpperCase() : person.name}
        </button>
      ))}
      <DetailView email={email} setEmail={setEmail} />
    </div>
  );
}

export default App;

이제 App은 모든 state를 제어할 수 있으며 DetailView는 이제 멍청한 컴포넌트(보여주기만 할 수 있는 컴포넌트)입니다. 이 접근 방법은 많은 상황에서 구현 가능하지만 결점이 없지는 않습니다.

이제 입력 요소에 타이핑을 할 때마다 App이 리렌더될 것입니다. 물론 지금처럼 작은 규모의 예시에서는 문제가 되지 않지만 더 큰 규모의 앱의 경우 문제가 될 수 있습니다. 사람들은 종종 불필요한 리렌더를 줄이기 위해 전역 state 관리에 의존합니다.

누군가는 이메일 초안 state의 스코프가 너무 크다고 말할 수 있습니다. App에서는 오직 사용자가 Apply 버튼을 눌렀을 때의 새로운 이메일에만 관심이 있는데 왜 유저가 타이핑하는 순간순간의 state에 대해 신경을 쓸 필요가 있냐고 말하면서 말이죠.

세 번째 접근 방법은 앞서 설명드릴 두 가지 방법의 중간이라고 할 수 있습니다: 동일한 ux를 유지하고 이메일 초안 state의 스코프를 작게 유지하지만 원할 때 폼을 다시 마운트 할 수 있는 방법입니다.

3. key 속성을 이용한 완전히 제어되지 않는 컴포넌트

import React from 'react';
import DetailView from './DetailView';

const persons = [
  {
    id: 1,
    name: "Dominik",
    email: "dominik@dorfmeister.cc",
  },
  {
    id: 2,
    name: "John",
    email: "john@doe.com",
  },
];

function App() {
  const [selected, setSelected] = React.useState(persons[0]);

  return (
    <div>
      {persons.map((person) => (
        <button
          type="button"
          key={person.id}
          onClick={() => setSelected(person)}
        >
          {person.id === selected.id ? person.name.toUpperCase() : person.name}
        </button>
      ))}
      <DetailView key={selected.id} initialEmail={selected.email} />
    </div>
  );
}

export default App;

이 예시는 다음 한 줄만 제외하면 첫 번째 예시와 완전히 같은 코드입니다.

- <DetailView initialEmail={selected.email} /> + <DetailView key={selected.id} initialEmail={selected.email} />

React keys

React 컴포넌트에서 key는 특별한 속성입니다. key들은 대부분 목록(리스트)에서 React의 안정성을 보장하기 위해 사용됩니다. key가 있어야 react 재조정기(reconciler)가 어떤 엘리먼트인지 식별할 수 있고 리렌더 시 같은 엘리먼트를 효율적으로 재사용할 수 있기 때문입니다.

그러나 위와 같은 용도 이외에도 React에게 "이봐 key 속성이 동일하면 컴포넌트를 리렌더를 시켜주고, 바뀌면 마운트 시켜줘"라고 말하기 위해서 사용할 수 있습니다.

이것은 useEffect Hook의 의존성 배열과 비슷하다고 생각할 수 있습니다. 이전 렌더와 비교해서 변경되었다면 React는 컴포넌트를 다시 마운트 할 것입니다.

만약 더 자세히 알고 싶다면 재조정에 대한 설명을 읽어보세요.

해결 방법이 아닌 것

아마 이 문제를 useEffect를 사용해 props와 state를 동기화 시키는 방법으로 해결하려고 했을 수도 있습니다.

function DetailView({ initialEmail }) {
const [email, setEmail] = React.useState(initialEmail)
React.useEffect(() => {
setEmail(initialEmail)
}, [initialEmail])
return (...)
}

useEffect를 이런 방식으로 사용하는 것은 안티 패턴이라고 생각합니다. 만약 useEffect가 동기화를 위해 사용된다면 react state와 react 외부의 무언가(예. 로컬 스토리지)와 사용되어야 합니다.

그러나 여기에서는 react 내부에 있는 무언가(initialEmail)와 react state(email)를 동기화하기 위해 사용하고 있습니다. 게다가 동기화되는 조건이 원하는 바와 다르게 되어있습니다: 이메일이 변경됐을 때가 아니라 사람이 선택됐을 때마다 state를 초기화하기를 원하기 때문입니다.

첫 번째 방법은 조건부 렌더링을 이용해서, 두 번째는 사람을 선택하는 버튼이 클릭 됐을 때 명시적으로 state를 설정하면서, 세 번째는 안정적인 key(선택된 사람의 id)를 이용해 문제를 해결합니다.

이메일은 각각이 고유하기 때문에 최선의 예시가 아닐 수 있습니다. 만약 두 사람이 이름처럼 같은 데이터를 가지고 있다면 어떨까요? 두 사람의 이름이 같다면 다른 사람을 선택해도 데이터는 변경되지 않았기 때문에 useEffect가 동작하지 않고 의도한 동작이 이뤄지지 않을 것입니다.

유사한 예로 부모 컴포넌트에서 데이터가 변경되었지만 (예. react-query의 refetch로 인해) 사용자가 이미 입력 요소를 변경했다면 어떨까요? 정말 이런 상황에서 사용자의 입력 요소를 받아온 데이터로 덮어쓰고 싶으신가요?

결론적으로 이러한 방식으로 사용한 useEffect는 골치 아프고 추적하기 어려운 많은 버그의 길로 이끕니다.

핵심 내용

개인적으로, 선호하는 해결 방법은 없으며 상황에 따라 세 가지 접근 방법을 모두 사용해왔습니다.

초안 state를 가지고 있는 DetailView는 몇 가지 이점이 있지만 언마운트에는 큰 비용이 따릅니다. 안정적인 key가 없을 수도 있고 언제 컴포넌트가 초기화되어야 하는지가 모호할 수도 있습니다.

state를 끌어올리는 것 또한 이점이 있지만 완전히 제어되는 컴포넌트들은 보통 추론하기 쉽지만 큰 규모의 앱에서 항상 수용 가능하지만은 않습니다.

어떤 해결 방법을 선택해서 적용해도 상관없지만 useEffect를 이용해 state와 props를 동기화시키는 방법은 사용하지 말아주세요. 이 접근 방법은 prop와 state를 동기화시키기 위해 사용했던 componentWillReceiveProps 생명주기와 유사합니다. 여기Brian Vaughn이 2018년에 작성한 이 안티 패턴에 대한 좋은 글이 있습니다.