✨ 생성된 프로젝트에서 react에서 타입스크립트를 지원하기 위한 타입들에 대하여 설명하고 실제로 적용해보기
- React 18버전 이전까지 FC 사용을 지양했던 이유와 이제 다시 사용할 수 있는 이유는 무엇일까?
- 만약 FC를 사용할 수 없는 환경이라면 이유는 무엇이고 어떻게 대처가 가능한가
Function Component
타입의 줄임말로, React + Typescript 조합으로 개발할 때 사용하는 타입입니다. 함수형 컴포넌트 사용 시 타입 선언에 쓸 수 있도록 React에서 제공하는 타입입니다.
React.FC
는 함수 컴포넌트의 Props 타입을 간결하게 표현하기 위해 만들어졌습니다. 이를 통해 타입스크립트를 사용하는 프로젝트에서 함수 컴포넌트의 Props를 명시적으로 지정할 수 있었습니다.
뿐만 아니라 Props에 기본적으로 children
이 포함되어 있어, 컴포넌트에서 자식 요소를 손쉽게 다룰 수 있기를 의도했다고 합니다.
개인적으로 TS+React 프로젝트에서 컴포넌트를 작성할 때 아래와 같은 형태를 많이 사용했습니다.
import { FC } from 'react'
interface Props {
name: string
}
const Foo: FC<Props> = ({ name }) => {
return (
...
)
}
참고한 블로그에서도 그렇고 저도 그렇고, 별 다른 이유는 없이 FC
에 제네릭을 통해 Props를 전달할 수 있어 오히려 간결하고 편하다는 느낌으로 계속 사용해왔던 것 같습니다. Props
를 넘겨받는 방법이 위와 같은 방법 하나가 아님을 알고 있긴 해 개인 취향 차이로만 생각했었습니다.
FC를 사용하지 않는 방법
interface Props {
name: string
}
const Foo = ({ name }: Props) => {
return (
...
)
}
FC
를 이용하면 컴포넌트 props는 type이 ReactNode
인 children
을 암시적으로 가지게 됩니다.
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`의 타입이 명확하지 않아 의도하지 않게 동작하는 등의 문제가 있었습니다.
React 18 업데이트로, FC
의 암시적인 children
이 삭제되었습니다. 해당 변경 사항은 이 PR에서 확인할 수 있습니다.
export default function App() {} 와 같은 일반 함수 선언문에서 ❌
위와 같은 형태에서는 FC
를 사용하지 않고 명확하게 지정해주는 방법으로도 가능합니다.
interface Props {
name: string
}
export default function Foo({ name }: Props) {
return (
...
)
}
가장 문제였던 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) => {}
ReactNode
는 children
속성의 타입으로 가장 많이 사용하는 타입이기도 합니다.
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 내에서 사용할 수 있는 모든 요소의 타입을 허용한다
ReactElement
는 ReactNode
에 포함되어 있기도 합니다. 우선 d.ts
에서 살펴봅시다.
interface ReactElement<
P = any,
T extends string | JSXElementConstructor<any> =
| string
| JSXElementConstructor<any>
> {
type: T
props: P
key: Key | null
}
ReactElement
는 createElement
함수를 통해 생성된 객체의 타입입니다. 즉, 위에서 알아보았던 ReactNode
과 달리 원시타입을 포함하지 않고 완성된 jsx
요소만을 허용합니다.
⭐️ ReactElement는 원시타입을 포함하지 않고 완성된 `jsx` 요소만을 허용한다.
따라서 jsx
요소를 리턴하는 children
에 대해서는 ReactElement
을 타입으로 지정해 주어도 전혀 문제가 없습니다.
import { ReactElement } from 'react'
interface Props {
children: ReactElement
}
const Component: FC<Props> = ({ children }) => {
return <div>{children}</div>
}
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 }
PropsWithChildren
의 children
타입이 옵셔널인 것을 확인할 수 있습니다.
🤔 FC에서 children이 암시적으로 존재해서 문제가 있었다면서요.
👩🏻💻 맞아요.
그래서 PropsWithChildren도 children을 넘겨주지 않아도
에러가 발생하지 않기 때문에 의도하지 않은 동작을 할 수 있어요.
따라서, children을 반드시 받아야 하는 경우에는
PropsWithChildren을 사용하지 않는 게 좋아요.
🤔 그럼 그냥 ReactNode나 ReactElement를 사용하면 되나요?
👩🏻💻 네, 그래도 돼요.
일반적인 경우에는 ReactNode를 사용하는 것 같아요.
Props에 { children: ReactNode }로 명시해주면
사용하는 쪽에서 "children이 없으면 필수라고 에러"로 알려줘요.
React에서 특정 DOM을 선택해야할 땐 이 기능을 대체할 수 있는 useRef 훅을 제공합니다.
useRef
를 사용하다보면 인자로 어떨 때 null을 넣어야할지? 비어둘지? 고민을 하게 되었었는데요. 이와 관련이 되어 있습니다!
⭐️ useRef에는 3가지 오버로딩이 존재
1. 인자: [초기값] => 리턴: MutableRefObject<T>;
2. 인자: [초기값 | null] => 리턴: RefObject<T>;
3. 인자: [] => 리턴: MutableRefObject<T | undefined>;
위를 보게 되면 총 2개의 타입이 존재합니다. MutableRefObject
과 RefObject
입니다.
✔️ 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를 할당해주려면 아래와 같이 에러가 발생해요.
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>
}
이렇게 한번 해봅시다. 그럼 아래와 같이 에러가 발생해요.
🤔 호출 시그니처부터 모르겠는데요..
👩🏻💻 호출 시그니처는 타입스크립트에서 함수의 타입을 지정할 때 사용하는 문법이에요.
함수에 함수를 인수로 전달하거나,
함수를 반환하는 경우 이 문법을 통해 인수나 반환 함수의 타입을 지정할 수 있어요.
SetStateAction을 다시 살펴보면 (prev: S) => S 라고 되어있어요.
사실 이는 우리가 setState(여기에) 넣는 것이고
정확하게는 아무것도 리턴하지 않아요. 아래 제가 테스트해 본 결과를 같이 봅시다.
const handleInputValue = () => {
const test = setData('hello')
console.log(`test: ${test}`) // test: undefined
}
👩🏻💻 위처럼 undefined 즉, 아무 것도 리턴하지 않고 있는 것도 확인해보았고,
setData에 마우스를 가져가서 확인해도 리턴타입이 void예요.
결론적으로, SetStateAction는 본인을 인자로 하여 void를 리턴하도록 해줄 수 있는
호출 시그니처라는 자기를 감싸주는 그런 것이 필요했던 거죠.
위에서 Dispatch의 필요성에 대해 조금 알아보았습니다.
⭐️ Dispatch는 React에서 상태를 업데이트하는 함수의 호출 시그니처
d.ts
에서 직접 확인해봅시다.
type Dispatch<A> = (value: A) => void
위에서 알아보았던 대로, 리턴타입을 void로 해주는 호출 시그니처의 모습이 맞았습니다.
- 각각 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
를 통해 선언해보면 에러가 발생합니다.
4️⃣ 차이점4: 선언 병합이 가능한가
✔️ type alias : 불가능 ❌
✔️ interface : 가능 ⭕️
타입스크립트에서 선언 병합(declaration merging)이란 같은 이름을 가진 여러 선언들을 하나로 합치는 기능을 말합니다.
// interface
interface Person {
name: string
age: string
}
interface Person {
address: string
}
위와 같이 작성하면 Person
이라는 타입은 결국 아래와 같은 형태로 병합됩니다.
병합되어 결국에는 name
, age
, address
가 프로퍼티가 된 것을 확인할 수 있었습니다.
이를 비슷하게 type alias
에서 하게 되면 아래와 같이 식별자가 중복되었다면서 에러가 발생합니다.
🤔 차이점은 알겠는데, 그래서 언제 어떤 걸 써야 하나요?
👩🏻💻 이 또한 프로젝트의 컨벤션에 맞게 사용해야겠지만, 저 혼자 프로젝트를 진행한다면
특별한 경우를 제외하곤 interface를 사용할 것 같습니다.
항상 확장 가능성을 염두해 둔다면, interface 사용을 좀 더 고려할 것 같습니다.
여기서 특별한 경우라면 튜플, 리터럴, 유니온 등과 같은 type alias에서만 사용 가능한 경우입니다.