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

이주영·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
https://www.danny-log.xyz/ 문제 해결 과정을 정리하는 블로그입니다.

0개의 댓글