React에서 TypeScript가 쓰고 싶다면 알아야 할 타입들

Doeunnkimm·2023년 8월 5일
8

TypeScript

목록 보기
1/3
post-thumbnail

react에서 타입스크립트 지원을 위해 자체적으로 정의한 타입

✨ 생성된 프로젝트에서 react에서 타입스크립트를 지원하기 위한 타입들에 대하여 설명하고 실제로 적용해보기

1. React.FC

- React 18버전 이전까지 FC 사용을 지양했던 이유와 이제 다시 사용할 수 있는 이유는 무엇일까?
- 만약 FC를 사용할 수 없는 환경이라면 이유는 무엇이고 어떻게 대처가 가능한가

FC ?

Function Component 타입의 줄임말로, React + Typescript 조합으로 개발할 때 사용하는 타입입니다. 함수형 컴포넌트 사용 시 타입 선언에 쓸 수 있도록 React에서 제공하는 타입입니다.

FC가 태어난 배경

React.FC는 함수 컴포넌트의 Props 타입을 간결하게 표현하기 위해 만들어졌습니다. 이를 통해 타입스크립트를 사용하는 프로젝트에서 함수 컴포넌트의 Props를 명시적으로 지정할 수 있었습니다.

뿐만 아니라 Props에 기본적으로 children이 포함되어 있어, 컴포넌트에서 자식 요소를 손쉽게 다룰 수 있기를 의도했다고 합니다.

React.FC의 사용

개인적으로 TS+React 프로젝트에서 컴포넌트를 작성할 때 아래와 같은 형태를 많이 사용했습니다.

import { FC } from 'react'

interface Props {
    name: string
}

const Foo: FC<Props> = ({ name }) => {
    return (
        ...
    )
}

참고한 블로그에서도 그렇고 저도 그렇고, 별 다른 이유는 없이 FC에 제네릭을 통해 Props를 전달할 수 있어 오히려 간결하고 편하다는 느낌으로 계속 사용해왔던 것 같습니다. Props를 넘겨받는 방법이 위와 같은 방법 하나가 아님을 알고 있긴 해 개인 취향 차이로만 생각했었습니다.

Props를 넘겨 받는 또 다른 방법들

FC를 사용하지 않는 방법

interface Props {
    name: string
}

const Foo = ({ name }: Props) => {
    return (
        ...
    )
}

FC를 쓰지 말야야 하는 이유

children을 암시적으로 가지고 있습니다

FC를 이용하면 컴포넌트 props는 type이 ReactNodechildren을 암시적으로 가지게 됩니다.

const App: React.FC = () => {
  return <div>hi</div>
}

const Example = () => {
  return (
    <App>
      <div>Unwanted children</div>
    </App>
  )
}

위 코드를 보게 되면 <App /> 컴포넌트에서 children을 다루고 있지 않음에도 Example에서 children을 넘겨주고 있으며, 어떤 런타임 에러도 발생하지 않습니다.

🤔 이게 왜 문제가 되나요?
👩🏻‍💻 Props의 명확성
`React.FC`가 `children`을 암시적으로 포함하면서 `Props`의 타입이 명확하지 않아 의도하지 않게 동작하는 등의 문제가 있었습니다.

👏 18 버전에서는 없어졌습니다

React 18 업데이트로, FC의 암시적인 children삭제되었습니다. 해당 변경 사항은 이 PR에서 확인할 수 있습니다.

FC를 사용할 수 없는 환경?

export default function App() {} 와 같은 일반 함수 선언문에서 ❌

위와 같은 형태에서는 FC를 사용하지 않고 명확하게 지정해주는 방법으로도 가능합니다.

interface Props {
    name: string
}

export default function Foo({ name }: Props) {
    return (
        ...
    )
}

🔥 그래서 FC랑 명확하게 지정하는 방법, 둘 중에 뭘 써야 하는가?

가장 문제였던 FC의 암묵적인 children이 18 버전에서 사라졌기 때문에
어떤 것을 사용해야 할 지의 판단은 개발자의 몫이 될 것 같습니다.

다만, FC에는 제네릭 타입이 포함되어 있기 때문에
타입이 길고 장황해질 수 있다는 의견도 있었습니다.

FC에 이미 제네릭이 있기 떄문에 Props에도 제네릭이 필요할 경우
FC<Props<string>>과 같이 작성해줘야 해 복잡해질 수 있다고 보았습니다.

그래서 결론적으로 저의 생각은 아래와 같습니다.

기본적으로 프로젝트의 컨벤션에 맞게 작성하면 될 것 같습니다.
저 혼자 프로젝트를 진행한다면 FC를 사용할 것 같습니다.

제가 FC를 사용하는 이유는 FC 이후 부분(매개변수 부분)이, 기본 React에서의 코드 스타일과 비슷하기 때문입니다.

✔️ FC를 사용하는 경우
const Foo: FC<Props> = ({ name, age }) => {}

✔️ 명시적으로 나타내는 경우
const Foo = ({ name, age }: Props) => {}

2. ReactNode

ReactNodechildren 속성의 타입으로 가장 많이 사용하는 타입이기도 합니다.

import { ReactNode } from 'react'

interface Props {
  children: ReactNode
}

const Component: FC<Props> = ({ children }) => {
  return <div>{children}</div>
}

@types/react에서 살펴본 ReactNode은 다음과 같았습니다.

type ReactNode =
  | ReactElement
  | string
  | number
  | Iterable<ReactNode>
  | ReactPortal
  | boolean
  | null
  | undefined

ReactNode 타입은 jsx 내에서 사용할 수 있는 모든 요소의 타입을 의미합니다. 즉 string, null, undefined 등을 포함하는 가장 넓은 범위를 갖는 타입이죠!

const node: React.ReactNode = <div />
const node2: React.ReactNode = 'hello world'
const node3: React.ReactNode = 123
const node4: React.ReactNode = undefined
const node5: React.ReactNode = null
⭐️ ReactNode는 가장 넓은 범위를 갖는 타입이며,
   원시타입 및 jsx 내에서 사용할 수 있는 모든 요소의 타입을 허용한다

3. ReactElement

ReactElementReactNode에 포함되어 있기도 합니다. 우선 d.ts에서 살펴봅시다.

interface ReactElement<
  P = any,
  T extends string | JSXElementConstructor<any> =
    | string
    | JSXElementConstructor<any>
> {
  type: T
  props: P
  key: Key | null
}

ReactElementcreateElement 함수를 통해 생성된 객체의 타입입니다. 즉, 위에서 알아보았던 ReactNode과 달리 원시타입을 포함하지 않고 완성된 jsx 요소만을 허용합니다.

⭐️ ReactElement는 원시타입을 포함하지 않고 완성된 `jsx` 요소만을 허용한다.

따라서 jsx 요소를 리턴하는 children에 대해서는 ReactElement을 타입으로 지정해 주어도 전혀 문제가 없습니다.

import { ReactElement } from 'react'

interface Props {
  children: ReactElement
}

const Component: FC<Props> = ({ children }) => {
  return <div>{children}</div>
}

4. PropsWithChildren

PropsWithChildren 타입을 사용하게 되면 반복적으로 children 타입을 설정해줘야하는 번거로움이 사라질 수 있습니다.

import { PropsWithChildren } from 'react'

interface Props {
  name: string
}

export const Foo: FC<PropsWithChildren<Props>> = ({ name, children }) => {
  return (
    <>
      <div>{name}</div>
      <div>{children}</div>
    </>
  )
}

이전에는 Props에 children: ReactNode 혹은 children: ReactElement 하고 적어주었었습니다.

반면, 위 코드에서는 Props에서 children을 명시하지 않고 바로 children을 사용해 주었습니다.

d.ts에서 살펴봅시다.

type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined }

PropsWithChildrenchildren 타입이 옵셔널인 것을 확인할 수 있습니다.

🤔 FC에서 children이 암시적으로 존재해서 문제가 있었다면서요.
👩🏻‍💻 맞아요.
   그래서 PropsWithChildren도 children을 넘겨주지 않아도
   에러가 발생하지 않기 때문에 의도하지 않은 동작을 할 수 있어요.

   따라서, children을 반드시 받아야 하는 경우에는
   PropsWithChildren을 사용하지 않는 게 좋아요.
🤔 그럼 그냥 ReactNode나 ReactElement를 사용하면 되나요?
👩🏻‍💻 네, 그래도 돼요.
   일반적인 경우에는 ReactNode를 사용하는 것 같아요.
   Props에 { children: ReactNode }로 명시해주면
   사용하는 쪽에서 "children이 없으면 필수라고 에러"로 알려줘요.

5. RefObject

React에서 특정 DOM을 선택해야할 땐 이 기능을 대체할 수 있는 useRef 훅을 제공합니다.

useRef를 사용하다보면 인자로 어떨 때 null을 넣어야할지? 비어둘지? 고민을 하게 되었었는데요. 이와 관련이 되어 있습니다!

⭐️ useRef에는 3가지 오버로딩이 존재

1. 인자: [초기값]         => 리턴: MutableRefObject<T>;

2. 인자: [초기값 | null]  => 리턴: RefObject<T>;

3. 인자: []             => 리턴: MutableRefObject<T | undefined>;

위를 보게 되면 총 2개의 타입이 존재합니다. MutableRefObjectRefObject입니다.

✔️ useRef는 .current 프로퍼티에 변경 가능한 값을 담고 있는 “상자” 📦
  인수를 .current에 저장하게 된다.

  아래 두 개의 리턴타입은 .current 프로퍼티를 직접 수정 가능 여부에 따라 구분

✔️ MutableRefObject<T>
  직접 수정 가능 ⭕️

✔️ MutableRefObject<T | undefined>
  직접 수정 불가능 ❌, 다만 undefined이 아님이 체크되면 가능 ⭕️

✔️ RefObject<T>
  직접 수정 불가능 ❌

즉, 특정 초기값 혹은 비어두게 되면 current를 직접 수정 가능하며, null을 부여할 경우 current를 직접 수정 불가능하게 됩니다. 쉽게 말해 null로 부여할 경우 아래와 같은 경우에 에러가 발생하는 것이죠.

const ref = React.useRef<number>(null)

ref.current += 1
//~~~~~~~~~~~~~~ 읽기 전용 속성이므로 'current'에 할당할 수 없습니다.
🤔 그래서 결론적으로 어떤 상황에 어떤 걸 사용해야 하나요?
👩🏻‍💻 DOM 요소를 참조하고 싶은 경우에는 null을 입력해주면 돼요.
   DOM 요소에 ref를 연결하고 싶다면 readonly인 RefObject만을 할당할 수 있어요.

   만약 DOM 요소에 MutableRefObject한 ref를 할당해주려면 아래와 같이 에러가 발생해요.

image

6. SetStateAction

Props로 상태를 업데이트하는 함수, 예를 들어 setData를 넘겨야 할 때도 있습니다. 이때 타입을 어떻게 주어야 할까요?

저는 평소에 아래와 같이 해주었었습니다.

interface Props {
  setData: (data: string) => void
}

사실 위와 같이 타입을 선언해 주어도 문제를 없지만 일반 함수와 구별이 되지 않으며 state함수임을 이름을 통해서만 유추해야 합니다.

⭐️ React에서는 state함수를 명시적으로 나타낼 수 있는 적절한 타입을 제공하고 있다

→ SetStateAction !!

이떄 사용할 수 있는 타입이 바로 SetStateAction입니다. SetStateAction은 React의 useState 또는 useReducer 훅에서 상태 값을 업데이트하기 위해 사용되는 타입입니다. 이 타입은 새로운 상태 값을 계산하는 함수를 나타내며, 이 함수는 현재 상태 값을 인자로 받아 새로운 상태 값을 반환합니다.

사용 방법은 아래와 같습니다.

interface Props {
  setData: Dispatch<SetStateAction<string>>
}

d.ts에서 SetStateAction는 다음과 같습니다.

type SetStateAction<S> = S | ((prevState: S) => S)

제네릭에 타입을 넘겨주면 state함수의 형태에 알맞게 타입을 지정해줍니다. 그런데 여기서 드는 생각이 있습니다.

🤔 그럼 SetStateAction만 써도 될 거 같은데, 겉에 왜 Dispatch가 필요한가요?
👩🏻‍💻 그럼 한번 없애보고 뭐가 문제가 되는지 알아볼까요?

   interface Props {
     setData: SetStateAction<string>
   }

   이렇게 한번 해봅시다. 그럼 아래와 같이 에러가 발생해요.

image

🤔 호출 시그니처부터 모르겠는데요..
👩🏻‍💻 호출 시그니처는 타입스크립트에서 함수의 타입을 지정할 때 사용하는 문법이에요.
   함수에 함수를 인수로 전달하거나,
   함수를 반환하는 경우 이 문법을 통해 인수나 반환 함수의 타입을 지정할 수 있어요.

   SetStateAction을 다시 살펴보면 (prev: S) => S 라고 되어있어요.
   사실 이는 우리가 setState(여기에) 넣는 것이고
   정확하게는 아무것도 리턴하지 않아요. 아래 제가 테스트해 본 결과를 같이 봅시다.
const handleInputValue = () => {
  const test = setData('hello')
  console.log(`test: ${test}`) // test: undefined
}

image

👩🏻‍💻 위처럼 undefined 즉, 아무 것도 리턴하지 않고 있는 것도 확인해보았고,
   setData에 마우스를 가져가서 확인해도 리턴타입이 void예요.

   결론적으로, SetStateAction는 본인을 인자로 하여 void를 리턴하도록 해줄 수 있는
   호출 시그니처라는 자기를 감싸주는 그런 것이 필요했던 거죠.

7. Dispatch

위에서 Dispatch의 필요성에 대해 조금 알아보았습니다.

⭐️ Dispatch는 React에서 상태를 업데이트하는 함수의 호출 시그니처

d.ts에서 직접 확인해봅시다.

type Dispatch<A> = (value: A) => void

위에서 알아보았던 대로, 리턴타입을 void로 해주는 호출 시그니처의 모습이 맞았습니다.

8. type alias와 interface의 차이점

- 각각 type alias와 interface로 props 타입을 정의하고 주석을 통해 차이점을 작성
- 비교를 통해 무엇을 사용하는게 좋을지 자기 의견을 자유롭게 써볼 것

타입스크립트에서 named Type을 정의하는 방법은 두 가지가 있습니다.

// type alias
type TState = {
  name: string
  age: number
}

// interface
interface IState {
  name: string
  age: number
}
🤔 언제 type alias / interface를 사용해야 하나요?
👩🏻‍💻 대부분의 경우에는 type alias를 사용해도 되고 interface를 사용해도 돼요.
   그러나, 둘 사이에 존재하는 차이를 분명히 알고
   같은 상황에서는 동일한 방법으로 사용해 일관성을 유지해야 해요.

차이점에 대해 알아봅시다.


1️⃣ 차이점1: 타입을 확장하는 방식

✔️ type alias : &
✔️ interface  : extends

여기서 말하는 타입 확장은 기존에 정의된 타입을 기반으로 새로운 타입을 만드는 것을 의미합니다.

// type alias
type Person = {
  name: string
  age: number
}

type Developer = Person & { skill: string }
// interface
interface Person {
  name: string
  age: number
}

interface Developer extends Person {
  skill: string
}

2️⃣ 차이점2: 객체만 허용하는가

✔️ type alias : 리터럴 타입부터 객체까지 다룬다
✔️ interface  : 객체만 다룬다

interface의 경우 객체의 타입만을 다룹니다. 반면 type alias는 객체뿐만 아니라 리터럴 타입까지 해당 리터럴을 유니온 타입으로까지도 표현이 가능합니다.

// type alias
type Color = 'Red' | 'Green' | 'Blue'

// interface
interface ?? // 객체가 아닌 것은 다룰 수 X

3️⃣ 차이점3: mapped type 사용이 가능한가

✔️ type alias : 가능 ⭕️
✔️ interface  : 불가능 ❌

mapped type은 기존의 타입을 변환하여 새로운 타입을 만들기 위한 도구로, 기존의 타입을 순회합니다.

// type alias
type PersonField = 'name' | 'address' | 'phone'

type Person = {
  [key in PersonField]: string
}

비슷하게 interface를 통해 선언해보면 에러가 발생합니다.

image


4️⃣ 차이점4: 선언 병합이 가능한가

✔️ type alias : 불가능 ❌
✔️ interface  : 가능 ⭕️

타입스크립트에서 선언 병합(declaration merging)이란 같은 이름을 가진 여러 선언들을 하나로 합치는 기능을 말합니다.

// interface
interface Person {
  name: string
  age: string
}

interface Person {
  address: string
}

위와 같이 작성하면 Person이라는 타입은 결국 아래와 같은 형태로 병합됩니다.

image

병합되어 결국에는 name, age, address가 프로퍼티가 된 것을 확인할 수 있었습니다.

이를 비슷하게 type alias에서 하게 되면 아래와 같이 식별자가 중복되었다면서 에러가 발생합니다.

image


🤔 차이점은 알겠는데, 그래서 언제 어떤 걸 써야 하나요?
👩🏻‍💻 이 또한 프로젝트의 컨벤션에 맞게 사용해야겠지만, 저 혼자 프로젝트를 진행한다면

   특별한 경우를 제외하곤 interface를 사용할 것 같습니다.
   항상 확장 가능성을 염두해 둔다면, interface 사용을 좀 더 고려할 것 같습니다.

   여기서 특별한 경우라면 튜플, 리터럴, 유니온 등과 같은 type alias에서만 사용 가능한 경우입니다.

참고문서

profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻

0개의 댓글