Home / TypeScript / Typing Props và Components Trong React TypeScript: Từ Events Đến Polymorphic
TypeScript

Typing Props và Components Trong React TypeScript: Từ Events Đến Polymorphic

Typing props React TypeScript: ReactNode, ChangeEventHandler, ComponentPropsWithRef, discriminated union, as prop

Bạn viết một component Input nhỏ, rồi đến lúc cần truyền placeholder, autoComplete, required… và phải quay lại khai báo thêm từng prop một. Chán, bạn gõ props: any cho xong — và thế là TypeScript im lặng suốt phần còn lại của dự án. Type props sai chỗ nào cũng đau: lỏng quá thì mất an toàn, chặt quá thì chính bạn cũng ngại dùng lại component của mình.

Bài này đi qua các pattern typing props thực tế nhất — children, event handler, ComponentPropsWithoutRef, discriminated union, và polymorphic component với as prop. Xong bài, bạn type component theo chuẩn thay vì đ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, và chúng không tương đương nhau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { type ReactNode, type PropsWithChildren } from "react";

// Cách 1: khai báo trực tiếp — children BẮT BUỘC
type CardProps = {
  title: string;
  className?: string;
  children: ReactNode;
};

// Cách 2: PropsWithChildren — children TỰ ĐỘNG optional
type BoxProps = PropsWithChildren<{
  title: string;
  className?: string;
}>;

function Card({ children, title, className }: CardProps) {
  return (
    <div className={className}>
      <h2>{title}</h2>
      {children}
    </div>
  );
}

Khác biệt nằm ngay trong định nghĩa của PropsWithChildren:

1
2
// @types/react
type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined };

Dấu ? là toàn bộ câu chuyện: <Card title="Hi" /> (không có children) sẽ báo lỗi compile với cách 1, nhưng pass với cách 2. Nên nếu component của bạn vô nghĩa khi thiếu children — như Card, Modal, Layout — hãy khai báo children: ReactNode trực tiếp để TypeScript bắt lỗi giúp. Chỉ dùng PropsWithChildren khi children thật sự là tùy chọn.

ReactNode bao gồm những gì:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// @types/react 19
type ReactNode =
  | ReactElement
  | string
  | number
  | bigint
  | Iterable<ReactNode>
  | ReactPortal
  | boolean
  | null
  | undefined
  | Promise<AwaitedReactNode>; // async Server Component

<Card title="Hello">
  <p>Paragraph</p>            {/* ReactElement ✅ */}
  <><span>Fragment</span></>  {/* ReactElement (Fragment) ✅ */}
  Some text                   {/* string ✅ */}
  {42}                        {/* number ✅ — render ra "42" */}
  {null}                      {/* null ✅ — không render gì */}
</Card>

Để ý: boolean, null, undefined đều hợp lệ về type nhưng React không render chúng ra gì cả. number thì ngược lại — React render số ra màn hình. Đây chính là cái bẫy kinh điển của {items.length && <List />}: khi mảng rỗng, biểu thức trả về 0 và bạn thấy một số 0 lạc lõng trên giao diện thay vì không thấy gì. Dùng items.length > 0 && <List /> để trả về false thay vì 0.

ReactNode vẫn 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ứ React render được. Chỉ khi bạn thật sự cần đúng một React element (để cloneElement chẳng hạn) thì mới siết xuống ReactElement.

2. Event Typing — ChangeEventHandler và valueAsNumber

Type event handler đúng thì event.target mới có đầy đủ property của element. React cho bạn hai cách viết cùng một thứ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { type ChangeEvent, type ChangeEventHandler } from "react";

// Cách 1: viết đầy đủ signature
type SearchProps = {
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
};

// Cách 2: ChangeEventHandler — alias có sẵn, gọn hơn hẳn
type SearchProps2 = {
  onChange: ChangeEventHandler<HTMLInputElement>;
};

function Search({ onChange }: SearchProps2) {
  return <input onChange={onChange} />;
}

Cả hai đều đúng, không cái nào là “sai” — ChangeEventHandler<T> chỉ là alias của signature kia. Thực tế: dùng alias khi khai báo prop type cho gọn, dùng signature đầy đủ khi bạn muốn người đọc thấy ngay type của event.

Lưu ý: target hay currentTarget?

Đây là chỗ dev hay vấp. Trong ChangeEvent<T>, @types/react khai báo target: EventTarget & T — nên e.target.value có type đầy đủ. Nhưng ở các event khác như MouseEvent<T> hay FormEvent<T>, target chỉ là EventTarget trơn (vì click có thể bắn ra từ element con bất kỳ), còn currentTarget mới là T:

1
2
3
4
5
6
7
8
9
10
11
12
function Form({ onData }: { onData: (data: FormData) => void }) {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        // ❌ e.target là EventTarget — không có .elements, không compile
        // ✅ e.currentTarget là HTMLFormElement — chính element gắn handler
        onData(new FormData(e.currentTarget));
      }}
    />
  );
}

Quy tắc dễ nhớ: cần đúng element đang gắn handler thì dùng currentTarget.

Khi lấy giá trị số từ input, valueAsNumber có sẵn trên HTMLInputElement — khỏi convert thủ công:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function NumberInput({ onValueChange }: { onValueChange: (n: number) => void }) {
  return (
    <input
      type="number"
      onChange={(e) => {
        // Number(e.target.value) hoặc +e.target.value cũng chạy, nhưng dài hơn
        const value = e.target.valueAsNumber;

        // Input rỗng → valueAsNumber trả NaN (Number("") trả 0!)
        if (Number.isNaN(value)) return;

        onValueChange(value);
      }}
    />
  );
}

Khác biệt NaN vs 0 này quan trọng hơn vẻ ngoài của nó: với Number(""), xóa trắng ô input sẽ gửi đi số 0 như thể người dùng thật sự gõ 0. valueAsNumber trả NaN để bạn phân biệt được “chưa nhập gì” với “nhập số 0” — chỉ cần nhớ guard bằng Number.isNaN() trước khi dùng.

Còn khi prop nhận cả giá trị mới lẫn updater function (kiểu setState), đừng tự gõ lại signature — React đã có sẵn type cho nó:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { type Dispatch, type SetStateAction } from "react";

// Tự viết tay:
type CounterProps = {
  onCountChange: (count: number | ((previous: number) => number)) => void;
};

// Cùng nghĩa, nhưng dùng type có sẵn của React:
type CounterProps2 = {
  onCountChange: Dispatch<SetStateAction<number>>;
};

function Counter({ onCountChange }: CounterProps2) {
  // Truyền thẳng setCount từ useState vào prop này cũng khớp type
  return (
    <button onClick={() => onCountChange((prev) => prev + 1)}>Increment</button>
  );
}

3. ComponentPropsWithRef và ComponentPropsWithoutRef — Extend HTML Elements

Đây chính là cảnh mở đầu bài viết: bạn bọc một <input> rồi phải khai báo lại từng prop của nó. Cuộc chạy đua này không có đích — hôm nay thiếu autoComplete, mai thiếu maxLength, mốt thiếu pattern.

React có sẵn type để lấy toàn bộ props của một HTML 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
29
import { type ComponentPropsWithRef } from "react";

// ❌ Tự khai báo từng prop — không bao giờ đủ
type ManualInputProps = {
  value?: string;
  onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
  placeholder?: string;
  disabled?: boolean;
  // ... còn hàng chục props nữa của <input>
};

// ✅ Kế thừa mọi props của <input> (kể cả ref), rồi thêm props của riêng bạn
type InputProps = ComponentPropsWithRef<"input"> & {
  label: string;
  error?: string;
};

function Input({ label, error, ...inputProps }: InputProps) {
  return (
    <div>
      <label>
        {label}
        {/* ref nằm trong inputProps, spread xuống là tự động forward */}
        <input {...inputProps} />
      </label>
      {error && <span className="error">{error}</span>}
    </div>
  );
}

Giờ mọi prop hợp lệ của <input> đều dùng được ngay, kể cả ref:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function LoginForm() {
  const emailRef = useRef<HTMLInputElement>(null);

  return (
    <Input
      ref={emailRef} // ✅ React 19: ref là prop bình thường
      label="Email"
      error="Email không hợp lệ"
      type="email"
      placeholder="you@example.com"
      autoComplete="email"
      required
    />
  );
}

Điểm đáng chú ý: bạn không cần forwardRef. Từ React 19, ref là prop bình thường của function component, nên nó nằm sẵn trong ...inputProps và được spread xuống <input> như mọi prop khác. (Nếu dự án còn ở React 18, xem lại phần forwardRef ở mục 5.)

Chọn type nào:

  • ComponentPropsWithRef<"input"> — mặc định cho wrapper component từ React 19: kế thừa cả props lẫn ref.
  • ComponentPropsWithoutRef<"input"> — khi bạn cố ý không cho truyền ref vào component. TypeScript sẽ báo lỗi nếu ai đó thử.
  • ComponentProps<"input"> — thực chất là alias của ComponentPropsWithRef, nên cũng bao gồm ref.

Lưu ý: prop trùng tên tạo ra type không ai dùng được

Đây là cái bẫy lớn nhất của pattern này. Dấu &intersection (giao), không phải “ghi đè”. Nếu prop của bạn trùng tên với một prop HTML nhưng khác type, TypeScript giao hai type đó lại thành never — và tệ hơn, nó rút gọn toàn bộ object props thành never luôn, khiến component hỏng hoàn toàn chứ không chỉ hỏng một prop:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ <button> đã có sẵn type: "button" | "submit" | "reset"
type ButtonProps = ComponentPropsWithRef<"button"> & {
  type: "primary" | "danger"; // giao với type gốc → never
};

function Button({ type, ...buttonProps }: ButtonProps) {
  // ❌ Lỗi ngay tại dòng destructure:
  // "Rest types may only be created from object types"
  return <button className={`btn btn-${type}`} {...buttonProps} />;
}

// ❌ Và tại chỗ gọi: "'Button' cannot be used as a JSX component"
<Button type="primary">Save</Button>;

Sửa bằng cách Omit prop gốc ra trước, rồi định nghĩa lại theo ý bạn:

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
// ✅ Gỡ "type" của <button> ra khỏi props kế thừa
type ButtonProps = Omit<ComponentPropsWithRef<"button">, "type"> & {
  variant: "primary" | "danger";
  type?: "button" | "submit";
};

function Button({
  variant,
  type = "button", // <button> mặc định là "submit" — đặt lại cho an toàn
  className,
  ...buttonProps
}: ButtonProps) {
  return (
    <button
      type={type}
      className={`btn btn-${variant} ${className ?? ""}`}
      {...buttonProps}
    />
  );
}

// variant của bạn và type của HTML sống chung hoà bình
<Button variant="danger" type="submit" disabled>
  Xoá tài khoản
</Button>

Để ý cách className được destructure ra riêng rồi ghép lại thủ công. Nếu bạn để nó trôi vào ...buttonProps, spread sẽ đè mất class btn btn-${variant} của component ngay khi người dùng truyền className vào — một bug âm thầm rất hay gặp ở các wrapper component.

Nhưng trong thực tế, cách an toàn nhất là đừng đặt trùng tên ngay từ đầu — dùng variant thay cho type, dùng scale thay cho size khi bọc <input> (vì <input> đã có size: number). Trùng tên với HTML là tự chuốc rắc rối cho mình.

Cuối cùng, khi muốn mượn props từ component của bạn thay vì từ HTML element, dùng ComponentProps với typeof:

1
2
3
4
5
6
import { type ComponentProps } from "react";

// Lấy đúng props của <Input> ở trên — Input đổi props thì FieldProps tự đổi theo
type FieldProps = ComponentProps<typeof Input> & {
  hint?: string;
};

4. Discriminated Union — Controlled hay Uncontrolled, chọn một

Chắc bạn từng thấy cảnh báo này trong console:

You provided a value prop to a form field without an onChange handler. This will render a read-only field.

Nó xảy ra khi bạn truyền value mà quên onChange — input bị “đóng băng”, gõ không lên chữ. Một <input> chỉ được chọn một trong hai chế độ: controlled (React giữ giá trị qua value + onChange) hoặc uncontrolled (DOM tự giữ, bạn chỉ đưa defaultValue). Trộn hai chế độ là sinh bug.

Vấn đề là type thường của props cho phép trộn thoải mái — value?: stringdefaultValue?: string đứng cạnh nhau, TypeScript chẳng thấy gì sai. Discriminated union biến quy tắc “chọn một” đó thành luật mà compiler ép đượ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
import { type ChangeEventHandler, type ComponentPropsWithRef } from "react";

// Gỡ 3 prop này ra để tự định nghĩa lại (đúng bài học Omit ở mục 3)
type BaseProps = Omit<
  ComponentPropsWithRef<"input">,
  "value" | "defaultValue" | "onChange"
> & { label: string };

type TextFieldProps = BaseProps &
  (
    | {
        // Controlled: value và onChange BUỘC đi cùng nhau, cấm defaultValue
        value: string;
        onChange: ChangeEventHandler<HTMLInputElement>;
        defaultValue?: never;
      }
    | {
        // Uncontrolled: chỉ defaultValue, cấm value
        defaultValue?: string;
        value?: never;
        onChange?: ChangeEventHandler<HTMLInputElement>;
      }
  );

function TextField({ label, ...props }: TextFieldProps) {
  return (
    <label>
      {label}
      <input {...props} />
      {/* Chỉ controlled mới biết giá trị hiện tại → mới đếm được ký tự */}
      {props.value !== undefined && <span>{props.value.length}/100</span>}
    </label>
  );
}

Giờ compiler chặn đúng những tổ hợp sai, ngay tại chỗ gọi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const [name, setName] = useState("");

// ✅ Controlled — value đi kèm onChange
<TextField label="Tên" value={name} onChange={(e) => setName(e.target.value)} />;

// ✅ Uncontrolled — DOM tự giữ giá trị, đọc lại qua FormData hoặc ref
<TextField label="Tên" defaultValue="Paolo" />;

// ❌ Lỗi compile: thiếu property 'onChange'
// Đây chính là cái warning ở đầu mục — nay chết ngay lúc build, không đợi runtime
<TextField label="Tên" value={name} />;

// ❌ Lỗi compile: 'defaultValue' không tồn tại ở variant controlled
<TextField label="Tên" value={name} onChange={handleChange} defaultValue="Paolo" />;

Giải thích:

  • value?: neverdefaultValue?: never là chìa khóa. Chúng cấm prop đó xuất hiện ở variant kia — nên “controlled + defaultValue“ trở thành tổ hợp không tồn tại trong type
  • Đặt valueonChange cùng một variant (và cùng bắt buộc) chính là cách diễn đạt “hai prop này đi liền nhau” bằng type. Không union thì không nói được điều này
  • props.value !== undefined là runtime check bình thường, nhưng TypeScript dùng nó để thu hẹp union: ở variant uncontrolled value chỉ có thể là undefined, nên trong nhánh true nó biết chắc valuestring — vì vậy props.value.length mới compile
  • Đừng thay bằng "value" in props. Vì value?: never khiến value vẫn là key hợp lệ của cả hai variant, check in không thu hẹp được union
  • Phải Omit ba prop gốc của <input> trước khi định nghĩa lại, nếu không intersection sẽ giao chúng thành never — đúng cái bẫy ở mục 3

Đây là điểm đẹp nhất của discriminated union: một quy tắc vốn chỉ nằm trong tài liệu (“đừng trộn controlled với uncontrolled”) nay được compiler canh giúp. Người dùng component không cần đọc doc, chỉ cần gõ sai là đỏ ngay trong editor.

5. Polymorphic Component — as Prop

Trong design system, một Box hay Text phải render ra thẻ HTML khác nhau tùy chỗ dùng: trong danh sách thì là <li>, trong nav thì là <nav>, làm tiêu đề thì là <h1>. Polymorphic component cho phép đổi thẻ qua prop as mà vẫn giữ đúng props của thẻ đó.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { type ComponentPropsWithoutRef, type ElementType } from "react";

type PolymorphicProps<T extends ElementType> = {
  as?: T;
} & ComponentPropsWithoutRef<T>;

function Box<T extends ElementType = "div">({
  as,
  ...props
}: PolymorphicProps<T>) {
  // Cast bắt buộc: TypeScript không verify được spread props generic vào tag generic
  const Component = (as ?? "div") as ElementType;
  return <Component {...props} />;
}

as quyết định luôn bộ props hợp lệ — TypeScript suy ra T từ chính giá trị bạn truyền:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// render <div> — có className, onClick...
<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>;

// ❌ Lỗi compile — <li> làm gì có prop href
<Box as="li" href="/home">
  bad
</Box>;

Lưu ý: ở đây phải dùng ComponentPropsWithoutRef

Mục 3 khuyên mặc định dùng ComponentPropsWithRef, nên phản xạ tự nhiên là viết { as?: T } & ComponentPropsWithRef<T> cho polymorphic component. Đừng. Code vẫn chạy, nhưng TypeScript ngừng kiểm tra props hoàn toàn — không báo lỗi gì, và editor cũng mất luôn autocomplete:

1
2
3
4
5
6
// ❌ Trông hợp lý nhưng làm hỏng type checking
type BrokenProps<T extends ElementType> = { as?: T } & ComponentPropsWithRef<T>;

// Không dòng nào dưới đây báo lỗi cả:
<BrokenBox as="li" href="/home" />;
<BrokenBox as="li" totallyFakeProp={123} />;

Lý do: ComponentPropsWithRef<T> là một conditional type. Khi T còn là generic chưa xác định, TypeScript hoãn việc giải nó, mà đã hoãn thì nó không kiểm tra JSX props được nữa — nên cho qua tất cả. ComponentPropsWithoutRef<T> giải ra được ngay, nên type checking hoạt động bình thường.

Nói ngắn gọn: prop as cần ComponentPropsWithoutRef, còn component thường (mục 3) thì dùng ComponentPropsWithRef.

Lợi ích lớn nhất của polymorphic component là semantic HTML đúng chuẩn: bạn render <li>, <h1>, <nav> thật thay vì <div> giả mạo, nên screen reader và điều hướng bàn phím hoạt động tự nhiên, không phải vá bằng ARIA.

Lưu ý về giới hạn

T extends ElementType nghĩa là TypeScript chỉ chấp nhận HTML element đã biết ("div", "a", "button"…) hoặc một component hợp lệ. Gõ sai tên thẻ là báo lỗi ngay:

1
2
// ❌ Type '"invalid-element"' is not assignable to type 'ElementType'
<Box as="invalid-element">content</Box>;

Giới hạn thật sự nằm ở custom element (web component): tag kiểu "my-widget" không có trong JSX.IntrinsicElements nên cũng bị từ chối. Muốn dùng, bạn phải augment JSX namespace để khai báo nó trước.

Nếu dự án còn ở React 18

Từ React 19 (stable 12/2024), ref là prop bình thường và forwardRef đã deprecated — đó là lý do cả bài này không cần đến nó. Nhưng nếu bạn còn ở React ≤ 18, ref phải đi qua forwardRef, và props lúc này phải dùng ComponentPropsWithoutRef: forwardRef tự thêm ref vào, khai báo lại trong props là thừa và sai type.

1
2
3
4
5
6
7
8
9
10
11
12
13
import { forwardRef, type ComponentPropsWithoutRef } from "react";

// React ≤ 18: props KHÔNG chứa ref, forwardRef tự chèn vào
type LegacyInputProps = ComponentPropsWithoutRef<"input"> & { label: string };

const LegacyInput = forwardRef<HTMLInputElement, LegacyInputProps>(
  ({ label, ...props }, ref) => (
    <label>
      {label}
      <input ref={ref} {...props} />
    </label>
  )
);

Nâng lên React 19 thì xoá được toàn bộ lớp bọc này: props quay về ComponentPropsWithRef<"input">, ref nằm sẵn trong đó, spread xuống là xong — đúng như Input ở mục 3.

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.

← Previous
Zod: Validate Dữ Liệu Type-Safe Trong React
Table of Contents
  1. 1. Children Prop — ReactNode và PropsWithChildren
  2. 2. Event Typing — ChangeEventHandler và valueAsNumber
  3. 3. ComponentPropsWithRef và ComponentPropsWithoutRef — Extend HTML Elements
  4. 4. Discriminated Union — Controlled hay Uncontrolled, chọn một
  5. 5. Polymorphic Component — as Prop