Backgrounds
FE 개발자 단톡방에 이런 질문이 올라왔습니다.
결론부터 말하자면...
You Might Not Need a Dispatch<SetStateAction> Type.
해당 타입 사용을 선호하지 않는다기 보다, 사용할 필요가 없다 란 답이 가장 적절하다고 생각했습니다. 그 이유가 무엇일까요?
언제 사용되는가?
Dispatch<SetStateAction>은 React에서 useState 훅을 사용할 때 반환되는 상태 업데이트 함수의 타입입니다. 상태 값을 업데이트할 때 사용하는 setState 함수의 타입을 정의하며, React 상태 관리에서 매우 일반적으로 사용되죠.
그렇다면, 해당 타입 정의가 왜 필요할까요? 가장 대표적인 예시는 자식 컴포넌트에 Props로 전달할 때입니다. 부모 컴포넌트에서 useState로 상태를 관리한 뒤, 자식 컴포넌트에서 상태를 업데이트해야 하는 상황에서는 setState 함수를 자식 컴포넌트에 전달하게 되는데, 이때 Dispatch<SetStateAction<T>> 타입을 명시하게 되죠.
// 자식 컴포넌트
import React from "react";
interface Props {
setSelectedItem: React.Dispatch<React.SetStateAction<T>>;
}
const ChildComp = ({ setState }: Props) => {
return (...)
};
그렇다면, 왜 자식 컴포넌트가 부모 컴포넌트의 상태 업데이트 함수를 전달받아야 할까요?
이 질문이 중요한 포인트입니다. 이 상황은 대부분 사용자 상호작용을 통해 부모 컴포넌트의 상태를 자식 컴포넌트에서 업데이트해야 하는(것처럼 보이는) 경우 발생합니다.
대표적인 예시는 아래와 같습니다.
- 모달(Dialog) 상태 관리: 자식 컴포넌트가 모달을 닫기 위해 setIsOpen 같은 함수를 호출해야 하는 경우
- 선택 항목 업데이트: 자식 컴포넌트에서 아이템을 클릭하거나 선택할 때 부모 컴포넌트의 selectedItem 상태를 업데이트해야 하는 경우
import React, { useState } from 'react';
import ItemList from './ItemList';
const ParentComponent = () => {
const [selectedItem, setSelectedItem] = useState<string | null>(null);
return (
<div>
<h1>선택된 항목: {selectedItem}</h1>
<ItemList setSelectedItem={setSelectedItem} />
</div>
);
};
export default ParentComponent;
예를 들어, 위 코드처럼 부모 컴포넌트에 "선택된 item"이라는 상태가 존재하고, 자식 컴포넌트에서 item 목록 중 하나를 선택해 해당 상태를 변경해야 한다면...
import React from 'react';
interface ItemListProps {
setSelectedItem: React.Dispatch<React.SetStateAction<string | null>>;
}
const ItemList: React.FC<ItemListProps> = ({ setSelectedItem }) => {
const items = ['Apple', 'Banana', 'Cherry'];
return (
<ul>
{items.map((item) => (
<li key={item} onClick={() => setSelectedItem(item)}>
{item}
</li>
))}
</ul>
);
};
export default ItemList;
자식 컴포넌트에서는 위 코스에서처럼 Props로 Dispatch<React.SetStateAction<string | null>>를 입력받아야만 하는 것이죠.
무엇이 문제인가?
사실 위 코드는 아무런 문제 없이 동작합니다. 하지만 이 구조에는 몇 가지 단점이 존재합니다.
불필요한 결합도
만약 위 예시에서 ItemList의 item이 문자열이 아닌, 특정 데이터 타입으로 변경되었다고 가정해 봅시다.
import React from 'react';
interface ItemType {
name: string
}
interface ItemListProps {
setSelectedItem: React.Dispatch<React.SetStateAction<string | null>>;
}
const ItemList: React.FC<ItemListProps> = ({ setSelectedItem }) => {
const items: ItemType[] = [{name: 'Apple'}, {name: 'Banana'}, {name: 'Cherry'}];
return (
<ul>
{items.map((item) => (
<li key={item} onClick={() => setSelectedItem(item)}> // 에러 발생!
{item}
</li>
))}
</ul>
);
};
export default ItemList;
이 경우 ItemList 함수의 onClick 구문에서 타입에러가 발생하게 됩니다. setSelectedItem은 string을 인자로 받아야 하니까요.
그런데, 가만히 생각해 보면 이는 정말 이상한 일입니다! ItemList 내에 존재하는 item들은 ItemType으로 변경되었는데도, 왜 여전히 setSelectedItem은 string을 인자로 받아야 하는 것일까요?
답은 간단합니다. ItemList를 사용하는 부모 컴포넌트(ParentComponent)의 상태, 즉 selectedItem의 타입이 string이기 때문이죠. 즉 현재 ItemList는 setSelectedItem 함수를 통해 내부적으로 ParentComponent에 의존하고 있고, 이는 반대로 ParentComponent의 selectedItem타입이 변경될 경우, 해당 변경사항이 ItemList에게까지 전파됨을 의미합니다.
우리는 이런 상황을 두 컴포넌트 간 결합도가 높다고 표현합니다. 자식 컴포넌트가 부모 컴포넌트의 Dispatch<SetStateAction<T>> 타입을 직접 사용하게 되면서, 부모의 상태 관리 방식에 의존하게 되고, 이로 인해 두 컴포넌트 간 결합도가 증가되고 마는 것이죠.
관심사의 모호함
자식 컴포넌트는 단순히 상위 상태를 업데이트하는 함수를 호출하는 역할을 합니다. 하지만 Dispatch<SetStateAction<T>> 타입을 명시하게 되면서 자식 컴포넌트가 상태 업데이트 로직을 직접적으로 알고 있는 것처럼 보일 수 있습니다. 이는 자식 컴포넌트가 상태 업데이트를 제어하는 듯한 역할을 부여받는 것으로, 컴포넌트의 역할을 혼란스럽게 만듭니다.
전체 코드를 100% 이해하고 있지 않은 상태로 ItemList 컴포넌트만을 봤을 때, 이 Dispatch<SetStateAction<T>>타입의 setSelectedItem라는 Props의 정체를 파악하기가 무척이나 어렵다는 것이죠.
명시적 인터페이스 부족
Dispatch<SetStateAction<T>> 타입은 상태 관리 방법을 명확히 설명해 주지 않습니다. 위 코드에서 setSelectedItem은 단순히 특정 item을 선택하는 함수임에도 불구하고, 타입이 명확하게 그 역할을 드러내지 못합니다. 그저 React의 상태 업데이트 함수로 취급되기 때문에, 자식 컴포넌트의 Props 인터페이스에서 의도한 역할을 충분히 설명하지 못하게 됩니다.
어떻게 해야 하는가?
Dispatch<SetStateAction<T>> 타입 대신 명시적인 콜백 함수 타입을 자식 컴포넌트에 전달하는 방법이 더 나은 선택일 수 있습니다. 예를 들어, selectItem이라는 함수로 자식 컴포넌트가 특정 아이템을 선택할 수 있도록 정의하고, 이 함수의 타입을 명시적으로 정의하면 자식 컴포넌트가 정확히 어떤 일을 수행해야 하는지 더 명확하게 알 수 있습니다.
import React, { useState } from 'react';
import ItemList from './ItemList';
const ParentComponent = () => {
const [selectedItem, setSelectedItem] = useState<string | null>(null);
// 명시적 콜백 함수
const selectItem = (item: string) => {
setSelectedItem(item);
};
return (
<div>
<h1>선택된 항목: {selectedItem}</h1>
<ItemList selectItem={selectItem} />
</div>
);
};
export default ParentComponent;
import React from 'react';
interface ItemListProps {
selectItem: (item: string) => void; // 명시적인 콜백 타입
}
const ItemList: React.FC<ItemListProps> = ({ selectItem }) => {
const items = ['Apple', 'Banana', 'Cherry'];
return (
<ul>
{items.map((item) => (
<li key={item} onClick={() => selectItem(item)}>
{item}
</li>
))}
</ul>
);
};
export default ItemList;
이제 선택된 항목(selectedItem)이라는 상태에 대한 책임은, 온전히 ParentComponent에게만 존재하게 됩니다. ItemList는 부모 컴포넌트의 상태에 대해 아무것도 알지 못하며, 그저 "사용자가 클릭한 Item을 콜백함수로 전달"하는 관심사에만 집중할 수 있게 되는 것이죠!
결론적으로, Dispatch<SetStateAction> 타입을 사용하는 대신 명시적 콜백 함수 타입을 사용하여 자식 컴포넌트에 전달함으로써,
1) 자식 컴포넌트(ItemList)는 selectItem이라는 명시적 함수만 있으면 되므로 자식 컴포넌트가 다양한 부모 컴포넌트에서도 재사용될 수 있으며
2) selectItem이라는 명시적인 콜백 함수를 사용하여 특정 아이템을 선택하는 기능만 수행해 다른 개발자들도 해당 컴포넌트를 쉽게 이해할 수 있고
3) 부모 컴포넌트의 상태 관리 방법이 변경되더라도 자식 컴포넌트에 전달되는 함수의 시그니처만 유지하면 되므로, 프로젝트 전반의 유지 보수성을 향상하고 FE 코드의 일관성, 유지 보수성, 그리고 재사용성을 높이는 데 큰 도움을 줄 수 있습니다.
'source-code > React' 카테고리의 다른 글
Drag&Drop 컴포넌트에서 클릭 이벤트와 드래그 이벤트 분리하기 (1) | 2024.08.01 |
---|---|
Cannot read properties of null (reading 'useContext') 에러 해결 (0) | 2024.01.16 |
react-dom의 root.render()를 여러 번 호출하면 어떤 일이 생길까? (0) | 2024.01.04 |
useQuery data type 지정하기 (feat. 관심사 분리) (1) | 2023.08.16 |
react-query와 error 전파 handling (0) | 2023.08.16 |