📚 TypeScript 教程系列

  1. 入门与配置
  2. 基础类型与变量声明
  3. 函数
  4. 流程控制与运算符
  5. 集合类型
  6. 异步编程与错误处理
  7. 接口与类
  8. 泛型与类型组合
  9. 高级类型
  10. 模块、装饰器与工程化
  11. TypeScript 与 Bun 实战
  12. TypeScript 与 React 实战(本文)

前面的章节我们系统掌握了 TypeScript 的类型系统,并在 Bun 服务端场景里落地。本篇把 TypeScript 带入 React 前端工程:从项目初始化、组件与 Props 的类型设计,到 Hooks、Context、事件处理、泛型组件、状态管理库的类型对接,最后落地一个类型完备的可运行示例。目标只有一个——让类型系统在 UI 工程中真正兑现价值:少写文档、少踩 undefined、重构有底气。

为什么在 React 中使用 TypeScript

React 本身是"动态类型友好"的——props 是普通对象,state 可以是任意值。但正因为灵活,组件之间的契约很容易被破坏。在 React 项目里认真对待 TypeScript,核心收益有三点:

  1. 组件契约显式化:Props 的形状、哪些必填哪些可选、回调的参数类型,全部写进类型定义。调用方在 JSX 里写错一个 prop 名立刻红线,而不是等到运行时白屏。
  2. Hooks 类型安全useState 的初值类型决定后续 setter 的参数类型,useReducer 的 action 联合类型让 dispatch 调用点享受穷举校验,useRef 区分"可变 ref"与"DOM ref"。
  3. 重构有底气:改一个 props 字段名或一个 Context 值结构,编译器会列出所有受影响的组件文件,比"全局搜索 + 手动核对"可靠得多。

💡 成本提示:React 19 的类型由 @types/react 提供,Vite 的 react-ts 模板会自动安装,开箱即用。你不需要额外的转译层,tsc --noEmit 配合 IDE 就能拿到完整的类型检查与跳转。

项目初始化

用 Vite 创建项目

1
2
3
4
5
6
# 用 Bun 创建带 TS 模板的 React 项目(等价于 npm create vite)
bun create vite my-app --template react-ts

# 进入项目并安装依赖(Bun 的安装速度远快于 npm)
cd my-app
bun install

💡 用 Bun 跑 Vite:安装完成后用 bun dev 启动开发服务器、bun run build 构建产物。Vite 模板生成的 package.json scripts 在 Bun 下直接可用,无需改动。类型检查用 bunx tsc -b(project references 需构建模式,模板的 build 脚本同样依赖 tsc -b)。

生成的关键文件结构:

1
2
3
4
5
6
7
8
9
my-app/
├── src/
│ ├── App.tsx # 根组件
│ ├── main.tsx # 入口
│ └── vite-env.d.ts # Vite 环境类型声明
├── tsconfig.json # 根配置:用 references 指向 app/node,自身不含编译选项
├── tsconfig.app.json # 应用代码(src)的 TS 配置
├── tsconfig.node.json # 给 vite.config.ts 等用的 TS 配置
└── vite.config.ts # Vite 配置

tsconfig 关键配置

Vite 官方 react-ts 模板用 project references 把配置拆成三份:tsconfig.json 仅用 references 指向 tsconfig.app.jsontsconfig.node.json,自身不含编译选项(files: []);应用代码的配置集中在 tsconfig.app.json,关键几项如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// tsconfig.app.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx", // 使用新版 JSX 转换,无需 import React
"verbatimModuleSyntax": true, // 严格区分 type/value 导入,取代 importsNotUsedAsValues
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, // 推荐开启:arr[i] 类型为 T | undefined
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

tsconfig.node.json 则把 vite.config.ts 等构建期脚本单独编译(include: ["vite.config.ts"]module/target 走 Node 侧设置)。

💡 jsx: "react-jsx":React 17+ 的新版 JSX 转换由编译器自动注入 react/jsx-runtime,组件文件顶部不再需要 import React from 'react'。但使用 React.FCReact.ReactNode 等类型时仍需按需导入类型。

组件与 Props 类型

函数组件的两种写法

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

// ✅ 推荐:直接标注参数类型,返回类型由推断得到
function Button({ label, onClick }: { label: string; onClick: () => void }) {
return <button onClick={onClick}>{label}</button>;
}

// ✅ 也可用 interface 抽取 Props,便于复用与 JSDoc
interface CardProps {
title: string;
children: ReactNode; // children 也是普通 prop,需在 Props 中显式声明
}

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

⚠️ 关于 React.FC:早期教程常用 const Button: React.FC<Props> = (props) => ...。它的好处是显式约束返回值为 ReactNode,但不再隐式添加 children@types/react 18 起已移除该行为,children 需显式声明),且对泛型组件支持不友好。现代 React 项目更推荐直接标注参数类型React.FC 只在需要约束返回值时使用。

可选、默认值与联合类型

1
2
3
4
5
6
7
8
9
10
11
interface AvatarProps {
src: string;
size?: number; // 可选,类型为 number | undefined
shape?: 'circle' | 'square'; // 字面量联合,约束取值范围
alt?: string;
}

function Avatar({ src, size = 48, shape = 'circle', alt = '' }: AvatarProps) {
const style = { width: size, height: size, borderRadius: shape === 'circle' ? '50%' : 0 };
return <img src={src} alt={alt} style={style} />;
}
  • size? 表示可选;解构里给的 = 48运行时默认值,它会让函数体内 size 的类型收窄为 number(不再是 number | undefined)。
  • shape 用字面量联合 'circle' | 'square',调用方传错值(如 'round')会在编译期报错。

事件处理类型

React 的事件类型都来自 React 命名空间,常用的有 React.ChangeEventReact.MouseEventReact.KeyboardEvent 等,泛型参数指向触发事件的元素类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState, type ChangeEvent, type FormEvent } from 'react';

function SearchForm() {
const [keyword, setKeyword] = useState('');

// 泛型参数指明目标元素是 <input>
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value); // e.target.value 是 string
};

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('搜索:', keyword);
};

return (
<form onSubmit={handleSubmit}>
<input value={keyword} onChange={handleChange} placeholder="输入关键词" />
<button type="submit">搜索</button>
</form>
);
}

💡 小技巧:懒得查事件类型时,可以先写 onChange={(e) => ...},把光标悬停在 e 上让 IDE 推断出类型,再决定要不要显式标注。但提取成独立函数时,显式标注事件类型能让调用点更清晰。

children 与 render props

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
import { type ReactNode } from 'react';

// children:最常见的"插槽"
interface LayoutProps {
header?: ReactNode; // 任意可渲染内容:字符串、JSX、数组、null
children: ReactNode;
}

function Layout({ header, children }: LayoutProps) {
return (
<div>
{header && <header>{header}</header>}
<main>{children}</main>
</div>
);
}

// render props:把函数作为 prop,类型签名要写清入参与返回
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
}

function List<T>({ items, renderItem }: ListProps<T>) {
return <ul>{items.map((item, i) => <li key={i}>{renderItem(item, i)}</li>)}</ul>;
}

ReactNodeReactElement 的区别:

类型含义典型场景
ReactElement一个具体的 JSX 元素(有 typeprops需要对返回的元素做克隆/检查
ReactNodeReactElement | string | number | boolean | null | ReactNode[]组件的 children、任意可渲染内容
JSX.Element等价于 ReactElement(全局命名空间;React 19 类型起已弃用,改用 React.JSX.Element老代码常见,新代码用 ReactElement

绝大多数情况用 ReactNode 即可,它最宽松也最符合"能渲染就行"的语义。

Hooks 类型

useState

useState 是泛型函数,初值的类型决定 state 的类型;当初值可能为 undefined 或需要更精确的联合类型时,显式传入泛型参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useState } from 'react';

function Counter() {
// 推断为 number
const [count, setCount] = useState(0);

// 初值为 null,必须显式标注,否则推断成 null
const [user, setUser] = useState<{ name: string; age: number } | null>(null);

// 显式标注联合类型,避免初值收窄成 string
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');

return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{user && <p>{user.name}</p>}
</div>
);
}

⚠️ useState(null) 的坑:不写泛型时类型会被推断为 null,后续 setUser({ name: 'Tom' }) 直接报错。只要初值是 null 或空数组等"占位值",就应显式传入泛型参数。

useReducer:用联合类型约束 Action

useReducer 配合 判别联合(discriminated union) 是 React 里类型安全度最高的状态方案,dispatch 调用点享受穷举校验,switch 分支里能自动收窄。

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
37
38
39
40
41
42
43
44
45
import { useReducer } from 'react';

// 状态
interface State {
count: number;
loading: boolean;
error: string | null;
}

// Action:用 type 字段做判别
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'set'; payload: number }
| { type: 'loading' }
| { type: 'success'; payload: number }
| { type: 'error'; payload: string };

const initialState: State = { count: 0, loading: false, error: null };

function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'decrement':
return { ...state, count: state.count - 1 };
case 'set':
return { ...state, count: action.payload }; // 这里 payload 已收窄为 number
case 'loading':
return { ...state, loading: true, error: null };
case 'success':
return { ...state, loading: false, count: action.payload };
case 'error':
return { ...state, loading: false, error: action.payload };
default:
// 编译期穷举检查:漏掉一个 case 这里会报错
const _exhaustive: never = action;
return _exhaustive;
}
}

function useCounter() {
const [state, dispatch] = useReducer(reducer, initialState);
return { state, dispatch };
}

default 分支里的 const _exhaustive: never = action 是经典的穷举检查技巧:当你将来给 Action 新增一个分支却忘了在 switch 里处理,这一行就会编译报错,把遗漏挡在编译期。

useRef

useRef 有两种用法,对应两种类型语义,初学者常混淆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useRef, useEffect } from 'react';

function Demo() {
// 1. DOM ref:初始值传 null,泛型是元素类型
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // current 是 HTMLInputElement | null
}, []);

// 2. 可变 ref:保存任意可变值,泛型不能含 null 时要给初值
const timerRef = useRef<number | null>(null);
const countRef = useRef(0); // 推断为 number,永不为 null

useEffect(() => {
timerRef.current = window.setInterval(() => {
countRef.current += 1;
}, 1000);
return () => {
if (timerRef.current) window.clearInterval(timerRef.current);
};
}, []);

return <input ref={inputRef} />;
}
用法初值泛型current 类型
DOM refnullHTMLInputElementT | null
可变值 ref默认值或 null值类型(或含 nullT(或 T | null

useMemo/useCallback

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
import { useMemo, useCallback, useState } from 'react';

function PriceList({ items }: { items: { name: string; price: number }[] }) {
const [taxRate, setTaxRate] = useState(0.1);

// useMemo 泛型可省略,由工厂函数返回值推断
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]);

// useCallback 的依赖项数组是只读元组类型,多写/少写会报错
const format = useCallback(
(price: number) => `¥${(price * (1 + taxRate)).toFixed(2)}`,
[taxRate],
);

return (
<ul>
{items.map((i) => (
<li key={i.name}>
{i.name}: {format(i.price)}
</li>
))}
<li>合计:{format(total)}</li>
</ul>
);
}

Context 的类型安全

Context 是 React 里最容易"类型逃逸"的地方——默认值给 null 后,所有消费者都得做非空判断。推荐用 undefined 哨兵 + 自定义 hook 断言的模式,把"忘记包 Provider"的运行时错误转成编译期错误。

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
import { createContext, useContext, useState, type ReactNode } from 'react';

interface ThemeContextValue {
theme: 'light' | 'dark';
toggle: () => void;
}

// 默认值用 undefined,类型显式标注可选
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const value: ThemeContextValue = {
theme,
toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

// 自定义 hook:没有 Provider 时直接抛错,且返回非空类型
function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme 必须在 <ThemeProvider> 内部使用');
}
return ctx;
}

// 消费者:直接解构,无需做 null 判断
function ThemeButton() {
const { theme, toggle } = useTheme();
return <button onClick={toggle}>当前主题:{theme}</button>;
}

这种模式的三个关键点:

  1. createContext 的泛型写 T | undefined,默认值给 undefined,逼迫消费者走自定义 hook。
  2. 自定义 hook 内做非空断言并抛错,返回值类型是 T(非空)。
  3. 调用方解构出来的字段类型准确,IDE 自动补全与重构跳转都正常工作。

💡 React 19 的 use:除了 useContext,React 19 还提供 use(Context),可以在条件分支与循环中读取 Context(useContext 必须在顶层调用)。类型行为与 useContext 一致,上面的“undefined 哨兵 + hook 断言”模式同样适用。

泛型组件

当组件需要"保持输入与输出类型一致"时(如 TableSelectList),就需要泛型组件。函数声明形式天然支持泛型,箭头函数需要显式写泛型参数。

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
37
38
39
40
41
42
43
44
45
46
47
import { useState, type ReactNode } from 'react';

interface SelectProps<T> {
options: T[];
value: T | undefined;
onChange: (value: T) => void;
// 用 keyof T 把"取值字段"约束成对象的真实键
getKey: (item: T) => string | number;
getLabel: (item: T) => ReactNode;
}

// ✅ 函数声明:泛型写在函数名后
function Select<T>({ options, value, onChange, getKey, getLabel }: SelectProps<T>) {
return (
<select
value={value === undefined ? '' : String(getKey(value))}
onChange={(e) => {
const matched = options.find((o) => String(getKey(o)) === e.target.value);
if (matched) onChange(matched); // 类型为 T,与 options 一致
}}
>
{options.map((o) => (
<option key={getKey(o)} value={String(getKey(o))}>
{getLabel(o)}
</option>
))}
</select>
);
}

// 使用:T 被推断为 User
interface User {
id: number;
name: string;
}
function UserPicker({ users }: { users: User[] }) {
const [selected, setSelected] = useState<User | undefined>(undefined);
return (
<Select
options={users}
value={selected}
onChange={setSelected}
getKey={(u) => u.id}
getLabel={(u) => u.name}
/>
);
}

⚠️ 箭头函数泛型组件const Select = <T,>(props: SelectProps<T>) => ... 里的 <T,> 必须加逗号,否则 .tsx 里会被解析成 JSX 标签。这是 React + TS 的经典坑。

ref 与命令式句柄

React 19 起,ref 可以作为普通 prop 直接传给函数组件,forwardRef 已不再需要(仍可用,仅用于维护 React 18 及更早的遗留代码)。子组件在 props 中声明 ref,配合 useImperativeHandle 暴露命令式方法即可。

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
37
import { useRef, useImperativeHandle, type Ref } from 'react';

// 子组件对外暴露的方法句柄
interface InputHandle {
focus: () => void;
clear: () => void;
}

interface FancyInputProps {
defaultValue?: string;
ref?: Ref<InputHandle>; // ref 现在是普通 prop
}

function FancyInput({ defaultValue = '', ref }: FancyInputProps) {
const inputRef = useRef<HTMLInputElement>(null);

useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = '';
},
}));

return <input ref={inputRef} defaultValue={defaultValue} />;
}

// 父组件
function App() {
const handleRef = useRef<InputHandle>(null);
return (
<>
<FancyInput ref={handleRef} />
<button onClick={() => handleRef.current?.focus()}>聚焦</button>
<button onClick={() => handleRef.current?.clear()}>清空</button>
</>
);
}

💡 forwardRef 的去向:React 19 之前,要让函数组件接收 ref 必须用 forwardRef 包裹,并写成 forwardRef<THandle, TProps>((props, ref) => ...)。React 19 把 ref 提升为普通 prop 后,这套写法成为遗留——新代码直接在 props 里声明 ref?: Ref<T>,更直观。若只是把原生 DOM ref 透传给内部元素,连 useImperativeHandle 都不需要,直接把 ref 传下去即可。

与状态管理库对接

Zustand

Zustand 的 store 用 create 定义,类型由你的 state 推断;用 create<T>()(...) 的"双调用"写法能在闭包内拿到正确的 set/get 类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { create } from 'zustand';

interface CounterState {
count: number;
increment: () => void;
reset: () => void;
}

// 注意 create<T>()(...) 的双括号——这是 TS 推断 set/get 的固定写法
const useCounterStore = create<CounterState>()((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));

// 组件里按需订阅,selector 的返回类型自动推断
function Counter() {
const count = useCounterStore((s) => s.count);
const increment = useCounterStore((s) => s.increment);
return <button onClick={increment}>{count}</button>;
}

TanStack Query

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
37
38
39
import { useQuery, useMutation, queryOptions } from '@tanstack/react-query';

interface Todo {
id: number;
title: string;
done: boolean;
}

// queryOptions 把请求与类型打包,跨组件复用时类型自动流转
const todoOptions = queryOptions({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos');
return res.json();
},
});

function TodoList() {
// data 类型为 Todo[] | undefined
const { data, isLoading } = useQuery(todoOptions);

const toggle = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/todos/${id}/toggle`, { method: 'POST' });
return res.json() as Promise<Todo>;
},
});

if (isLoading) return <p>加载中…</p>;
return (
<ul>
{data?.map((t) => (
<li key={t.id} onClick={() => toggle.mutate(t.id)}>
{t.done ? '✅' : '⬜'} {t.title}
</li>
))}
</ul>
);
}

💡 queryFn 显式标注返回类型:让 useQuerydata 类型准确(Todo[] | undefined)。如果不标注,res.json() 会被推断成 any,整个链路的类型安全就破了。

综合实战:可搜索的用户列表

把前面所有要点串起来——Props 类型、事件、useReducer、Context、泛型组件——落地一个类型完备的小功能。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import {
createContext,
useContext,
useReducer,
useState,
type ChangeEvent,
type Dispatch,
type ReactNode,
} from 'react';

// ---------- 数据模型 ----------
interface User {
id: number;
name: string;
role: 'admin' | 'editor' | 'viewer';
}

// ---------- 状态:useReducer + 判别联合 ----------
interface UserState {
users: User[];
selectedId: number | null;
}

type UserAction =
| { type: 'select'; id: number }
| { type: 'clear' }
| { type: 'setUsers'; users: User[] };

const initialUserState: UserState = { users: [], selectedId: null };

function userReducer(state: UserState, action: UserAction): UserState {
switch (action.type) {
case 'select':
return { ...state, selectedId: action.id };
case 'clear':
return { ...state, selectedId: null };
case 'setUsers':
return { ...state, users: action.users };
default: {
const _exhaustive: never = action;
return _exhaustive;
}
}
}

// ---------- Context:undefined 哨兵模式 ----------
interface UserContextValue {
state: UserState;
dispatch: Dispatch<UserAction>;
selectedUser: User | null;
}

const UserContext = createContext<UserContextValue | undefined>(undefined);

function UserProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(userReducer, initialUserState);
const selectedUser = state.users.find((u) => u.id === state.selectedId) ?? null;
return (
<UserContext.Provider value={{ state, dispatch, selectedUser }}>
{children}
</UserContext.Provider>
);
}

function useUsers(): UserContextValue {
const ctx = useContext(UserContext);
if (!ctx) throw new Error('useUsers 必须在 <UserProvider> 内使用');
return ctx;
}

// ---------- 泛型组件:复用的输入框 ----------
interface SearchInputProps<T> {
items: T[];
onFilter: (keyword: string) => void;
placeholder?: string;
}

function SearchInput<T>({ onFilter, placeholder }: SearchInputProps<T>) {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => onFilter(e.target.value);
return <input onChange={handleChange} placeholder={placeholder} />;
}

// ---------- 视图层 ----------
function UserSearchPanel() {
const { state, dispatch, selectedUser } = useUsers();
const [keyword, setKeyword] = useState('');

const filtered = state.users.filter((u) =>
u.name.toLowerCase().includes(keyword.toLowerCase()),
);

return (
<div style={{ display: 'flex', gap: 16 }}>
<div>
<SearchInput<User>
items={state.users}
onFilter={setKeyword}
placeholder="搜索用户名"
/>
<ul>
{filtered.map((u) => (
<li key={u.id} onClick={() => dispatch({ type: 'select', id: u.id })}>
{u.name}({u.role})
</li>
))}
</ul>
</div>
<div>
{selectedUser ? (
<>
<h3>{selectedUser.name}</h3>
<p>角色:{selectedUser.role}</p>
<button onClick={() => dispatch({ type: 'clear' })}>取消选择</button>
</>
) : (
<p>请选择左侧用户</p>
)}
</div>
</div>
);
}

// 应用根部挂 Provider,子树内任意组件都能用 useUsers
function App() {
return (
<UserProvider>
<UserSearchPanel />
</UserProvider>
);
}

⚠️ 上例为了把所有知识点塞进一个文件做了简化。真实项目里建议按职责拆分文件,UserProvider 挂在应用根部(如本例的 App),让 useUsers 在整棵子树可用。

常见坑与最佳实践

场景错误做法正确做法
useState 初值为 nulluseState(null),后续 setter 报错useState<T | null>(null) 显式标注泛型
Context 默认值createContext<T>({} as T) 强转绕过createContext<T | undefined>(undefined) + hook 断言
泛型箭头函数组件const C = <T>(...) => 在 tsx 报错const C = <T,>(...) => 加逗号
事件处理(e: any) => ...(e: ChangeEvent<HTMLInputElement>) => ...
queryFn 返回值不标注,data 推断成 anyqueryFn: async (): Promise<T[]> => ...
useReducer 漏 case只写几个分支,新增 action 静默遗漏default 分支用 never 做穷举检查
ref 作为命令式句柄React 19 仍用 forwardRef 包裹在 props 声明 ref?: Ref<T>,配合 useImperativeHandle

通用原则

  1. Props 用 interface,状态联合用 typeinterface 便于声明合并与扩展,type 更适合联合与映射类型。
  2. 能用推断就别手写useState(0) 已经能推断出 number,再写 useState<number>(0) 是噪声。只有推断不准(null、空数组、联合类型)时才显式标注。
  3. 把运行时错误前置到编译期:Context 的"忘记包 Provider"、reducer 的"漏写 case"、props 的"字段拼错"——这三类用类型都能挡住,是 TS 在 React 里最大的价值。
  4. any 是债:尤其在 queryFnJSON.parse、第三方回调里,一个 any 会让类型链路断裂。用 unknown + 类型守卫或显式标注替代。

小结

本篇把 TypeScript 的类型能力映射到 React 工程的每个角落:组件 Props 与事件、Hooks(含 useReducer 的判别联合与穷举检查)、Context 的 undefined 哨兵模式、泛型组件、ref 作为普通 prop 与命令式句柄,以及与 Zustand / TanStack Query 的对接。核心心法只有一条——让编译器替你守住组件之间的契约,把“运行时白屏”尽可能压到“编辑器红线”。

掌握这些之后,你已经具备在一个真实 React 项目里写出类型完备、可重构、可维护代码的能力。TypeScript 教程系列到此完结,希望你把类型系统当成日常工程的底层基础设施,而不是额外的负担。