React 中的 TypeScript 组件与 Hook 类型

解释 React 组件 props、children、事件、状态、Reducer、Context 和自定义 Hook 的 TypeScript 类型设计边界。

#type / concept #status / growing #tech / dev / frontend #resource / typescript #package / react

[!info] related notes

React 中的 TypeScript 组件与 Hook 类型

[!abstract] 学习目标 目标不是给每个局部变量补类型,而是让组件输入、事件、状态转移和 Hook 返回值形成稳定契约,同时尽量保留 React 与 TypeScript 的上下文推断。

从 JSX 调用反推 Props 契约

组件类型首先约束“调用者可以传什么”,而不是描述组件内部的所有实现细节。

interface StatusCardProps {
  title: string;
  status: 'idle' | 'running' | 'completed';
  onDismiss?: () => void;
  children?: React.ReactNode;
}

function StatusCard({ title, status, onDismiss, children }: StatusCardProps) {
  // 返回类型通常可以由 JSX 推断
}
  • 必填 props 表达组件成立所需的不变量。
  • 可选 props 表达组件确实能在缺少该能力时工作。
  • 不要为了统一而强制使用 React.FC;普通函数组件的参数契约更直接。

children 不是固定的“子组件类型”

  • React.ReactNode:可渲染内容的宽类型,包含元素、字符串、数字、空值等。
  • React.ReactElement:已经构造出的 React 元素,范围更窄。
  • PropsWithChildren<P>:在已有 props 上补可选 children

TypeScript 不能可靠约束“只能传某一种 React 组件作为 children”。如果业务必须限制结构,优先设计具名 props 或数据配置,而不是假设 JSX 元素类型能证明组件身份。

事件类型来自 JSX 属性

内联回调通常可以依赖上下文推断:

<input onChange={(event) => setValue(event.currentTarget.value)} />

抽成独立函数后再显式标注:

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  setValue(event.currentTarget.value);
}

currentTarget 是绑定处理器的元素,类型通常最稳定;target 可能是事件冒泡路径中的更深层节点。浏览器原生 MouseEventReact.MouseEvent 也不处于同一抽象层。

state:先推断,初始值不足时再标注

const [enabled, setEnabled] = useState(false);            // boolean
const [selection, setSelection] = useState<string | null>(null);

当一个状态由多个互斥阶段组成时,用对象联合代替多组可能互相矛盾的布尔值:

type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: Result }
  | { status: 'error'; error: Error };

这样 status === 'success' 不只是运行时判断,也会让 TypeScript 知道 data 存在。

Reducer 与 Context 的类型连接

Action 联合
  -> reducer(state, action): State
  -> useReducer 推断 dispatch
  -> Context 暴露 state 或 actions
  -> 自定义 Hook 收敛空值检查

Reducer 的 Action 应使用共同的 type 判别字段,每个分支只携带该动作需要的数据。Context 没有合法默认业务值时,用 T | null 创建并在消费 Hook 中检查 Provider。完整机制见 React useReducerContext 与状态管理边界

包装原生元素和第三方组件

包装组件通常应继承被包装对象已有的 props,而不是手工重写 disabledonClickaria-* 等属性:

  • 原生元素:React.ComponentProps<'button'>InputHTMLAttributes<HTMLInputElement>
  • 第三方组件:React.ComponentProps<typeof ThirdPartyComponent> 或库公开的 Props
  • 需要透传 ref:根据 React 版本与项目约定选择 ref prop 或 forwardRef

组件变体、冲突字段和多态 as 的设计见 React 类型化包装组件与多态组件

自定义 Hook 的公开边界

Hook 内部局部变量继续依赖推断;导出的返回值应让调用者看到稳定能力:

  • 多个具名能力优先返回对象。
  • 固定位置、数量短小且语义明确时才返回元组。
  • 不要为隐藏实现而返回过宽的 Record<string, unknown>
  • Hook 的类型安全不能代替 effect 清理、请求取消和运行时数据校验。

BodySense 实例地图

知识点生产源码阅读重点
ReactNode 与 Provider propsActiveTurnContext.tsxchildren 为什么是可渲染内容
Action 联合ActiveTurnContext.tsxTurnAction 如何限制 dispatch
泛型事件契约packages/contracts/src/stream-events.tsStreamEventBase 如何关联 type 与 payload
第三方 props 与变体components/ui/Button.tsxButtonPrimitive.PropsVariantProps 如何合并
原生 input props 与 refcomponents/ui/Input.tsxInputHTMLAttributesforwardRef 的类型连接
服务端状态 HookuseConsultationSessionQuery.tsconversationIdenabled 与 queryFn 的空值边界

常见误区

  • 把所有 props 都写成可选,把组件不变量推给每个调用者。
  • 给每个 Hook 都显式传泛型,反而掩盖错误或增加重复。
  • as 修复第三方组件 props 冲突,而不是重新检查包装边界。
  • 认为 TypeScript 能验证网络响应或限制 children 的真实组件身份。
  • 把服务端缓存状态、流式临时状态和表单草稿建模成同一种状态。

从阅读到独立实现

  1. 预测Button.tsx 删除 VariantProps<typeof buttonVariants> 后,哪些调用会失去约束?
  2. 模仿:参照 Input.tsx 为一个原生 textarea 包装组件设计 props 与 ref。
  3. 重建:不看源码,从需求实现一个支持原生 button props、isLoading 和两个视觉变体的按钮。
  4. 迁移:为 useConsultationSessionQuery 设计一个不使用非空断言的调用边界,并解释为何 Suspense 版本不能直接照搬 enabled
创建于 2026/7/29 更新于 2026/8/1