드롭다운 구현으로 알아보는 Generic을 사용하는 React props

2023. 1. 19. 16:37React/고민거리들

0. 배경


React의 props로 Generic을 넘겨줘야 할 일이 종종 있는데 대표적으로 드롭다운(셀럭터)를 만들 때가 바로 그렇다.

직관적으로 생각해보면 새로 컴포넌트 인스턴스를 생성할 때 적절한 타입을 캐스팅 해주면 되지 않을까 싶은데 React.FC에서 Generic이 지원되지 않는다는 점에서 문제가 일어난다.

이 글에서는 드롭다운에서 Generic을 왜 넘겨줘야하는지 그 전략과 어떻게 해결했는지 공유해보려고 한다.

 

 

1. 왜 제네릭을 사용하려고 하는가?


<Selector
    categories={
      [
        { id: 0, content: '이름 순 정렬', customData: 'NAME' },
        { id: 1, content: 'id 순 정렬', customData: 'ID' },
        { id: 2, content: '검색한 챔피언 순', customData: 'RECENTLY_SEARCHED' },
      ]
    }
    initId={0}
    callback={(item) => onChangeSortFilter(item.customData)}
/>

 

드롭다운을 구현할 때 크게 고려해야할 것은 두 가지이다.

 

  1. UI로 나타낼 string.
  2. 상태관리 등 내부 로직에 필요한 커스텀 데이터.

 

드롭다운을 클릭 했을 때 부모 컴포넌트에서 내려준 콜백함수를 실행한다.

그리고 우리가 사전에 정의했던 커스텀 데이터를 return 해주면 부모 컴포넌트에서 확인할 수 있는 구조로 정의했다.

드롭다운을 효과적으로 사용할 수 있는 상황은 사용자가 골라야할 데이터가 몇 개 없는 경우라고 생각한다. (개인적으로 10개 이하일 때 사용해야한다고 생각한다.)

 

예를 들어, 이번에 만들고 있는 서비스는 정렬을 위해 (이름 순, id 순, 최근 검색한 챔피언 순)으로 총 3 가지 범주를 제공한다. 이에 해당하는 내용을 따로 type을 정의해서 핸들러나 상태로 사용하는 전략을 세웠다.

 

export type ORDER_TYPE = 'NAME' | 'ID' | 'RECENTLY_SEARCHED';

 

 

위처럼 primitive type이 아니라 내부 로직으로 사용하기 위해서 특정 type이나 객체를 저장하고 싶을 때가 있다.

이런 경우에는 커스텀 데이터를 넣어둘 props를 unknown으로 정의하고 제네릭으로 타입 캐스팅을 해줘야하는데, 컴포넌트를 선언할 때 도대체 어떻게 타입을 넣어줘야하는지 감이 안잡혔었다.

 

 

 

2. interface 캐스팅에 사용하던 React.FC와 제네릭


//@types\react\index.d.ts

    // Class Interfaces
    // ----------------------------------------------------------------------

    type FC<P = {}> = FunctionComponent<P>;

    interface FunctionComponent<P = {}> {
        (props: P, context?: any): ReactElement<any, any> | null;
        propTypes?: WeakValidationMap<P> | undefined;
        contextTypes?: ValidationMap<any> | undefined;
        defaultProps?: Partial<P> | undefined;
        displayName?: string | undefined;
    }

 

익명 함수 혹은 화살표 함수로 props를 쉽게 캐스팅 해줄 수 있는 인터페이스 중에 React.FC가 있다.

React가 원래 TypeScript를 사용하던 프레임워크가 아니기 때문에 따로 설치한 @types/react에서 정의된 props를 보면 context나 props에 정의한 interface를 넣어주는걸 확인할 수 있다. (그리고 제네릭은 없다.)

 

결론적으로 제네릭을 사용할 때에는 React.FC를 사용하지 않아야 한다.

 

( + 다른 블로그에서 'React.FC 사용을 지양해야하는 이유' 와 같은 포스트가 있어서 읽어봤었는데 implicit children props랑 geneirc을 사용할 수 없다는 이유로 적혀있었다. React 18이 되면서 children props는 이제 명시적으로 작성해줘야하기 때문에 제네릭을 사용하는 경우만 고려해주면 되겠다.)

 

// withoutFC.tsx
interface MyInterface {
  id: number;
  content: string;
}

function myFunc = ({ id, content }: MyInterface) => {
    //...
}

 

일반 hooks를 정의할 때 사용하는 함수처럼 객체와 그 객체의 프로퍼티를 정의해주는 인터페이스를 묶어주는 식으로 사용하면 제네릭을 사용할 수 있다. 정의되지 않은 값을 넣어주기 때문에 초기값은 unknown으로 처리해줬다.

 

3. 드롭다운 구현


// Selector.tsx
interface CategoryProps<T> {
  id: number;
  content: string;
  customData?: T;
}

interface SelectorProps<T> {
  categories: CategoryProps<T>[];
  initId?: number;
  placeholder?: string;
  callback?: (item: CategoryProps<T>) => void;
}

const Selector =  <T extends unknown>(props: SelectorProps<T>) => {
  const { categories, initId, placeholder, callback } = props;
  const [selectedCategory, setSelectedCategory] = useState<CategoryProps<T>>({ id: -1, content: '' });
  //..
}

 

드롭다운도 두 가지 종류가 있을 수 있다고 가정했다.

 

  1. 최초값 중 특정 index의 값을 초기값으로 가지는 드롭다운. (ex. 서울 🔻)
  2. placeholder로 초기값이 없는 드롭다운. (ex. ----지역을 선택해주세요.----🔻)

 

두 값 모두 optional props로 받고, useEffect에서 처리해주면 쉽게 처리 가능하다.

이어서, 가장 중요한 기능 두 가지를 만들어야한다.

  1. 버튼을 눌렀을 때, 리스트를 토글하는 기능.
  2. 특정 값을 마우스 이벤트로 받았다면, 그 값을 콜백함수를 통해 부모 컴포넌트로 보내주는 기능.

 

// Selector.tsx
//..

const onHandlerToggleSelector = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => {
    e?.preventDefault();
    e?.stopPropagation();
    setOpenList((prev) => !prev);
  }, []);

  const onChangeSelectedItem = useCallback((e: React.MouseEvent<HTMLSpanElement>, category: CategoryProps<T>) => {
    e.preventDefault();
    e.stopPropagation();
    setSelectedCategory(category);
    callback && callback(category);
    onHandlerToggleSelector();
  }, [callback]);
  
  return (
    //여러분 입맛대로 만드세요!
  )

 

3-1. 드롭다운 컴포넌트 생성


이 컴포넌트를 생성할 때 어떤 type으로 customData props에 바인딩 해줄지 정의해야한다.

// 드롭다운을 사용하려는 부모 컴포넌트.tsx
// customData로 사용한 타입 T가 콜백될 때 optional로 반환됨.

const Parent = () => {
  // 드롭다운 콜백 핸들러
  const onChangeSortFilter = useCallback((val?: ORDER_TYPE) => {
    if (!val) return;
    dispatch({ type: 'ORDER', val: val });
  }, []);
  return (
    <div>
      <Selector<ORDER_TYPE>
        categories={
          [
            { id: 0, content: '이름 순 정렬', customData: 'NAME' },
            { id: 1, content: 'id 순 정렬', customData: 'ID' },
            { id: 2, content: '검색한 챔피언 순', customData: 'RECENTLY_SEARCHED' },
          ]
        }
        initId={0}
        callback={(item) => onChangeSortFilter(item.customData)}
      />
    </div>
  );

 

jsx 문법 안에서 꺾쇠로 타입을 넣어줘야한다.

신기한 점은 callback 등을 통해 받는 Generic이 optional로 반환된다는 점이다.

핸들러에 optional 체크를 넣어줘서 대응해야한다.

 

+ 개선하고 싶은 점


1. Default type 지정

 

위에서도 언급하긴 했지만, 드롭 다운에서 Generic이 유용한 경우는 customData로 특정 타입 혹은 객체를 넣어주는 경우이다. 만약 10개 이상의 많은 값을 넣어주거나, 범주로 제한되지 않는 매번 변경될 수 있는 값이 들어갈 수 있다. default type으로 string 등을 지정한다면 편할 것 같다.

 

const Selector = <T extends unknown>(props: SelectorProps<T | string>) => (X)

const Selector = <T extends unknown>(props: SelectorProps<Boolean(typeof T) ? T : string>) => (X)

 

문제는 아래 정의된 콜백 함수나 상태에서 저 string 때문에 타입 체커가 에러를 뿜어낸다.

삼항 연산자도 안된다. 이런..

 

string type을 지정했을 때 예외 상황을 모든 함수나 상태에서 처리해주는게 효율적인 해결방법인지 모르겠다.