React에서 TypeScript를 쓴다는 것은 props, state, ref, 이벤트 핸들러에 타입이 있는 .tsx 파일로 컴포넌트를 작성한다는 뜻이며, 그러면 코드가 실행되기 전에 컴파일러가 빠진 prop이나 잘못된 이벤트 핸들러를 잡아 줍니다. 타입은 빌드 시점에 사라집니다. 브라우저에서 실행되는 것은 타입 없이 작성했을 때와 같은 자바스크립트입니다. 이 페이지의 실행 가능한 에디터는 그 자바스크립트를 실행하고, 타입을 지정한 버전은 각각 옆에 정적 블록으로 있습니다.
다음은 타입을 지정한 같은 Badge입니다. count는 선택적이므로(?) 두 번째 배지는 기본값 0을 쓰고, children은 요소와 일반 문자열을 모두 받습니다.
type BadgeProps = {
label: string;
count?: number;
children: React.ReactNode;
};
function Badge({ label, count = 0, children }: BadgeProps) {
return (
<div>
<strong>{label}</strong> ({count})
<div>{children}</div>
</div>
);
}
<Badge count={3}>Hi</Badge>;
// Error: Property 'label' is missing in type '{ children: string; count: number; }'
TypeScript 프로젝트 만들기
Vite에는 React와 TypeScript 템플릿이 있습니다.
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
.tsx 파일, strict가 켜진 tsconfig.json, 그리고 이미 설치된 React 타입 정의(@types/react, @types/react-dom)를 받습니다. Vite는 서빙하고 빌드하면서 타입을 제거하지만 검사하지는 않습니다. 템플릿의 build 스크립트가 먼저 tsc를 실행하고, 에디터가 입력하는 동안 검사합니다. Next.js 같은 프레임워크도 CLI로 프로젝트를 만들면 같은 방식으로 TypeScript를 설정합니다.
props 타입 지정하기
props를 객체 타입으로 설명하고 구조 분해한 매개변수에 타입을 붙이세요. 몇 가지 패턴으로 대부분의 컴포넌트를 다룰 수 있습니다.
interface UserCardProps {
name: string; // required
age?: number; // optional, may be undefined
role: 'admin' | 'member'; // a union of allowed strings
tags: string[];
onSelect: (id: string) => void; // a callback prop
children?: React.ReactNode; // anything React can render
}
function UserCard({ name, age, role, tags, onSelect, children }: UserCardProps) {
// ...
}
type과 interface. 둘 다 props에 쓸 수 있습니다. interface는 extends로 확장하고 두 번 선언해서 병합할 수 있고, type은 유니온, 교차 타입, 매핑된 타입도 표현할 수 있습니다. 대부분의 팀은 하나를 골라 모든 곳에서 씁니다.
children. 태그 사이에 들어가는 모든 것에는 React.ReactNode를 쓰세요. 요소, 문자열, 숫자, 배열, null입니다. children이 애초에 어떻게 전달되는지는 props 페이지에서 설명합니다.
React.FC가 선택 사항인 이유. const Badge: React.FC<BadgeProps> = (...) => ...는 매개변수 대신 함수 전체에 타입을 지정합니다. React 18 타입부터는 더 이상 children을 대신 추가하지 않고, 제네릭 컴포넌트를 위한 타입 매개변수를 받을 수 없으며, 타입을 지정한 매개변수가 하지 않는 일을 더해 주지도 않습니다. 오래된 코드에서는 널리 쓰이지만, 새 코드에서는 타입을 지정한 props를 가진 일반 함수가 흔한 선택입니다.
useState 타입 지정하기
간단한 초기값이라면 추론으로 충분합니다. useState(0)은 number, useState('')는 string입니다. state가 초기값이 암시하는 것보다 많은 것을 담을 수 있을 때 타입 인자를 넘기세요. null로 시작하는 값이나, 정해진 여러 문자열 중 하나인 값이 그렇습니다.
"Load user"를 클릭하면 상태가 idle에서 loading, done으로 바뀝니다. 타입을 지정한 state입니다.
type User = { id: number; name: string };
type Status = 'idle' | 'loading' | 'done';
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<Status>('idle');
user.name; // Error: 'user' is possibly 'null'
user?.name; // OK
setStatus('ready'); // Error: Argument of type '"ready"' is not assignable to parameter of type 'SetStateAction<Status>'
<User | null>이 없으면 useState(null)은 타입을 null로 추론하고 setUser(data)가 컴파일되지 않습니다. 유니온은 빈 경우를 처리하도록 강제하며, 그것이 바로 실행 예제의 user ? ... : ... 검사입니다.
DOM 요소를 위한 useRef
DOM 요소를 가리키는 ref는 요소 타입을 받고 null로 시작합니다. React가 첫 렌더링 후에 연결하기 전까지 요소가 존재하지 않기 때문입니다.
무언가를 입력한 다음 "Log the value"를 클릭해 보세요. 콘솔이 DOM 요소에서 바로 읽은, 입력한 내용을 출력합니다. 타입을 지정한 버전입니다.
const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current is HTMLInputElement | null
inputRef.current?.focus(); // OK
inputRef.current.focus(); // Error: 'inputRef.current' is possibly 'null'
const timerId = useRef<number | null>(null); // a mutable value, not a DOM node
태그에 맞는 요소 인터페이스를 쓰세요: HTMLInputElement, HTMLDivElement, HTMLCanvasElement, HTMLButtonElement. React 19 타입에서 useRef는 항상 인자를 받고 current에 할당할 수 있는 RefObject를 반환하므로, DOM ref와 타이머 id 같은 값에 같은 형태를 씁니다. 런타임 쪽은 useRef를 참고하세요.
이벤트 핸들러 타입
인라인 핸들러에는 타입을 붙일 필요가 없습니다. onChange={(e) => setName(e.target.value)}에서 TypeScript는 넘겨지는 prop으로부터 e를 압니다. 핸들러를 별도 함수로 옮길 때 타입을 붙이세요.
제출하면 콘솔에 주소가 기록되고, preventDefault가 페이지 새로고침을 막습니다. 타입을 지정하면 다음과 같습니다.
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setEmail(e.target.value); // e.target is HTMLInputElement, value is string
}
function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
}
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {}
function handleKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') { /* ... */ }
}
@types/react 19.2.10부터 onSubmit은 React.SubmitEvent로 타입이 지정되고 이전의 React.FormEvent는 지원 중단으로 표시됩니다. 더 오래된 버전의 타입이라면 대신 React.FormEvent<HTMLFormElement>라고 쓰세요. 타입 매개변수는 핸들러가 붙은 요소입니다. 타입을 모르겠다면 에디터에서 onChange prop에 마우스를 올리거나, 핸들러 전체에 한 번에 타입을 지정하세요: const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ... }.
컨텍스트 타입 지정하기
createContext에 값 타입을 주세요. 적절한 기본값이 있다면 넘기세요. 그러면 모든 소비자가 null이 아닌 값을 받습니다.
type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');
const theme = useContext(ThemeContext); // Theme
로그인한 사용자나 스토어처럼 좋은 기본값이 없다면, null로 시작하고 컴포넌트가 Provider 바깥에서 쓰이면 오류를 던지는 훅으로 useContext를 감싸세요. 그러면 모든 호출자가 null이 아닌 타입을 받고, 깊은 곳에서의 충돌 대신 명확한 오류 메시지를 받습니다.
type Auth = { user: string; logout: () => void };
const AuthContext = createContext<Auth | null>(null);
export function useAuth(): Auth {
const value = useContext(AuthContext);
if (!value) throw new Error('useAuth must be used inside <AuthProvider>');
return value;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState('Ada');
return (
<AuthContext value={{ user, logout: () => setUser('') }}>
{children}
</AuthContext>
);
}
<AuthContext value={...}>는 React 19의 Provider 문법이며, <AuthContext.Provider value={...}>도 같은 방식으로 타입이 지정됩니다.
제네릭 컴포넌트
어떤 항목 타입과도 동작하는 목록, 테이블, select는 제네릭 컴포넌트입니다. 타입 매개변수가 items prop과 renderItem 콜백을 연결합니다.
타입을 지정한 List입니다.
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
getKey: (item: T) => string | number;
};
function List<T>({ items, renderItem, getKey }: ListProps<T>) {
return <ul>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>;
}
<List items={books} getKey={(b) => b.isbn} renderItem={(b) => b.title} />;
// b is inferred as the book type, so b.titel would be an error
TypeScript는 items로부터 T를 추론하므로 호출하는 쪽은 타입 매개변수를 쓸 필요가 없습니다. .tsx 파일에서 화살표 함수는 <T> 대신 <T,>가 필요합니다. <T>만 쓰면 JSX 태그로 읽히기 때문입니다.
네이티브 요소 props 확장하기
button이나 input을 감싸는 래퍼는 네이티브 요소가 받는 모든 속성을 받아야 합니다. React.ComponentProps<'button'>이 그 전체 집합입니다. 그 위에 직접 만든 props를 더하고 나머지를 전개하세요.
type ButtonProps = React.ComponentProps<'button'> & {
variant?: 'primary' | 'ghost';
};
function Button({ variant = 'primary', style, ...rest }: ButtonProps) {
return (
<button
{...rest}
style={{ fontWeight: variant === 'primary' ? 600 : 400, ...style }}
/>
);
}
<Button type="submit" disabled onClick={(e) => console.log(e.currentTarget)}>
Save
</Button>;
onClick, disabled, type, aria-* 모두 나열하지 않아도 타입이 지정됩니다. React 19에서 ref는 일반 prop이고 ComponentProps<'button'>에 포함되므로, <Button ref={buttonRef}>는 forwardRef 없이 전개를 통해 DOM 노드에 도달합니다. ref를 빼고 싶다면 ComponentPropsWithoutRef<'button'>을, 직접 만든 컴포넌트의 props를 읽으려면 ComponentProps<typeof UserCard>를 쓰세요.
흔한 타입 오류
"'ref.current' is possibly 'null'". 첫 렌더링 중에는 요소가 존재하지 않습니다. ref.current?.focus()를 쓰거나, 쓰기 전에 if (ref.current)로 확인하세요.
유니온 state에서 "Type 'string' is not assignable". const [status, setStatus] = useState('idle')은 원하는 것보다 넓은 string으로 추론되며, Status로 타입이 지정된 prop에 넘기면 실패합니다. useState<Status>('idle')이라고 쓰세요.
엉뚱한 요소로 타입을 지정한 이벤트. select에 React.ChangeEvent<HTMLInputElement>를 쓰면 e.target의 타입이 잘못됩니다. 타입 매개변수는 핸들러가 붙은 요소와 일치해야 합니다.
오류를 숨기려고 any 쓰기. 그 값이 닿는 모든 것에 대해 검사가 꺼집니다. unknown을 쓰고 좁히거나, 원천에서 타입을 고치세요.
자주 묻는 질문
TypeScript로 React 앱을 만들려면 어떻게 하나요?
npm create vite@latest my-app -- --template react-ts를 실행한 다음 npm install과 npm run dev를 실행하세요. 컴포넌트는 .tsx 파일에 두며, Vite는 빌드하면서 타입을 제거합니다. 타입을 검사하려면 tsc를 실행하세요(템플릿의 build 스크립트가 실행합니다).
React에서 children의 타입은 무엇인가요?
React.ReactNode입니다. 요소, 문자열, 숫자, 배열, null, undefined 등 React가 렌더링할 수 있는 모든 것을 받습니다. 정확히 요소 하나가 필요할 때만 React.ReactElement를 쓰세요.
null을 쓰는 useState의 타입은 어떻게 지정하나요?
유니온으로 타입을 명시적으로 넘기세요: useState<User | null>(null). 타입 인자가 없으면 TypeScript는 null만 허용되는 값으로 추론합니다.
React에서 onChange 이벤트의 타입은 무엇인가요?
input에는 React.ChangeEvent<HTMLInputElement>입니다(textarea와 select에는 HTMLTextAreaElement, HTMLSelectElement). 그러면 e.target.value의 타입이 string이 됩니다.
React.FC를 써야 하나요?
선택 사항입니다. props 매개변수에 직접 타입을 지정하는 것(function Card({ title }: CardProps))이 같은 일을 하고, 더 단순하게 읽히며, 제네릭과도 동작합니다. React.FC는 더 이상 children을 암묵적으로 추가하지 않으므로 얻는 것이 거의 없습니다.
props에는 type과 interface 중 무엇을 써야 하나요?
둘 다 됩니다. interface는 확장하고 병합할 수 있고, type은 유니온과 매핑된 타입을 표현할 수 있습니다. 코드베이스에 한 가지 규칙을 정해서 유지하세요.