React와 TypeScript를 함께 쓰는 건 처음엔 낯설게 느껴질 수 있다. 타입을 일일이 지정해야 하고, JSX에 제네릭 문법이 섞이면 당황스럽기도 하다. 하지만 익숙해지면 컴파일 단계에서 버그를 잡아주기 때문에 훨씬 안정적인 코드를 짤 수 있다. 이 글에서는 React + TypeScript 환경의 기본 개념과 자주 쓰는 패턴을 정리한다.
프로젝트 세팅
Create React App 또는 Vite로 TypeScript 템플릿을 쓸 수 있다.
# Vite 기준
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
Bash
복사
이렇게 하면 .tsx 확장자를 쓰는 TypeScript 기반 React 프로젝트가 만들어진다.
컴포넌트에 타입 지정하기
React 컴포넌트에서 가장 먼저 부딪히는 건 props 타입 지정이다. interface 또는 type으로 props 형태를 정의하고 컴포넌트에 넘긴다.
interface GreetingProps {
name: string;
age?: number; // 선택적 props는 ? 붙이기
}
const Greeting = ({ name, age }: GreetingProps) => {
return (
<div>
<p>안녕, {name}!</p>
{age && <p>나이: {age}살</p>}
</div>
);
};
export default Greeting;
TypeScript
복사
age?처럼 물음표를 붙이면 optional props가 된다. 넘기지 않아도 에러가 나지 않는다.
useState에 타입 지정하기
useState는 초기값으로 타입을 추론하지만, 명시적으로 제네릭을 쓰는 게 더 안전하다.
import { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState<number>(0);
const [name, setName] = useState<string>('');
const [isVisible, setIsVisible] = useState<boolean>(false);
return (
<div>
<p>카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setIsVisible(!isVisible)}>토글</button>
{isVisible && <p>보임!</p>}
</div>
);
};
export default Counter;
TypeScript
복사
초기값이 null이 될 수 있는 경우엔 유니온 타입을 쓴다.
const [user, setUser] = useState<User | null>(null);
TypeScript
복사
이벤트 핸들러 타입
HTML 이벤트를 다룰 때 타입을 명시하지 않으면 TypeScript가 에러를 낸다. React는 자체적인 이벤트 타입을 제공한다.
import { ChangeEvent, FormEvent } from 'react';
const Form = () => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('제출됨');
};
return (
<form onSubmit={handleSubmit}>
<input type="text" onChange={handleChange} />
<button type="submit">제출</button>
</form>
);
};
export default Form;
TypeScript
복사
ChangeEvent<HTMLInputElement>, FormEvent<HTMLFormElement> 이런 식으로 제네릭에 HTML 엘리먼트 타입을 넣는다.
useRef 타입 지정
useRef는 DOM 요소를 참조하거나 값을 저장하는 데 쓴다. DOM 참조를 할 때는 초기값으로 null을 넣는다.
import { useRef } from 'react';
const InputFocus = () => {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} type="text" placeholder="클릭하면 포커스됨" />
<button onClick={focusInput}>포커스</button>
</div>
);
};
export default InputFocus;
TypeScript
복사
inputRef.current?.focus()처럼 옵셔널 체이닝을 쓰는 게 안전하다. null일 수도 있기 때문이다.
커스텀 훅에 타입 적용하기
커스텀 훅도 반환 타입을 명시할 수 있다.
import { useState } from 'react';
interface UseCounterReturn {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const useCounter = (initialValue: number = 0): UseCounterReturn => {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((prev) => prev + 1);
const decrement = () => setCount((prev) => prev - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
};
export default useCounter;
TypeScript
복사
반환 타입을 인터페이스로 따로 빼두면 나중에 훅이 어떤 값을 돌려주는지 한눈에 파악할 수 있다.
전체 예제 코드
아래는 지금까지 다룬 내용을 하나의 파일로 합친 실습용 코드다. Live Server나 Vite 개발 서버에서 바로 실행할 수 있다.
// App.tsx
import { useState, useRef, ChangeEvent } from 'react';
// --- 타입 정의 ---
interface User {
id: number;
name: string;
age: number;
}
interface CardProps {
user: User;
onDelete: (id: number) => void;
}
// --- 유저 카드 컴포넌트 ---
const UserCard = ({ user, onDelete }: CardProps) => {
return (
<div style={{ border: '1px solid #ccc', padding: '12px', borderRadius: '8px', marginBottom: '8px' }}>
<p><strong>{user.name}</strong> ({user.age}살)</p>
<button onClick={() => onDelete(user.id)} style={{ color: 'red' }}>삭제</button>
</div>
);
};
// --- 메인 앱 ---
const App = () => {
const [users, setUsers] = useState<User[]>([
{ id: 1, name: '김철수', age: 25 },
{ id: 2, name: '이영희', age: 30 },
]);
const [inputName, setInputName] = useState<string>('');
const [inputAge, setInputAge] = useState<string>('');
const nameRef = useRef<HTMLInputElement>(null);
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputName(e.target.value);
};
const handleAgeChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputAge(e.target.value);
};
const addUser = () => {
if (!inputName || !inputAge) return;
const newUser: User = {
id: Date.now(),
name: inputName,
age: Number(inputAge),
};
setUsers((prev) => [...prev, newUser]);
setInputName('');
setInputAge('');
nameRef.current?.focus();
};
const deleteUser = (id: number) => {
setUsers((prev) => prev.filter((u) => u.id !== id));
};
return (
<div style={{ maxWidth: '480px', margin: '40px auto', fontFamily: 'sans-serif' }}>
<h1>유저 목록</h1>
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<input
ref={nameRef}
type="text"
placeholder="이름"
value={inputName}
onChange={handleNameChange}
style={{ flex: 1, padding: '8px' }}
/>
<input
type="number"
placeholder="나이"
value={inputAge}
onChange={handleAgeChange}
style={{ width: '80px', padding: '8px' }}
/>
<button onClick={addUser} style={{ padding: '8px 16px' }}>추가</button>
</div>
{users.map((user) => (
<UserCard key={user.id} user={user} onDelete={deleteUser} />
))}
</div>
);
};
export default App;
TypeScript
복사
마치며
React와 TypeScript를 함께 쓰면 처음엔 타입 지정이 번거롭게 느껴진다. 하지만 props 실수, 잘못된 이벤트 처리, null 접근 오류 같은 흔한 버그들을 컴파일 단계에서 바로 잡아준다. 익숙해질수록 오히려 코드 작성이 빨라지고, 팀 작업에서도 훨씬 명확한 소통이 가능해진다.

