Typing Props và Components Trong React TypeScript: Từ Events Đến Polymorphic
Một trong những điểm khó nhất khi mới dùng TypeScript với React là biết cách type props cho đúng. Dùng any thì nhanh nhưng mất hết lợi ích của TypeScript. Type sai thì code chạy được nhưng không an toàn. Type quá chặt thì component khó dùng.
Bài này đi qua các pattern typing props thực tế nhất — từ children, events, đến những pattern nâng cao như polymorphic component với as prop. Sau bài này bạn sẽ biết cách type component đúng chuẩn mà không cần đoán mò.
#1. Children Prop — ReactNode và PropsWithChildren
children là prop đặc biệt nhất trong React. Có hai cách type phổ biến:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { type ReactNode, type PropsWithChildren } from "react";
// Cách 1: Khai báo children trực tiếp
type CardProps = {
children: ReactNode;
title: string;
};
// Cách 2: PropsWithChildren — gọn hơn khi có nhiều props
type CardProps = PropsWithChildren<{
title: string;
className?: string;
}>;
function Card({ children, title, className }: CardProps) {
return (
<div className={className}>
<h2>{title}</h2>
{children}
</div>
);
}ReactNode bao gồm những gì:
1
2
3
4
5
6
7
8
9
10
// ReactNode = string | number | boolean | null | undefined
// | ReactElement | ReactPortal | ReactFragment
<Card title="Hello">
<p>Paragraph</p> {/* ReactElement ✅ */}
<><span>Fragment</span></> {/* ReactFragment ✅ */}
Some text {/* string ✅ */}
{42} {/* number ✅ */}
{null} {/* null ✅ — render nothing */}
</Card>ReactNode là lựa chọn tốt nhất cho children trong hầu hết trường hợp vì nó chấp nhận mọi thứ có thể render được trong React.
#2. Event Typing — ChangeEventHandler và valueAsNumber
Typing event handlers đúng cách giúp tránh lỗi khi truy cập event.target.
1
2
3
4
5
6
7
8
9
import { type ChangeEventHandler } from "react";
// ❌ Typing thủ công — dài dòng
function Input({ onChange }: { onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }) {}
// ✅ ChangeEventHandler — gọn hơn, cùng type
function Input({ onChange }: { onChange: ChangeEventHandler<HTMLInputElement> }) {
return <input onChange={onChange} />;
}Khi nào dùng ChangeEventHandler<T> vs raw signature:
ChangeEventHandler<HTMLInputElement>— dùng khi khai báo prop type hoặc lưu handler vào biến. Ngắn gọn hơn, dễ đọc hơn trong interface/type.(e: React.ChangeEvent<HTMLInputElement>) => void— dùng khi viết inline handler trực tiếp trong JSX, hoặc khi cần destructure event rõ ràng hơn. Hai cách này hoàn toàn tương đương về type — chọn theo ngữ cảnh.
Khi lấy giá trị số từ input, dùng valueAsNumber thay vì convert thủ công:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function NumberInput({ onValueChange }: { onValueChange: (n: number) => void }) {
return (
<input
type="number"
onChange={(e) => {
// ❌ Cách dài
onValueChange(Number(e.target.value));
onValueChange(+e.target.value);
// ✅ Ngắn nhất — có sẵn trên HTMLInputElement
onValueChange(e.target.valueAsNumber);
}}
/>
);
}Callback prop có state updater function:
1
2
3
4
5
6
7
8
9
10
11
12
// Khi prop nhận cả value lẫn updater function (như setState)
type CounterProps = {
onCountChange: (count: number | ((previous: number) => number)) => void;
};
function Counter({ onCountChange }: CounterProps) {
return (
<button onClick={() => onCountChange((prev) => prev + 1)}>
Increment
</button>
);
}#3. ComponentPropsWithoutRef — Extend HTML Elements
Khi tạo custom component bọc HTML element, bạn muốn kế thừa toàn bộ props của element gốc.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { type ComponentPropsWithoutRef } from "react";
// ❌ Tự khai báo từng prop — không bao giờ đủ
type InputProps = {
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
disabled?: boolean;
// ... còn hàng chục props khác của <input>
};
// ✅ Kế thừa tất cả props của <input> + thêm props riêng
type InputProps = ComponentPropsWithoutRef<"input"> & {
label: string;
error?: string;
};
function Input({ label, error, ...inputProps }: InputProps) {
return (
<div>
<label>{label}</label>
<input {...inputProps} />
{error && <span className="error">{error}</span>}
</div>
);
}
// Dùng được tất cả HTML input props
<Input
label="Email"
error="Email không hợp lệ"
type="email"
placeholder="you@example.com"
autoComplete="email"
required
/>Dùng ComponentPropsWithoutRef thay vì ComponentPropsWithRef khi component không cần forward ref — hầu hết component thông thường đều không cần forward ref.
Generic form submit event:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Submit event cho bất kỳ form nào
type FormProps = ComponentPropsWithoutRef<"form"> & {
onValidSubmit: (data: FormData) => void;
};
function Form({ onValidSubmit, ...formProps }: FormProps) {
const handleSubmit: ComponentPropsWithoutRef<"form">["onSubmit"] = (e) => {
e.preventDefault();
onValidSubmit(new FormData(e.currentTarget));
};
return <form {...formProps} onSubmit={handleSubmit} />;
}#
Một pattern cực kỳ hữu ích: component có behavior thay đổi hoàn toàn dựa trên một prop.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type ButtonProps =
| ({ href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>)
| ({ href?: never } & React.ButtonHTMLAttributes<HTMLButtonElement>);
function Button({ children, ...props }: ButtonProps) {
if ("href" in props) {
// TypeScript biết đây là anchor — có target, rel, download...
return <a {...props}>{children}</a>;
}
// TypeScript biết đây là button — có disabled, type, form...
return <button {...props}>{children}</button>;
}
// Dùng như link — tự nhiên có target, rel
<Button href="/home" target="_blank" rel="noopener">
Go home
</Button>
// Dùng như button — tự nhiên có disabled, type
<Button disabled onClick={handleSubmit} type="submit">
Submit
</Button>
// ❌ Không thể trộn — TypeScript báo lỗi ngay
<Button href="/home" disabled>Bad</Button>Giải thích:
href?: neverlà chìa khóa — nó cấm prophreftrong button variant. Khi TypeScript thấyhrefđược truyền vào, nó biết chắc đây là anchor variant (vì button variant không cho phéphref)& React.AnchorHTMLAttributes<HTMLAnchorElement>dùng intersection để gộp toàn bộ anchor props vào một variant — mà không cần khai báo từng prop thủ công'href' in propslà runtime check, nhưng TypeScript dùng nó để thu hẹp type: trong branchtrue, TypeScript biếtpropslà anchor variant; trong branchfalse, là button variant- Người dùng component không cần biết implementation — chỉ cần truyền
hrefhoặc không
#5. Polymorphic Component — as Prop
Polymorphic component render thành bất kỳ HTML element nào mà vẫn giữ đúng props của element đó.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type PolymorphicProps<T extends React.ElementType> = {
as?: T;
children?: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;
function Box<T extends React.ElementType = "div">({
as,
children,
...props
}: PolymorphicProps<T>) {
const Component = as ?? "div"; // Chữ hoa C → JSX hiểu là component
return <Component {...props}>{children}</Component>;
}
// render <div> — có className, onClick, v.v.
<Box className="wrapper">content</Box>
// render <a> — có href, target, rel
<Box as="a" href="/home" target="_blank">link</Box>
// render <li> — có value
<Box as="li" value={1}>item</Box>
// render <button> — có disabled, type
<Box as="button" disabled onClick={handleClick}>click me</Box>
// ❌ TypeScript báo lỗi — li không có prop href
<Box as="li" href="/home">bad</Box>Lợi ích lớn nhất của polymorphic component là semantic HTML đúng chuẩn — bạn render <li>, <h1>, <nav> thực sự thay vì <div> giả mạo, nên accessibility features (screen reader, keyboard navigation) hoạt động tự nhiên mà không cần thêm ARIA.
#Lưu ý về giới hạn
TypeScript chỉ validate props cho những HTML element đã biết ("div", "a", "button"…). Nếu truyền string không phải HTML element hợp lệ, TypeScript sẽ không báo lỗi compile — lỗi chỉ xuất hiện ở runtime.
1
2
// TypeScript không báo lỗi, nhưng browser sẽ không hiểu element này
<Box as="invalid-element">content</Box>Nếu component cần nhận ref, thay ComponentPropsWithoutRef bằng ComponentPropsWithRef và dùng React.forwardRef để truyền ref xuống element bên trong.
1
2
3
4
5
6
7
8
9
// Khi cần forward ref
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, ...props }, ref) => (
<div>
<label>{label}</label>
<input ref={ref} {...props} />
</div>
)
);Typing props đúng cách không chỉ giúp tránh bug mà còn làm API của component tự document — nhìn vào type là biết component nhận gì, trả về gì, và cần truyền những prop nào. Khi bạn type đúng từ đầu, TypeScript sẽ catch lỗi cho bạn tự động mỗi khi refactor.