타입스크립트로 재사용 컴포넌트 만들기

이주영·2023년 3월 11일
0
post-thumbnail

React에서 TS를 활용한 generic component 만들기

Generic Component란 📘

데이터만 넣으면 공통으로 사용할 수 있는 컴포넌트를 말합니다. 예를 들어 Dropdown, select, Form 그리고 Field 컴포넌트 등이 있습니다. 그렇다면 Typescript를 활용하여 generic component를 만들기 위해서 알아야 하는 선수 지식이 뭘까요?

Generics (사전 지식) 📕

소프트웨어 엔지니어링에서 중요한 요소는 컴포넌트를 APIs를 사용하는 데 있어 잘 설계하는 것도 중요하지만 어떠한 데이터에를 넣어도 재사용이 가능한 컴포넌트를 만드는 것이라고 합니다. 이러한 측면에서 타입스크립트를 사용할 때 기본적인 문법이 제네릭이라고 합니다.

함수를 선언해주고 다양한 곳에서 재사용한다고 가정해보겠습니다. 공식 문서의 예시를 가져왔습니다. identity 함수에 number 타입 변수를 지정한다면 호출하여 사용할 때 number 타입만 받을 수 있기에 재사용하는 데 불편함을 느낍니다. 그렇다면 어떻게 할까요?

function identity(arg: number): number {
	return arg;
}

//호출부 
identity(5) // success
identity('hi') // error!! 

any 타입을 지정해서 어떤 타입도 파라미터로 받을 수 있는 함수를 만들어 재사용할 수 있게 해야 하는 걸까요?

function identity(arg: any): any {
	return arg;
}

//호출부 
identity(5) // success
identity('hi') // success

이것도 하나의 방법일 수 있겠지만 파라미터의 타입을 알 수 없어지기 때문에 타입 시스템의 이점을 활용할 수 없다는 의미일 수도 있습니다. 그래서 우리가 사용해야하는 것은 제네릭 타입 변수입니다.

function identity<T>(arg: T): T {
	return arg;
}

//호출부 
identity<number>(5) // success
identity<string>('hi') // success

위의 예시와 같이 identity 함수에 T라는 제네릭 타입 변수를 더하면 함수가 호출된 시점의 타입을 받아서 identity 함수를 재사용할 수 있습니다. 위에서 봤던 any 타입과는 다르게 제네릭 타입 변수를 사용하면 파라미터와 리턴 타입 정보를 잃지 않습니다.

위에 호출 부에서 사용했던 코드를 살펴보겠습니다. 제네릭 함수를 재사용할 때 <>안에 해당하는 타입을 꼭 선언해야 할까요? 선언해도 되지만 타입스크립트의 타입시스템이 추론을 해준다고 합니다. 그래서 간단한 타입의 인자가 들어갈 때 생략이 가능하지만 복잡해질 땐 타입 추론을 하기 쉽도록 타입을 지정해주는 것이 좋다고 합니다.

identity(5) // success
identity('hi') // success


// 복잡한 인자 
type = {
a: string[],
b: 복잡한 인자의 객체 [],
...
}

identity<복잡한인자타입>(복잡한 인자) // success

제네릭 타입을 사용하는 이유 ❓

제네릭은 선언 시점이 아니라 생성 시점에 타입을 명시하여 하나의 타입만이 아닌 다양한 타입을 사용할 수 있도록 하는 기법이다. 한 번의 선언으로 다양한 타입에 재사용이 가능하다는 장점이 있습니다.

다시 말해 제네릭 타입인 함수의 정의부에서 타입이 지정되는 게 아니라 호출된 시점에 들어간 타입에 따라 다양한 타입을 사용할 수 있도록 하는 방식이라고 이해할 수 있을 것 같습니다.

제네릭의 제약 ⌛

위에 있는 identity 함수를 보면


function identity<T>(arg: T): T {
console.log(text.length) // 여기서 text.length의 타입은 지정되어있지 않기 때문에 에러가 에러가 발생합니다. 
	return arg;
}

그래서 제네릭 타입에 직접 타입을 지정할 수 있습니다.

function identity<T>(arg: T[]): T[] {
console.log(text.length) // success
	return arg;
}

이렇게 제네릭 타입 변수를 지정할 수도 있지만 <> 안에서 추론을 도와줄 수 있는 방법도 있습니다.

interface LengthWise {
  length: number;
}

function identity<T extends LengthWise>(arg: T): T {
console.log(text.length) // success
	return arg;
}

class 문법의 extends와 비슷하게 사용할 수 있습니다. 제네릭 함수 안에서 LengthWise라는 인터페이스를 받도록 설정하여 제네릭 타입 변수(text : T) 에서 변경하지 않고 함수 내에서 해결할 수 있게 됐습니다.

TS를 활용하여 react component 만들기 📕

데이터에 따른 드롭다운을 구현해보려고 합니다. 해당 레포는 하단에 첨부하였습니다.

- SelectableListSection 컴포넌트

const SelectableListSection = () => {
  return (
    <div className={classes.section}>
      <div>
        <h2>야채 데이터 드롭다운</h2>
        <SelectableList
          items={vegetablesList}
          renderItem={({ label, icon }) => `${icon} ${label}`}
        />
      </div>
      <div>
        <h2>왕좌의 게임 데이터 드롭다운</h2>
        <SelectableList
          items={charactersList}
          renderItem={(char) => <CharacterListItem character={char} />}
        />
      </div>
    </div>
  );
};

현재 SelectableList 컴포넌트로 각각 다른 데이터가 props로 넘겨지고 있습니다. 두 개의 데이터의 타입은 아래의 예시와 같습니다.

type Vegetable = {
  id: string;
  label: string;
  icon: string;
};

type Character = {
  id: string;
  firstName: string;
  lastName: string;
  photo: string;
};

"타입이 전혀 다른 데이터를 어떻게 타입을 지정해줘야 하는 거지?" 라는 생각이 들 수 있습니다. 즉 재사용 컴포넌트를 만들 때 호출된 시점에서 타입을 적용해 런타임 에러를 발생시키지 않을 방법인 타입스크립트의 제네릭 방식을 사용하면 됩니다.

- SelectableList 컴포넌트 (핵심 재사용 컴포넌트)

import { ReactNode, useState } from 'react';
import classes from './SelectableList.module.css';
import clsx from 'clsx';

type SelectItem = { id: string };

type SelectableListProps<T> = {
    items: T[];
  renderItem?: (item: T) => ReactNode;
};

const takeId = ({ id }: SelectItem) => id;

export function SelectableList<T extends SelectItem>({
  items,
  renderItem = takeId,
}: SelectableListProps<T>) {
  const [selectedItem, setSelectedItem] = useState<T>();
  const [isOpen, setIsOpen] = useState(false);

  const handleToggle = () => {
    setIsOpen((isOpen) => !isOpen);
  };

  return (
    <div className={classes.listWrapper}>
      <div className={classes.selectedValue} onClick={handleToggle}>
        {selectedItem ? renderItem(selectedItem) : 'Nothing is selected'}
      </div>
      <ul className={clsx(classes.list, isOpen && classes.visible)}>
        {items.map((item) => (
          <li
            className={clsx(classes.item, item.id === selectedItem?.id && classes.selected)}
            key={item.id}
            onClick={() => {
              setIsOpen(false);
              setSelectedItem(item);
            }}
          >
            {renderItem(item)}
          </li>
        ))}
      </ul>
    </div>
  );
}

우선 설명하기에 앞서, className은 보실 필요가 없습니다. 단지 스타일링을 해준 것 뿐입니다. 간단하게 살펴보면

type SelectItem = { id: string };

type SelectableListProps<T> = {
  items: T[];
  renderItem?: (item: T) => ReactNode;
};

const takeId = ({ id }: SelectItem) => id; // 아래에서  renderItem(item)으로 사용하기 위한 함수
type SelectableListProps<T>가 에디터에서 읽히지 않아
SelectableListProps<> 라고 작성하겠습니다:)

type SelectableListProps<티> 식별자에 선언된 객체를 살펴보면 T라는 변수는 제네릭 타입 변수로서 들어온 데이터의 타입을 함수의 파라미터와 같이 사용할 수 있습니다. 현재 두 개의 다른 데이터가 해당 컴포넌트의 Props로 내려오고 있는데 각각에 맞는 타입이 T에 들어간다고 생각하면 어렵지 않게 이해하실 수 있습니다.

오늘은 gitNation 영상을 통해 generic component를 타입스크립트를 활용해서 만드는 방법을 공부했습니다. 부족한 설명일 수 있으나 읽어주셔서 감사합니다. 궁금한 점이 있다면 댓글을 통해 알려주세요.

참고 자료

1.타입스크립트 핸드북
2. 타입스크립트 공식문서
3. gitNation knowledge hub

첨부 링크

  1. REPO 보러가기
profile
문제는 비판적으로 삶은 긍정적으로

0개의 댓글