React với TypeScript nghĩa là viết component trong tệp .tsx, nơi props, state, ref và trình xử lý sự kiện đều có kiểu, để trình biên dịch phát hiện một prop bị thiếu hay một trình xử lý sự kiện sai trước khi code chạy. Kiểu biến mất lúc build: thứ chạy trong trình duyệt vẫn là đoạn JavaScript bạn sẽ viết nếu không có chúng. Các trình soạn thảo chạy trực tiếp trên trang này chạy đoạn JavaScript đó, còn phiên bản có kiểu nằm cạnh mỗi ví dụ trong một khối tĩnh.
Đây là cùng component Badge có kiểu. count là tùy chọn (?), nên badge thứ hai dùng giá trị mặc định 0, còn children nhận cả một phần tử lẫn một chuỗi thông thường.
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; }'
Tạo dự án TypeScript
Vite có sẵn một template React kèm TypeScript:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
Bạn sẽ có các tệp .tsx, một tsconfig.json đã bật strict, và các định nghĩa kiểu của React (@types/react, @types/react-dom) đã được cài sẵn. Vite loại bỏ kiểu khi chạy dev server và khi build nhưng không kiểm tra chúng; script build của template chạy tsc trước, còn trình soạn thảo của bạn kiểm tra ngay khi bạn gõ. Các framework như Next.js cũng thiết lập TypeScript theo cách tương tự khi bạn tạo dự án bằng CLI của chúng.
Khai báo kiểu cho props
Mô tả props bằng một kiểu object và chú thích kiểu cho tham số đã destructure. Vài mẫu sau áp dụng được cho phần lớn component:
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 hay interface. Cả hai đều dùng được cho props. interface có thể được mở rộng bằng extends và được gộp khi khai báo hai lần; type còn biểu diễn được union, intersection và mapped type. Phần lớn các đội chọn một cái và dùng ở mọi nơi.
children. Dùng React.ReactNode cho mọi thứ nằm giữa hai thẻ: phần tử, chuỗi, số, mảng, null. Trang props giải thích children được truyền vào từ đâu.
Vì sao React.FC không bắt buộc. const Badge: React.FC<BadgeProps> = (...) => ... khai báo kiểu cho cả hàm thay vì cho tham số. Từ bộ kiểu của React 18, nó không còn tự thêm children cho bạn, không nhận được tham số kiểu cho component generic, và không đem lại gì mà một tham số có kiểu không làm được. Code cũ dùng nó rất nhiều; với code mới, hàm thông thường với props có kiểu là lựa chọn phổ biến.
Khai báo kiểu cho useState
Với giá trị khởi tạo đơn giản, TypeScript tự suy ra là đủ: useState(0) là number, useState('') là string. Hãy truyền đối số kiểu khi state có thể chứa nhiều hơn những gì giá trị khởi tạo gợi ý: một giá trị bắt đầu là null, hoặc một trong vài chuỗi cố định.
Nhấn "Load user" và trạng thái chuyển từ idle sang loading rồi done. State có kiểu:
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>'
Nếu không có <User | null>, useState(null) suy ra kiểu null và setUser(data) sẽ không biên dịch được. Union buộc bạn xử lý trường hợp rỗng, chính là phép kiểm tra user ? ... : ... trong ví dụ đang chạy.
useRef cho phần tử DOM
Một ref trỏ tới phần tử DOM nhận kiểu của phần tử và bắt đầu bằng null, vì phần tử chưa tồn tại cho đến khi React gắn nó vào sau lần render đầu tiên.
Gõ gì đó, rồi nhấn "Log the value": console in ra những gì bạn đã gõ, đọc trực tiếp từ phần tử DOM. Phiên bản có kiểu:
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
Dùng interface phần tử khớp với thẻ: HTMLInputElement, HTMLDivElement, HTMLCanvasElement, HTMLButtonElement. Trong bộ kiểu của React 19, useRef luôn nhận một đối số và trả về một RefObject mà bạn có thể gán current, nên cùng một cách viết dùng được cho cả ref DOM lẫn giá trị như id của timer. Xem useRef để biết phần hoạt động lúc chạy.
Kiểu của trình xử lý sự kiện
Trình xử lý viết inline không cần chú thích kiểu: trong onChange={(e) => setName(e.target.value)}, TypeScript biết kiểu của e từ prop mà nó được truyền vào. Hãy chú thích kiểu khi bạn tách trình xử lý ra thành hàm riêng.
Nhấn gửi và console ghi ra địa chỉ email; preventDefault ngăn trang tải lại. Với kiểu:
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') { /* ... */ }
}
Từ @types/react 19.2.10, onSubmit có kiểu React.SubmitEvent và React.FormEvent cũ bị đánh dấu deprecated; với phiên bản bộ kiểu cũ hơn, hãy viết React.FormEvent<HTMLFormElement>. Tham số kiểu là phần tử mà trình xử lý được gắn vào. Nếu không chắc về kiểu, hãy rê chuột lên prop onChange trong trình soạn thảo, hoặc khai báo kiểu cho cả trình xử lý một lần: const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ... }.
Khai báo kiểu cho context
Truyền kiểu giá trị cho createContext. Nếu có một giá trị mặc định hợp lý, hãy truyền nó, và mọi nơi dùng context sẽ nhận được giá trị không phải null:
type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');
const theme = useContext(ThemeContext); // Theme
Khi không có giá trị mặc định phù hợp (người dùng đã đăng nhập, một store), hãy bắt đầu bằng null và bọc useContext trong một hook ném lỗi nếu component được dùng bên ngoài provider. Khi đó mọi nơi gọi đều nhận được kiểu không null và một thông báo lỗi rõ ràng thay vì một lỗi crash sâu bên trong.
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={...}> là cú pháp provider của React 19; <AuthContext.Provider value={...}> cũng có kiểu giống vậy.
Component generic
Một danh sách, bảng hay ô chọn hoạt động với mọi kiểu phần tử là một component generic: một tham số kiểu nối prop items với callback renderItem.
List có kiểu:
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 suy ra T từ items, nên nơi gọi không bao giờ phải viết tham số kiểu. Trong tệp .tsx, arrow function cần <T,> thay vì <T>, vì riêng <T> sẽ bị đọc thành một thẻ JSX.
Mở rộng props của phần tử gốc
Một component bọc quanh button hoặc input nên nhận mọi thuộc tính mà phần tử gốc nhận. React.ComponentProps<'button'> là toàn bộ tập thuộc tính đó; thêm props riêng của bạn lên trên và spread phần còn lại.
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 và aria-* đều có kiểu mà không cần liệt kê. Trong React 19, ref là một prop thông thường và ComponentProps<'button'> có chứa nó, nên <Button ref={buttonRef}> tới được node DOM qua phép spread mà không cần forwardRef. Dùng ComponentPropsWithoutRef<'button'> khi bạn muốn loại ref ra, và ComponentProps<typeof UserCard> để đọc props của một component do chính bạn viết.
Các lỗi kiểu thường gặp
"'ref.current' is possibly 'null'". Phần tử chưa tồn tại trong lần render đầu tiên. Dùng ref.current?.focus(), hoặc kiểm tra if (ref.current) trước khi dùng.
"Type 'string' is not assignable" với state kiểu union. const [status, setStatus] = useState('idle') suy ra kiểu string, rộng hơn bạn muốn, và truyền nó vào một prop có kiểu Status sẽ lỗi. Hãy viết useState<Status>('idle').
Sự kiện khai báo sai kiểu phần tử. React.ChangeEvent<HTMLInputElement> trên một select khiến e.target có kiểu sai. Tham số kiểu phải khớp với phần tử mà trình xử lý được gắn vào.
Dùng any để tắt lỗi. Nó tắt việc kiểm tra cho mọi thứ mà giá trị đó chạm tới. Hãy ưu tiên unknown rồi thu hẹp kiểu, hoặc sửa kiểu ngay từ gốc.
Câu hỏi thường gặp
Làm sao tạo ứng dụng React với TypeScript?
Chạy npm create vite@latest my-app -- --template react-ts, sau đó npm install và npm run dev. Component nằm trong tệp .tsx và Vite loại bỏ kiểu khi build; chạy tsc (script build của template đã làm việc này) để kiểm tra kiểu.
Kiểu của children trong React là gì?
React.ReactNode. Kiểu này nhận mọi thứ React render được: phần tử, chuỗi, số, mảng, null và undefined. Chỉ dùng React.ReactElement khi bạn cần đúng một phần tử.
Khai báo kiểu cho useState với null thế nào?
Truyền kiểu một cách tường minh dưới dạng union: useState<User | null>(null). Nếu không có đối số kiểu, TypeScript suy ra null là giá trị duy nhất được phép.
Kiểu của sự kiện onChange trong React là gì?
React.ChangeEvent<HTMLInputElement> cho một ô nhập (HTMLTextAreaElement hoặc HTMLSelectElement cho các phần tử tương ứng). Khi đó e.target.value có kiểu string.
Có nên dùng React.FC không?
Không bắt buộc. Khai báo kiểu trực tiếp cho tham số props (function Card({ title }: CardProps)) làm được cùng việc, dễ đọc hơn và dùng được với generic. React.FC không còn tự thêm children, nên nó gần như không đem lại gì thêm.
Nên dùng type hay interface cho props?
Cái nào cũng được. interface có thể được mở rộng và gộp; type có thể biểu diễn union và mapped type. Hãy chọn một quy ước cho cả codebase và giữ nguyên.