본문 바로가기

React 컴포넌트와 타입스크립트 통합

Vite의 React TypeScript 템플릿이 정하는 설정을 확인하고, 함수 컴포넌트의 props와 children, useState의 상태, 이벤트 핸들러에 타입을 붙인 뒤, 클래스 컴포넌트와 함께 조합하면서 컴포넌트를 잘못 쓰는 코드가 어떻게 진단되는지 봅니다.

React는 화면을 컴포넌트(component)라는 작은 조각으로 나누고, 그 조각을 조합해 전체 UI를 만드는 라이브러리입니다. 컴포넌트는 부모에게서 props를 받아 화면을 그리고, 사용자의 입력에 따라 상태(state)를 바꾸며, 필요하면 부모가 넘긴 콜백으로 무슨 일이 있었는지 알립니다. 타입스크립트를 함께 쓰면 컴포넌트가 어떤 props를 받는지 타입으로 적어 둘 수 있어서, 필수 props를 빠뜨리거나 다른 타입의 값을 넘기는 실수를 화면을 띄우기 전에 찾을 수 있습니다.

이 장은 React의 컴포넌트의 개념과 종류와 props를 이용한 데이터 전달을 알고 있다고 보고, 타입을 붙이는 방법에 집중합니다. 이 글은 프로젝트의 설정을 확인한 뒤, 함수 컴포넌트의 props와 상태, 이벤트에 타입을 붙이고, 클래스 컴포넌트까지 함께 조합해 봅니다.

프로젝트 준비

React와 타입스크립트로 새 프로젝트를 시작할 때는 Vite의 react-ts 템플릿을 쓰는 것이 가장 간단합니다. 다음 네 명령은 차례로 프로젝트를 만들고, 그 폴더로 들어가고, 의존성을 설치하고, 개발 서버를 띄웁니다. 첫 명령의 -- 뒤에 적은 --template react-ts는 npm이 아니라 create-vite에 넘기는 옵션입니다. 터미널에서 첫 명령을 실행하면 create-vite가 린터로 Oxlint와 ESLint 가운데 무엇을 쓸지와 지금 바로 설치하고 실행할지를 차례로 묻습니다. 린터는 어느 쪽을 골라도 이 장의 내용과 상관없고, 설치와 실행은 뒤의 명령으로 따로 하므로 두 번째 질문에는 No를 고릅니다.

npm create vite@latest my-react-ts-app -- --template react-ts
cd my-react-ts-app
npm install
npm run dev

이 템플릿은 React와 React DOM을 의존성으로 설치하고, 그 타입 정의인 @types/react와 @types/react-dom, 타입스크립트는 개발 의존성으로 설치합니다. 2026년 10월 현재 템플릿은 React 19와 TypeScript 7.0을 쓰며, 7.0은 앞 장들의 기준인 6.0과 기본값이 같으므로 이 장의 내용은 그대로 적용됩니다. 설치되는 정확한 버전은 만들어진 package.json에서 확인합니다.

템플릿의 tsconfig.app.json에서 이 장과 관계있는 설정은 다음과 같습니다.

설정값뜻
jsxreact-jsxJSX를 React 17부터 쓰는 자동 런타임으로 바꾸므로, JSX만 쓰는 파일은 React를 가져오지 않아도 됩니다.
libES2023, DOM브라우저의 document와 HTMLInputElement 같은 DOM 타입을 씁니다.
noEmittrue타입스크립트는 검사만 하고, 실제 변환과 번들은 Vite가 맡습니다.
verbatimModuleSyntaxtrue타입만 가져올 때는 import type으로 적어야 합니다.
erasableSyntaxOnlytrueenum이나 생성자의 매개변수 속성처럼, 타입을 지우는 것만으로 자바스크립트가 되지 않는 문법을 막습니다.
noUnusedLocals, noUnusedParameterstrue쓰지 않는 지역 변수와 매개변수를 진단합니다.

여기서 알아 둘 점은 개발 서버가 타입을 검사하지 않는다는 것입니다. npm run dev의 Vite는 타입 표기를 지우고 코드를 변환할 뿐이어서, 타입 오류가 있어도 화면은 뜰 수 있습니다. 타입 검사는 package.json의 build 스크립트인 tsc -b && vite build의 앞부분이나 편집기가 맡으므로, 화면이 뜬 것을 검사를 통과한 것으로 보지 않습니다.

기존 React 프로젝트에 타입스크립트를 더할 때는 npm install --save-dev typescript @types/react @types/react-dom으로 필요한 패키지를 설치합니다. 그리고 tsconfig.json에 jsx 설정과 DOM 라이브러리를 두고, JSX가 들어 있는 파일의 확장자를 .tsx로 바꿉니다. JSX가 없는 타입이나 함수만 담은 파일은 .ts로 둡니다.

props의 타입

함수 컴포넌트는 props 객체를 매개변수로 받는 함수이므로, props의 타입은 그 매개변수의 타입으로 적습니다. 필드가 여러 개이면 interface나 type으로 따로 선언하는 편이 읽기 쉽습니다.

src/components/WelcomeMessage.tsx
interface WelcomeMessageProps {
  name: string;
  age?: number;
  isVisible: boolean;
  onButtonClick: (message: string) => void;
}

export default function WelcomeMessage({ name, age, isVisible, onButtonClick }: WelcomeMessageProps) {
  if (!isVisible) {
    return null;
  }

  return (
    <div>
      <h1>Hello, {name}!</h1>
      {age !== undefined && <p>You are {age} years old.</p>}
      <button onClick={() => onButtonClick(`Hello, ${name}!`)}>Say Hello</button>
    </div>
  );
}

name, isVisible, onButtonClick은 필수이고, 물음표를 붙인 age는 생략할 수 있어 타입이 number | undefined입니다. isVisible이 false이면 컴포넌트는 null을 돌려주어 아무것도 그리지 않으므로, 그 안의 버튼도 화면에 없습니다. 버튼을 누르면 컴포넌트는 DOM 이벤트 객체가 아니라 Hello, 이름! 모양의 문자열만 부모의 콜백에 넘기며, 콜백의 타입 (message: string) => void가 그 약속을 나타냅니다.

나이를 age !== undefined로 검사한 데는 이유가 있습니다. 흔히 쓰는 {age && <p>...</p>}는 age가 0이면 &&의 결과가 숫자 0이 되고, React는 숫자 0을 그대로 화면에 그립니다. 0살을 유효한 나이로 보여 주려면 값이 없는지를 직접 검사해야 하며, 반대로 number 타입이 음수나 NaN까지 막아 주지는 않으므로 바깥에서 받은 값의 범위는 따로 검증합니다.

부모가 이 컴포넌트를 잘못 쓰면 JSX를 쓰는 자리에서 진단됩니다. <WelcomeMessage name="Guest" />는 필수 props인 isVisible과 onButtonClick이 없다는 진단을 받고, name={123}은 Type 'number' is not assignable to type 'string'.이라는 진단을 받습니다. 타입은 이렇게 부모가 직접 적은 값을 검사할 뿐이며, 서버 응답처럼 실행 중에 들어오는 값까지 검증하지는 않습니다.

children과 React.FC

컴포넌트가 여는 태그와 닫는 태그 사이의 내용을 받으려면, props에 children을 직접 선언합니다. 문자열, 숫자, JSX 요소처럼 화면에 그릴 수 있는 모든 것을 받으려면 타입으로 ReactNode를 씁니다.

src/components/Panel.tsx
import type { ReactNode } from "react";

interface PanelProps {
  title: string;
  children?: ReactNode;
}

export default function Panel({ title, children }: PanelProps) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
    </section>
  );
}

ReactNode는 타입이므로 verbatimModuleSyntax에 맞게 import type으로 가져옵니다. JSX 요소만 받고 싶다면 ReactElement를 쓸 수 있지만, 타입스크립트로 children을 특정 태그, 예를 들어 <li>만으로 제한할 수는 없습니다.

컴포넌트의 타입을 React.FC<Props>로 적는 방식도 있습니다. 예전의 @types/react는 React.FC에 children을 몰래 더해 주었기 때문에 children을 받지 않는 컴포넌트에도 children을 넘길 수 있었지만, @types/react 18부터는 더하지 않습니다. 지금은 두 방식의 차이가 크지 않으며, React 공식 문서의 TypeScript 안내도 이 글처럼 매개변수에 props 타입을 적는 방식을 씁니다.

useState의 타입

useState는 초기값으로 상태의 타입을 추론합니다. 초기값이 상태가 가질 수 있는 값의 일부만 나타낼 때는, 꺾쇠괄호로 타입을 직접 적어 줍니다.

src/components/Counter.tsx
import { useState } from "react";

interface User {
  id: number;
  name: string;
}

export default function Counter() {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState<User | null>(null);

  const fetchUser = () => {
    setTimeout(() => {
      setUser({ id: 1, name: "Alice" });
    }, 1000);
  };

  return (
    <div>
      <h2>Counter: {count}</h2>
      <button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
      <button onClick={() => setCount((prev) => prev - 1)}>Decrement</button>
      <button onClick={fetchUser}>Fetch User</button>
      {user ? <p>User ID: {user.id}, Name: {user.name}</p> : <p>No user data.</p>}
    </div>
  );
}

count는 초기값 0에서 number로 추론됩니다. user는 처음에 null이지만 나중에 사용자 객체가 들어오므로 User | null로 적었고, 이렇게 적지 않으면 타입이 null로만 추론되어 setUser에 객체를 넘길 수 없습니다. JSX의 user ? ... : ...에서 참인 쪽은 user가 User로 좁혀지므로 user.id를 바로 읽을 수 있습니다.

setCount((prev) => prev + 1)처럼 함수를 넘기면 React가 가장 최근의 상태를 prev로 넘겨 주므로, 같은 렌더링 안에서 여러 번 갱신해도 값이 빠지지 않습니다. fetchUser는 실제 서버 요청이 아니라 1초 뒤에 사용자를 넣는 흉내이며, 요청의 진행 중이나 실패 상태는 다루지 않습니다. 이런 상태를 하나의 타입으로 묶는 방법은 고급 타입 패턴과 Best Practices에서 다룹니다.

이벤트 핸들러의 타입

JSX의 onChange에 화살표 함수를 바로 쓰면, 이벤트 매개변수의 타입은 그 자리에서 추론됩니다. 핸들러를 따로 함수로 꺼내면 추론할 문맥이 없으므로, React가 제공하는 이벤트 타입을 직접 적습니다.

src/components/InputField.tsx
import { useState } from "react";
import type { ChangeEvent, MouseEventHandler } from "react";

export default function InputField() {
  const [value, setValue] = useState("");

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
  };

  const handleClick: MouseEventHandler<HTMLButtonElement> = () => {
    alert(`Current value: ${value}`);
  };

  return (
    <div>
      <input type="text" value={value} onChange={handleChange} placeholder="Type something..." />
      <button onClick={handleClick}>Show Value</button>
      <p>Input: {value}</p>
    </div>
  );
}

ChangeEvent<HTMLInputElement>의 꺾쇠 안은 이벤트가 일어난 요소의 타입이어서, event.target.value를 string으로 읽을 수 있습니다. 클릭 핸들러는 이벤트 객체를 쓰지 않으므로 매개변수를 두지 않고, 대신 변수에 MouseEventHandler<HTMLButtonElement> 타입을 붙였습니다. 쓰지 않는 event 매개변수를 적어 두면 템플릿의 noUnusedParameters가 진단하기 때문입니다.

입력창에 TS를 입력하면 아래에 Input: TS가 보이고, Show Value를 누르면 Current value: TS라는 알림 창이 뜹니다. 목록에 없는 이벤트에는 모든 이벤트의 기반 타입인 SyntheticEvent를 쓸 수 있으며, 편집기에서 JSX 속성 위에 마우스를 올리면 그 자리에 맞는 핸들러 타입을 볼 수 있습니다.

클래스 컴포넌트

클래스 컴포넌트는 Component<Props, State>를 상속하며, 두 타입 매개변수로 props와 상태의 타입을 정합니다. React는 새 코드에서 클래스 컴포넌트를 권하지 않지만, 오래된 코드에서 자주 만나므로 읽는 방법은 알아 둡니다.

src/components/ClassBasedCounter.tsx
import { Component } from "react";

interface ClassBasedCounterProps {
  title: string;
  initialCount?: number;
}

interface ClassBasedCounterState {
  count: number;
}

export default class ClassBasedCounter extends Component<ClassBasedCounterProps, ClassBasedCounterState> {
  constructor(props: ClassBasedCounterProps) {
    super(props);
    this.state = { count: props.initialCount ?? 0 };
  }

  private increment = (): void => {
    this.setState((prev) => ({ count: prev.count + 1 }));
  };

  private decrement = (): void => {
    this.setState((prev) => ({ count: prev.count - 1 }));
  };

  render() {
    return (
      <div>
        <h2>{this.props.title}</h2>
        <h3>Count: {this.state.count}</h3>
        <button onClick={this.increment}>Increment</button>
        <button onClick={this.decrement}>Decrement</button>
      </div>
    );
  }
}

this.state와 this.setState의 콜백이 받는 prev는 ClassBasedCounterState 타입이어서, 없는 필드를 쓰거나 count에 문자열을 넣으면 진단됩니다. increment를 화살표 함수의 클래스 필드로 만든 것은 onClick에 메서드를 그대로 넘겨도 this가 인스턴스를 가리키게 하기 위해서입니다. private는 타입만 지우면 되는 문법이어서 템플릿의 erasableSyntaxOnly에 걸리지 않습니다.

상태의 처음 값은 생성자에서 한 번만 정합니다. 이미 화면에 있는 컴포넌트에 부모가 다른 initialCount를 넘겨도 생성자가 다시 실행되지 않으므로 count는 바뀌지 않으며, 새 값으로 다시 시작하려면 컴포넌트를 새로 마운트해야 합니다.

컴포넌트 조합하기

앞에서 만든 컴포넌트를 App.tsx에서 조합합니다. 템플릿의 main.tsx는 createRoot(document.getElementById("root")!).render(...)로 이 App을 화면에 붙이며, 여기의 !는 root 요소가 반드시 있다고 컴파일러에게 단언하는 표기입니다.

src/App.tsx
import WelcomeMessage from "./components/WelcomeMessage";
import Panel from "./components/Panel";
import Counter from "./components/Counter";
import InputField from "./components/InputField";
import ClassBasedCounter from "./components/ClassBasedCounter";

export default function App() {
  const handleWelcomeButtonClick = (message: string) => {
    alert(`Button clicked! Message: ${message}`);
  };

  return (
    <div>
      <WelcomeMessage
        name="TypeScript User"
        age={25}
        isVisible={true}
        onButtonClick={handleWelcomeButtonClick}
      />
      <Panel title="Children example">
        <p>This paragraph is passed as children.</p>
      </Panel>
      <Counter />
      <InputField />
      <ClassBasedCounter title="Class Counter Example" initialCount={100} />
    </div>
  );
}

화면의 첫 부분은 Hello, TypeScript User!와 You are 25 years old., Say Hello 버튼입니다. 버튼을 누르면 WelcomeMessage가 Hello, TypeScript User!를 콜백에 넘기고, 부모는 Button clicked! Message: Hello, TypeScript User!라는 알림 창을 띄웁니다. Counter는 처음에 Counter: 0과 No user data.를 보여 주고, Fetch User를 누르고 1초가 지나면 User ID: 1, Name: Alice로 바뀌며, ClassBasedCounter는 100에서 시작합니다.

WelcomeMessage의 부모 Props에서 렌더 결과까지, 클릭에서 메시지 callback까지의 흐름과 상태·이벤트 타입 경계의 확장.

REACT COMPONENT / TYPED BOUNDARY

Props는 입력 계약, callback은 컴포넌트가 알리는 값이다

교재 WelcomeMessage는 부모의 name·isVisible·callback을 받아 화면을 만들고, 클릭하면 메시지 문자열을 부모에 알립니다. State union과 UserId 변환은 이 경계를 확장하는 별도 설계입니다.

Props는 입력 계약, callback은 컴포넌트가 알리는 값이다WelcomeMessage의 부모 Props에서 렌더 결과까지, 클릭에서 메시지 callback까지의 흐름과 상태·이벤트 타입 경계의 확장.부모 Propsname · isVisibleWelcomeMessageProps 계약JSX 또는 null화면 결과버튼 클릭MouseEventhandleClick메시지 생성부모 callback문자열 전달아래 행: 오른쪽 → 왼쪽으로 알림 전달
Props 입력과 클릭 메시지 전달의 실제 경계원 여섯 노드·다섯 간선을 세로로 재배치한다. 부모 Props→WelcomeMessage→JSX 또는 null, 표시된 버튼을 클릭한 경우 MouseEvent→handleClick→부모 callback 문자열 전달이다. null을 반환한 화면에서 버튼을 클릭할 수 있다는 뜻이 아니다. State union/UserId는 원 지원 설명의 별도 확장이다.
부모 Propsname · isVisible
연결 관계부모 Props · name · isVisible → WelcomeMessage · Props 계약
WelcomeMessageProps 계약
연결 관계WelcomeMessage · Props 계약 → JSX 또는 null · 화면 결과
JSX 또는 null화면 결과
연결 관계JSX 또는 null · 화면 결과 → 보이는 버튼 클릭 · MouseEvent
보이는 버튼 클릭MouseEvent
연결 관계보이는 버튼 클릭 · MouseEvent → handleClick · 메시지 생성
handleClick메시지 생성
연결 관계handleClick · 메시지 생성 → 부모 callback · 문자열 전달
부모 callback문자열 전달

INPUT

필수·선택·children을 선언

name·isVisible·onButtonClick은 필수이며 age는 선택입니다. isVisible이 false면 null을 반환하고 true면 JSX를 만듭니다. children을 사용하면 Props에 ReactNode 등을 명시합니다. 설치한 React 타입 정의의 계약을 확인합니다.

LOCAL STATE

렌더링과 State의 관계

본문 Counter는 number와 User | null을 사용합니다. loading·error·ready를 tag가 있는 union으로 묶고 모든 분기를 처리하는 방식은 다음 절로 이어지는 설계 확장입니다.

DOM TO DOMAIN

이벤트 값에 의미를 부여

본문 InputField는 event.target.value 문자열을 지역 상태에 저장합니다. 확장 설계에서는 currentTarget.value를 읽고 도메인 규칙을 검증한 뒤 UserId 등으로 변환해 callback에 전달할 수 있습니다.

RUNTIME

타입 선언과 실제 동작

타입 주석은 외부 데이터를 검증하지 않습니다. 부모 callback 예제는 alert를 실행하며 상태 갱신을 구현하지 않습니다. age && ...는 age가 0일 때 숫자 0을 렌더합니다.

그림의 위·아래 행은 실제 WelcomeMessage 예입니다. 판별 상태와 UserId는 설계 확장이며, DOM 이벤트 객체를 부모에 넘기는 방식도 API 목적에 따라 선택합니다.

그림의 위쪽 흐름은 부모의 props가 WelcomeMessage를 거쳐 JSX나 null이 되는 과정이고, 아래쪽 흐름은 보이는 버튼을 눌렀을 때 메시지 문자열이 부모의 콜백으로 전달되는 과정입니다. 이 예제의 콜백은 알림 창을 띄울 뿐 부모의 상태를 바꾸지는 않습니다. 그림의 보조 설명에 있는 상태 유니온과 UserId 변환은 이 예제를 넓힌 설계로, 이 글의 Counter는 number와 User | null을, InputField는 event.target.value 문자열을 그대로 씁니다.

더 깊이: 컴파일러가 JSX를 검사하는 방법

공식 Handbook의 JSX 안내에 따르면, 컴파일러는 태그 이름의 첫 글자로 두 종류의 요소를 나눕니다. 소문자로 시작하는 div 같은 고유 요소(intrinsic element)는 JSX.IntrinsicElements라는 인터페이스에서 그 이름의 속성을 찾아 속성의 타입을 정하고, 대문자로 시작하는 WelcomeMessage 같은 값 기반 요소(value-based element)는 지금 스코프에 있는 그 이름의 값을 찾습니다. 값 기반 요소는 먼저 함수 컴포넌트로 해석해 보고, 실패하면 클래스 컴포넌트로 해석하며, 둘 다 안 되면 진단합니다. 그래서 직접 만든 컴포넌트의 이름을 소문자로 시작하면 컴파일러는 그것을 고유 요소로 보고 JSX.IntrinsicElements에서 찾게 됩니다.

.tsx 파일에서는 <Foo>bar 꼴의 타입 단언을 쓸 수 없습니다. 이 꺾쇠괄호가 JSX 태그와 구별되지 않기 때문이며, 대신 bar as Foo처럼 as를 씁니다. 또 React 19의 타입은 전역 JSX 네임스페이스를 없애고 React.JSX로 옮겼습니다. React 19 업그레이드 안내는 그 이유로 JSX를 쓰는 여러 UI 라이브러리끼리 전역 타입이 부딪히는 일을 막는 것을 들며, 웹 컴포넌트 같은 사용자 정의 요소를 IntrinsicElements에 더하려면 그 선언을 declare module 안에 넣으라고 안내합니다. 이때 모듈 이름은 jsx 설정에 따라 다르며, 이 글의 템플릿처럼 react-jsx라면 react/jsx-runtime입니다.

정리

  • Vite의 react-ts 템플릿은 jsx, DOM 라이브러리, verbatimModuleSyntax, 미사용 검사 같은 설정을 갖추어 주며, 개발 서버는 타입을 검사하지 않으므로 tsc -b나 편집기로 검사합니다.
  • 함수 컴포넌트의 props 타입은 매개변수의 타입으로 적고, 선택적 props는 물음표로, 부모에게 알리는 값은 콜백의 매개변수 타입으로 나타냅니다.
  • children은 props에 직접 선언하며, @types/react 18부터 React.FC는 children을 몰래 더하지 않습니다.
  • useState는 초기값으로 타입을 추론하므로, null로 시작해 나중에 객체가 들어오는 상태에는 User | null처럼 타입을 직접 적습니다.
  • 따로 꺼낸 이벤트 핸들러에는 ChangeEvent<HTMLInputElement> 같은 React의 이벤트 타입을 적습니다.
  • 클래스 컴포넌트는 Component<Props, State>로 타입을 정하고, 생성자에서 정한 처음 상태는 props가 바뀌어도 다시 정해지지 않습니다.

연습 문제

1. 다른 props로 쓰기

WelcomeMessage에 name="Guest", isVisible={true}와 올바른 콜백을 넘기고 age는 생략했습니다. 화면과 버튼 클릭의 결과는 어떤가요? isVisible을 false로 바꾸면 어떻게 달라지나요?

해설 보기

age가 undefined이므로 나이 문장은 그려지지 않고, Hello, Guest!와 버튼만 보입니다. 버튼을 누르면 콜백이 Hello, Guest!를 받습니다. isVisible이 false이면 컴포넌트가 null을 돌려주어 제목과 버튼이 모두 사라지며, 비활성 버튼이 남는 것이 아니라 누를 버튼 자체가 없어집니다.

2. 0이라는 나이

age={0}을 넘겼을 때, 이 글의 age !== undefined 검사와 {age && <p>...</p>}는 각각 무엇을 그리나요?

해설 보기

age !== undefined는 0에서도 참이므로 You are 0 years old.를 그립니다. age && ...는 0이 falsy여서 &&의 결과가 숫자 0이 되고, React가 그 0을 그대로 화면에 그립니다. 0이 올 수 있는 숫자 props는 && 앞에 두지 말고, 값이 있는지를 직접 검사합니다.

3. 클래스 컴포넌트의 처음 상태

ClassBasedCounter를 initialCount={10}으로 마운트하고 Increment를 두 번, Decrement를 한 번 눌렀습니다. count는 얼마인가요? 그 뒤 부모가 initialCount만 100으로 바꾸면 count는 어떻게 되나요?

해설 보기

10에서 두 번 더하고 한 번 빼므로 count는 11입니다. 이미 마운트된 컴포넌트는 생성자를 다시 실행하지 않으므로, initialCount가 100으로 바뀌어도 count는 11 그대로입니다. 100에서 다시 시작하려면 컴포넌트를 새로 마운트해야 하며, props를 처음 값으로만 쓸지 계속 따라갈지는 타입이 아니라 컴포넌트의 설계로 정합니다.

다음 글 훅과 상태 관리에서는 useEffect, useContext, useRef와 직접 만든 훅에 타입을 붙이고, 상태를 어디에 둘지 정하는 기준을 다룹니다.