본문 바로가기
source-code/React

You Might Not Need a Dispatch<SetStateAction> Type.

by mattew4483 2024. 10. 29.
728x90
반응형

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 코드의 일관성, 유지 보수성, 그리고 재사용성을 높이는 데 큰 도움을 줄 수 있습니다.

728x90
반응형