컴포넌트가 TaskList > TaskListSection > Task 구조로 되어있을 때, Task에서 할일 만료일 dueDate를 변경하면 onChange 이벤트 핸들러에 의해 State가 바뀌어 재렌더링이 일어나도록 앱을 작성함. 할일의 만료일 변경 사항을 굳이 부모 컴포넌트가 알 필요가 없기 때문에 이렇게 작성할 수 있다고 생각할 수 있는데, 이후에 부모 컴포넌트에서 해당 Props를 변경하게 된다면 문제가 생길 수 있음.
Task 컴포넌트에서 특정 할일의 만료일을 변경한 후, 다른 할일의 완료/미완료 여부를 변경한 상황을 생각해보자.
최상위 부모 컴포넌트인 TaskList에 taskObj1의 isCompleted 속성값에 따라 해당 할일이<TaskListSection className="ongoing" />와<TaskListSectionclassName="completed"/>중 한 곳에 출력되게 하는 이벤트 핸들러 onChangeCompletion가 있음. 특정 Task 컴포넌트의 만료일을 변경한 후 완료/미완료 여부가 변경된다면TaskList 컴포넌트에서는 해당 Task 컴포넌트의 만료일이 변경되었다는 것을 알지 못하기 때문에만료일이 초기화되는 문제가 발생함.진리의 원천(Source of Truth)이만료일을 변경한 Task 컴포넌트,모든 할일에 대한 정보를 가지고 있는 TaskList 컴포넌트로2개가 있기 때문에 발생한 문제. 만료일 변경 이벤트를 TaskList 컴포넌트에 넘기면 문제는 해결됨.
즉, 해당 Props 속성이 초기값으로만 주어지고 부모 컴포넌트에서 변경될 일이 없다면 해당 Props를 State의 초기값으로 할당할 수 있지만 그렇지 않은 경우에는Props를 State에 바로 할당하면 안 됨!
온라인 쇼핑몰에서 쇼핑 카트를 구현하는 경우를 생각해보자. ShoppingCart 컴포넌트는 카트에 담긴 아이템을 담은 배열cartItems를 Props로 받아, 카트에서 체크된 아이템의 ID를 담은checkedItems의 초기값으로 할당하고 있음. 이 경우도 마찬가지로 ShoppingCart 컴포넌트에서 cartItems를 변경하면 예상치 못한 버그가 발생할 수 있음.
카트 목록(cartItems)이 변경되어도 ShoppingCart 컴포넌트의 State(checkedItems)가 변경되고 있지 않음
이 앱은 React Router를 이용해서 카트 페이지를 벗어나면 ShoppingCart 컴포넌트가 언마운트됨. 따라서, 나중에 다시 카트 페이지로 들어와도 업데이트된checkedItems을 이용해 다시 초기값을 설정하기 때문에 눈에 보이는 문제는 발생하지 않음.
checkedItems는 cartItems의 Derived State는 아니지만, 밀접한 관계를 가지기 때문에 두 값을 동기화해주는 작업이 필요함. 하지만, Props인cartItems에 isChecked 속성을 추가해주면 checkedItems는 항상 cartItems로부터 계산이 가능해지므로 따로 동기화해줄 필요가 없음!
ShoppingCart 컴포넌트에서 체크 상태를 변화시키면, 부모 컴포넌트 App의 State가 변경되어 자식 컴포넌트인 ShoppingCart도 재렌더링되기 때문에동기화 문제는 더이상 신경 쓸 필요가 없게 됨. 게다가, ShoppingCart 컴포넌트가 언마운트되었을 때 checkedItems가 초기화되어체크 상태를 기억하지 못했던 문제도 개선할 수 있게 됨.
위 코드처럼 부모 컴포넌트 App에서 전달된 Props를 Box 컴포넌트의 State로 할당하고, 만약 Props가 변경되면 useEffect()를 이용해Box 컴포넌트의 State를 갱신하는 경우를 생각해볼 수 있음. 언뜻 복잡해보이지만 작동에는 문제가 없음. 하지만, Box 컴포넌트에서Props가 변경되어 재렌더링이 일어나고,State가 변경되어 또 재렌더링이 일어나 불필요한 렌더링이 발생한다는 문제점이 있음.
Box 컴포넌트에 렌더링이 중복해서 일어나는 모습
이렇게 useEffect()를 쓰는 방법은코드가 늘어날 경우유지보수만 어렵게 하므로부모 컴포넌트App에서 해당 State를 완전히 통제하도록 하고, Box 컴포넌트는 Stateless Component로 만드는 것이 좋음
둘 이상의 State를 조합해 계산해낼 수 있는 State를 파생 State(Derived State)라 함. 예를 들어, 사용자가 조건 options을 입력하면 데이터 배열 data에서 필터링된 결과를 보여주는 기능을 구현하는 상황을 생각해보자. 필터링을 한다고 해도 기존 데이터는 변경하면 안 되기 때문에새로운 State인 filtered를 만들어야 한다고 생각할 수 있음. 원래 데이터 data가 변경될 일이 없다면 이렇게 만들어도 앱이 문제 없이 작동함.(하지만, 이런 경우엔 굳이 data를 State로 둘 필요가 없음!)
하지만, data가 변경될 일이 생긴다면 filtered도 변경된 데이터와 동기화를 해야 하기 때문에 setData(), setFiltered() 두번이나 State를 변경해야 하고, 이러한 점을 생각하지 못한다면 버그가 발생할 수도 있음. 따라서, 이러한파생 State는 별도의 State로 만들지 않고, 기반 State가 변경될 때마다 다시 계산되도록만들어 주는 게 좋음.
// 개선 후
const [inputs, setInputs] = useState('');
const [options, setOptions] = useState(initialData);
const filtered = options.filter((opt) => {
return opt.substring(0, inputs.length) === inputs;
})
// handleInputChange() 이벤트 핸들러는 더이상 필요 없음
파생 State를State가 아니라 컴포넌트 내부의 일반 변수로 만들어 주면, 컴포넌트가 재렌더링될 때마다 해당 변수도 다시 계산되고, 해당 데이터를 받아 렌더링되는 자식 컴포넌트도 다시 렌더링되므로 문제가 없음. 오히려 코드도 더 간결해지고, State가 줄어들어 컴포넌트의 복잡도가 줄어드는 장점이 있음.
한편, 이 경우엔 inputs가 빈번하게 바뀌어 컴포넌트도 자주 재렌더링되기 때문에 최적화의 효용성이 부족하지만, 변수 filtered가 자주 바뀔 필요가 없는 상황에서는useMemo()hook을 이용해 계산이 매번 일어나지 않도록 최적화를 해줄 수도 있음.
// filtered가 자주 바뀌지 않을 때
const filtered = useMemo(options.filter((opt) => {
return opt.substring(0, inputs.length) === inputs;
}));
State를 변경할 때 주의할 점들
1. Props를 State에 바로 할당할 때는 주의해야 함 #
A. 예시1
컴포넌트가 TaskList > TaskListSection > Task 구조로 되어있을 때, Task에서 할일 만료일 dueDate를 변경하면 onChange 이벤트 핸들러에 의해 State가 바뀌어 재렌더링이 일어나도록 앱을 작성함. 할일의 만료일 변경 사항을 굳이 부모 컴포넌트가 알 필요가 없기 때문에 이렇게 작성할 수 있다고 생각할 수 있는데, 이후에 부모 컴포넌트에서 해당 Props를 변경하게 된다면 문제가 생길 수 있음.
Task 컴포넌트에서 특정 할일의 만료일을 변경한 후, 다른 할일의 완료/미완료 여부를 변경한 상황을 생각해보자.
최상위 부모 컴포넌트인 TaskList에 taskObj1의 isCompleted 속성값에 따라 해당 할일이 <TaskListSection className="ongoing" />와 <TaskListSection className="completed" />중 한 곳에 출력되게 하는 이벤트 핸들러 onChangeCompletion가 있음. 특정 Task 컴포넌트의 만료일을 변경한 후 완료/미완료 여부가 변경된다면 TaskList 컴포넌트에서는 해당 Task 컴포넌트의 만료일이 변경되었다는 것을 알지 못하기 때문에 만료일이 초기화되는 문제가 발생함. 진리의 원천(Source of Truth)이 만료일을 변경한 Task 컴포넌트, 모든 할일에 대한 정보를 가지고 있는 TaskList 컴포넌트로 2개가 있기 때문에 발생한 문제. 만료일 변경 이벤트를 TaskList 컴포넌트에 넘기면 문제는 해결됨.
즉, 해당 Props 속성이 초기값으로만 주어지고 부모 컴포넌트에서 변경될 일이 없다면 해당 Props를 State의 초기값으로 할당할 수 있지만 그렇지 않은 경우에는 Props를 State에 바로 할당하면 안 됨!
B. 예시2
온라인 쇼핑몰에서 쇼핑 카트를 구현하는 경우를 생각해보자. ShoppingCart 컴포넌트는 카트에 담긴 아이템을 담은 배열 cartItems를 Props로 받아, 카트에서 체크된 아이템의 ID를 담은 checkedItems의 초기값으로 할당하고 있음. 이 경우도 마찬가지로 ShoppingCart 컴포넌트에서 cartItems를 변경하면 예상치 못한 버그가 발생할 수 있음.
위 코드처럼 handleDelete()에서 cartItems와 checkedItems를 동기화 해주면 cartItems와 checkedItems가 일치하지 않는 문제는 해결할 수 있음.
이 앱은 React Router를 이용해서 카트 페이지를 벗어나면 ShoppingCart 컴포넌트가 언마운트됨. 따라서, 나중에 다시 카트 페이지로 들어와도 업데이트된 checkedItems을 이용해 다시 초기값을 설정하기 때문에 눈에 보이는 문제는 발생하지 않음.
checkedItems는 cartItems의 Derived State는 아니지만, 밀접한 관계를 가지기 때문에 두 값을 동기화해주는 작업이 필요함. 하지만, Props인 cartItems에 isChecked 속성을 추가해주면 checkedItems는 항상 cartItems로부터 계산이 가능해지므로 따로 동기화해줄 필요가 없음!
ShoppingCart 컴포넌트에서 체크 상태를 변화시키면, 부모 컴포넌트 App의 State가 변경되어 자식 컴포넌트인 ShoppingCart도 재렌더링되기 때문에 동기화 문제는 더이상 신경 쓸 필요가 없게 됨. 게다가, ShoppingCart 컴포넌트가 언마운트되었을 때 checkedItems가 초기화되어 체크 상태를 기억하지 못했던 문제도 개선할 수 있게 됨.
C. useEffect()를 이용해서 State와 Props를 동기화 한다면?
위 코드처럼 부모 컴포넌트 App에서 전달된 Props를 Box 컴포넌트의 State로 할당하고, 만약 Props가 변경되면 useEffect()를 이용해 Box 컴포넌트의 State를 갱신하는 경우를 생각해볼 수 있음. 언뜻 복잡해보이지만 작동에는 문제가 없음. 하지만, Box 컴포넌트에서 Props가 변경되어 재렌더링이 일어나고, State가 변경되어 또 재렌더링이 일어나 불필요한 렌더링이 발생한다는 문제점이 있음.
이렇게 useEffect()를 쓰는 방법은 코드가 늘어날 경우 유지보수만 어렵게 하므로 부모 컴포넌트 App에서 해당 State를 완전히 통제하도록 하고, Box 컴포넌트는 Stateless Component로 만드는 것이 좋음
2. 기존 State를 immutable하게 변경해야 함
2022.08.09 - [React] - State와 얕은 복사(Shallow Copy)
3. Derived State는 State로 관리할 필요가 없음
둘 이상의 State를 조합해 계산해낼 수 있는 State를 파생 State(Derived State)라 함. 예를 들어, 사용자가 조건 options을 입력하면 데이터 배열 data에서 필터링된 결과를 보여주는 기능을 구현하는 상황을 생각해보자. 필터링을 한다고 해도 기존 데이터는 변경하면 안 되기 때문에 새로운 State인 filtered를 만들어야 한다고 생각할 수 있음. 원래 데이터 data가 변경될 일이 없다면 이렇게 만들어도 앱이 문제 없이 작동함.(하지만, 이런 경우엔 굳이 data를 State로 둘 필요가 없음!)
하지만, data가 변경될 일이 생긴다면 filtered도 변경된 데이터와 동기화를 해야 하기 때문에 setData(), setFiltered() 두번이나 State를 변경해야 하고, 이러한 점을 생각하지 못한다면 버그가 발생할 수도 있음. 따라서, 이러한 파생 State는 별도의 State로 만들지 않고, 기반 State가 변경될 때마다 다시 계산되도록 만들어 주는 게 좋음.
파생 State를 State가 아니라 컴포넌트 내부의 일반 변수로 만들어 주면, 컴포넌트가 재렌더링될 때마다 해당 변수도 다시 계산되고, 해당 데이터를 받아 렌더링되는 자식 컴포넌트도 다시 렌더링되므로 문제가 없음. 오히려 코드도 더 간결해지고, State가 줄어들어 컴포넌트의 복잡도가 줄어드는 장점이 있음.
한편, 이 경우엔 inputs가 빈번하게 바뀌어 컴포넌트도 자주 재렌더링되기 때문에 최적화의 효용성이 부족하지만, 변수 filtered가 자주 바뀔 필요가 없는 상황에서는 useMemo() hook을 이용해 계산이 매번 일어나지 않도록 최적화를 해줄 수도 있음.
개선 전 코드
개선 후 코드
어느 한 State에 의존하는 State를 한번에 변경하지 않아 State의 비동기성도 고려할 필요가 없고, 불필요한 함수도 작성할 필요가 없음.
[참고]
React Anti-Patterns: Props in Initial State
useCallback과 React.Memo을 통한 렌더링 최적화
'JavaScript > React' 카테고리의 다른 글