지나가던 개발(zigae)

useState에 props 주입

2021년 11월 8일 • ☕️ 5 min read

react-logo


useState함정 시리즈의 첫 번째 파트에서 상태관리를 위해 state를 피하는 방법에 대해 작성했다.

본 글은 props로 얻은 값으로 state를 초기화하려는 일반적인 상황에 대해 이야기한다. 아마도 많은 개발자들이 비슷하게 코드를 작성했을 것이다. 코드 자체의 문제는 아니지만 우리는 잠재적인 문제 몇 가지를 인지해야 한다.

Example

필자는 고전적인 리스트를 예시로 사용할 것이다. persons 리스트를 가지고 있으며, 한 명을 선택하면 DetailView가 채워진다. DetailView은 persons의 이메일 주소를 표시하고 해당 데이터를 업데이트하기 위한 버튼도 가지고 있다.

결과를 확인해 보자(코드 수정 가능)

useState 초기값

예제가 동작하지 않는다는 것을 바로 눈치챘을 것이다. 이메일 주소를 편집하고 확인을 누를 순 있겠지만 둘리를 클릭하면 입력 필드가 업데이트되지 않는다.

React는 개발자가 LifeCycle보다 hook으로 사고하길 원하지만, 상태에 관한 첫 번째 렌더(Mount)와 리렌더(Update) 사이에는 큰 차이가 있다.

useState의 초깃값은 리렌더링 시 항상 discard 되고, 컴포넌트가 마운트 될 때만 유효하다.

둘리를 클릭하면 DetailView 컴포넌트가 리렌더링 된다. 즉, 둘리의 이메일은 state에 들어가지 않는다는 것을 의미한다. 그리고 이와 같은 문제를 해결 하는 세가지 방법을 소개하고자 한다.

DetailView 조건부 렌더링

모달이나 화면에 다른 컴포넌트를 렌더링 해야 할 때 이 방법을 많이 사용한다.

모달에 DetailView를 표시하면 위의 코드가 마술처럼 동작한다. 모달은 일반적으로 조건부로 렌더링 되기 때문이다. 둘리를 클릭한 이후 모달을 마운트 하므로 useState의 초깃값이 유효하다. 사용자가 모달을 닫으면 언마운트 되고 다음에 길동을 클릭하면 다시 마운트 된다.

다음 코드를 살펴보자.

먼저 나의 CSS에 애도를 표한다. 필자는 마크업이 능숙하지 않다.

본론으로 돌아가서 이번 예제에는 문제가 없다. 그 이유는 모달은 조건부로 DetailView를 렌더링 하여 다시 마운트 하기 때문이다.

많은 개발자들이 이렇게 하였을 것이고, 유효한 해결책이다. 그러나 이 작업은 DetailView를 모달로 렌더링 하기 때문에 동작한다는 것을 유의해야한다. DetailView를 모든 곳에서 렌더링 가능하게 하려면 다른 해결책이 필요하다.

상태 끌어 올리기(lifting state up)

아마 이 문구를 어디선가 본 적 있을 것이다. 바로 React 문서에서 해당 주제에 대한 섹션이 존재한다.

이번 예제에서는 기본적으로 초기 상태를 트리 구조의 상위로 이동하여 DetailView를 완전히 controlled component로 만든다. DetailView는 로컬 상태가 전혀 필요하지 않기 때문에 props를 state로 넣어야 하는 문제는 없을 것이다.

앱은 모든 상태를 완전히 제어할 수 있게 되었고, DetailView는 일명 Dumb Component이다. 이러한 접근 방식은 많은 사례에 적용할 수 있지만 단점이 없는 것은 아니다.

input 필드에 입력을 하면 전체 앱이 리렌더링 된다. 지금과 같은 작은 앱에서는 문제가 되지 않지만 큰 앱에서는 문제가 될 수 있다. 개발자들은 효율적으로 리렌더링 되는 것을 원하기 때문에 이러한 상황에 종종 전역상태관리에 의존하기도 한다.

누군가는 예제의 이메일 state 범위가 너무 크다고 생각 할 수 있다. 그리고 앱은 사용자가 확인을 누른 후에야 새로운 이메일 관심사를 가질 수 있다.

세 번째 방법은 첫 번째와 두 번째의 중간(?)이다. 동일한 ux와 state의 범위를 작게 유지하면서 input 필드를 리렌더링 한다.

Key를 사용하여 전체 uncontrolled

이번 예제는 오직 한 가지 변화만 제외하면 첫 번째 예제와 정확히 같은 코드이다.

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

React keys

리액트의 컴포넌트의 key attribute는 특별하다. key는 대부분 리스트에서 안정성을 리액트 조정자(reconciler)에게 시그널링하기 위해 사용된다. 그리고 조정자는 재사용돌 수 있는 컴포넌트를 판단하여 리렌더링 한다.

그러나 리스트가 아닌 컴포넌트에 key attribute를 넣어 React에 “키가 변경될 때마다 이 친구를 마운트해주세요” 라고 알릴 수 있다.

이 방법은 dependency array 역할과 비슷해 보일 수 있다. 변경되면 이전 렌더링과 비교하여 React는 컴포넌트의 마운트를 다시 실행한다.

자세한 내용은 도큐먼트를 참고하자

⚠️ 잘못된 방법

다음과 같이 props를 “sync” 하기 위해 useEffect를 통해 문제를 해결하고 싶을 수 있다.

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

    React.useEffect(() => {
        setEmail(initialEmail)
    }, [initialEmail])

    return (...)
}

필자는 이와 같은 패턴을 안티패턴으로 간주할 것이다. useEffect가 동기화에 사용되는 경우 React의 state를 React 외부(e.g localstorage)와 동기화하는데 사용해야 한다.

하지만 여기서 우리는 이미 React 내부에 존재하는 것을 React의 state와 동기화하고 있다. 또한 동기화하는 조건은 개발자가 생각했던 결과가 아니다. 이메일이 변경될 때 반드시 변경 되는 것이 아니라 다른 사람이 선택될 때마다 state를 재설정 하고자 했다.

첫 번째 솔루션은 조건부 렌더링을 통해 해결했고, 두 번째 솔루션은 사람을 선택하는 버튼을 클릭했을 때 명시적으로 state를 설정하며, 세 번째 해결책은 안정적인 key(선택된 사람의 아이디)를 제공하여 문제를 해결했다.

이메일은 일반적으로 유니크하기 때문에 차선책이 될 수 있지만, 두사람이 동일한 데이터를 가지고 있다면 어떨까? 다른 사람을 클릭해도 아무런 effect가 없이 초기 상태 값이 바뀌지 않는다.

비슷하게 부모 컴포넌트에서 데이터가 변경 되었지만(e.g react-query에 의한 refetching) 사용자가 이미 입력 필드의 값을 변경한 경우엔 어떻게 될까? 이런 경우 사용자 입력이 무시 될 수 있으며 의도된 동작은 아닐 것이다.

따라서 이러한 effect는 개발자들이 추적하기 어려운 에러를 많이 발생 시키기 때문에 피하는 것이 좋다.

마치며

개인적으로 선호하는 방법은 따로 없다. 필자는 적재적소에 세 가지 해결책을 모두 사용했다.

초기 state를 가지는 DetailView에는 몇몇 장점이 있지만 마운트 해제에 약간의 비용이 필요하고, 컴포넌트를 재설정해야 할 때 stable key가 제공되지 않는다.

fully controlled component도 일반적으로 추론하기 쉽지만 대규모 앱에서는 항상 원하는 대로 동작하지 않을 수 있기 때문에 lifting state에도 단점이 있다.

이 글을 본 독자라면 무엇을 결정하든 마지막에 소개한 props를 useEffect에서 sync 하는 방법만 사용하지 않길 바란다. 이 접근 방식은 props와 state를 동기화할 때 사용되었던 과거의 componentWillReceiveProps(현재 deprecated) 라이프사이클과 유사하다. 이 안티패턴에 대해 Brian Vaughn작성한 글이 있고, 본 글 역시 많은 영감을 받았다.