Menu

React TypeScript 教程:props、state 和事件的类型

如何用 TypeScript 为 React 应用添加类型:props 和 children、带联合类型和 null 的 useState、用于 DOM 元素的 useRef、事件处理函数、context、泛型组件和原生元素的 props。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

React 搭配 TypeScript,就是在 .tsx 文件中编写组件,并给 props、state、ref 和事件处理函数标注类型,这样编译器能在代码运行之前发现缺失的 prop 或写错的事件处理函数。类型在构建时会被去掉:在浏览器中运行的,和你不写类型时写出的 JavaScript 完全一样。本页的在线编辑器运行的就是这种 JavaScript,每个编辑器旁边的静态代码块中都附有带类型的版本。

下面是带类型的同一个 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。props 页面讲解了 children 最初是如何传进来的。

为什么 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) => { ... }。

为 context 标注类型

把值的类型传给 createContext。如果有合理的默认值就传进去,这样每个使用者拿到的都是非 null 的值:

type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');

const theme = useContext(ThemeContext); // Theme

如果没有合适的默认值(已登录的用户、store),就从 null 开始,并把 useContext 包在一个 hook 中,当组件在 provider 之外使用时抛出错误。这样每个调用者都能拿到非 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={...}> 的类型标注方式相同。

泛型组件

适用于任意数据项类型的列表、表格或下拉选择框就是泛型组件:一个类型参数把 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}> 会通过展开运算到达 DOM 节点,不需要 forwardRef。想排除 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。它接受 React 能渲染的任何东西:元素、字符串、数字、数组、null 和 undefined。只有在需要恰好一个元素时才用 React.ReactElement。

useState 的值可能为 null 时怎么写类型?

显式传入联合类型: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 可以表达联合类型和映射类型。为代码库选定一种约定并坚持使用即可。

Coddy 编程语言插图

和 Coddy 一起学编程

开始