《TypeScript编程实战》11.2 Hooks 类型与自定义 Hook

本节沿着 useState、useReducer、useRef、useEffect 逐个拆解 React Hooks 的类型推导边界:状态为何被 widen 成 string、ref 的三种形态与 React 19 的变化、effect 回调的返回类型限制。后半部分讲自定义 Hook 的返回值形状与泛型 Hook 的运行时校验,读完能写出类型可推导、对调用方友好的 Hook。

本节目标:把组件内部的状态与副作用从「靠约定」变成「靠类型」。你会掌握 useState 的推导边界与字面量 widen 的成因、用判别联合 + useReducer 表达状态机、useRef 三种形态的取舍与 React 19 的变化、useEffect 回调返回类型的限制,以及自定义 Hook 的返回值该怎么设计才既好推导又好扩展。读完本节,你应该能让每一个 Hook 调用点都拿到精确类型,而不是一片 any。

11.2 Hooks 类型与自定义 Hook

props 定义了组件的对外接口,Hooks 定义了组件的内部世界。两者的问题不同:props 的类型是声明式的,写清楚就行;Hooks 的类型是推导式的,你写的初值、回调、依赖数组会被编译器一层层反推,任何一处含糊都会让下游全部退化。所以本节的重点不是「怎么写类型」,而是推导从哪里开始、在哪里断掉。

先看一个每天都在发生的退化:

function useStatus() {
  const [status, setStatus] = useState('idle');
  setStatus('idle2'); // 编译通过,但这是拼写错误
  return [status, setStatus] as const;
}

'idle2' 能通过,是因为 status 被推导成了 string 而不是 'idle'。这是本节要解决的第一类问题。

11.2.1 useState 的推导边界

useState<S>(initial: S | (() => S)) 的推导规则很朴素:从初值推 S,而字面量会被 widen。三处最常见的退化与修复:

// 退化一:字面量 widen 成 string
const [a, setA] = useState('idle');                 // string

// 修复:显式传类型参数,或给初值加 as const
const [b, setB] = useState<'idle' | 'loading' | 'done'>('idle');
const [c, setC] = useState('idle' as const);        // 'idle',但无法再变成 'loading'

// 退化二:null 初值让 T 变成 null
const [user, setUser] = useState(null);             // null
const [user2, setUser2] = useState<User | null>(null); // 正确

// 退化三:对象更新要求全字段
type Form = { name: string; age: number };
const [form, setForm] = useState<Form>({ name: '', age: 0 });
setForm({ name: 'ada' });
// TS2345: Argument of type '{ name: string; }' is not assignable to
//   parameter of type 'SetStateAction<Form>'. Property 'age' is missing.
setForm((f) => ({ ...f, name: 'ada' })); // 正解:函数式合并

useState<'idle' | 'loading' | 'done'>('idle') 是状态机的正确起点:一旦状态超过三个,就应该改用 11.2.2 的判别联合,而不是继续叠加布尔量。isLoading + isError + data 这种组合态有 8 种取值,其中 5 种是非法状态,用联合类型能把它们直接排除掉。

惰性初始化与函数状态的歧义是 useState 最隐蔽的一处:

// 期望:状态是一个函数
const [handler, setHandler] = useState(() => doSomething);
// 实际:React 把 () => doSomething 当成了惰性初始化函数,
//       handler 的类型是 () => void 而不是 () => void 的状态容器

// 正解:显式指定类型参数,让 React 知道返回的就是状态值
const [handler2, setHandler2] = useState<() => void>(() => doSomething);

规则可以一句话记住:只要状态本身是函数,就必须写类型参数,否则 React 会把初值当初始化器调用。

还有一个实践建议:惰性初始化只在初始化开销真的可观时才有意义(解析 JSON、读 localStorage、构造大数组),useState(() => 1 + 1) 只会增加噪音。

11.2.2 用判别联合 + useReducer 表达状态

当状态之间有互斥关系时,判别联合是唯一干净的写法:

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'done'; data: string[] }
  | { status: 'error'; message: string };

type Action =
  | { type: 'fetch' }
  | { type: 'success'; data: string[] }
  | { type: 'error'; message: string }
  | { type: 'reset' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'fetch':
      return { status: 'loading' };
    case 'success':
      return { status: 'done', data: action.data };
    case 'error':
      return { status: 'error', message: action.message };
    case 'reset':
      return { status: 'idle' };
    default: {
      const exhaustive: never = action;
      return exhaustive;
    }
  }
}

function useList() {
  return useReducer(reducer, { status: 'idle' } satisfies State);
}

这里有三个值得单独指出的点。

第一,satisfies State 而不是 as State。 satisfies 会检查初值是否合法,同时保留更精确的类型;as 只做断言,写错字段名也照过。TS 4.9 起 satisfies 已经可以放心使用。

第二,default 分支里的 never 是穷尽性检查。 一旦给 Action 新增成员却忘了在 reducer 里处理,const exhaustive: never = action 会立刻报 TS2322: Type '{ type: "refresh"; }' is not assignable to type 'never'。

第三,action 对象先存变量会 widen。 这是判别联合最常见的翻车点:

dispatch({ type: 'fetch' });          // OK:内联时有上下文类型,字面量不 widen
const act = { type: 'fetch' };
dispatch(act);
// TS2345: Argument of type '{ type: string; }' is not assignable to
//   parameter of type 'Action'.
const act2 = { type: 'fetch' } as const;
dispatch(act2);                        // OK

别用 as Action 兜底——它会绕过判别字段的检查,把编译期错误推迟到运行期。要么内联传入,要么加 as const。

11.2.3 useRef 的三种形态

useRef 的重载决定了返回值的可变性,理解这一点能省下大量 as:

const divRef = useRef<HTMLDivElement>(null);       // 形态一:DOM 引用
const countRef = useRef<number>(0);                // 形态二:可变容器
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 形态三:可空资源
形态调用典型用途current 是否可写
DOM 引用useRef<HTMLDivElement>(null)挂到 ref 属性上React 19 起可写;React 18 为只读
可变容器useRef<number>(0)存计时器 id、上一次的值可写
可空资源useRef<X | null>(null)存可能尚未创建的实例可写

React 19 有两处与 ref 相关的类型变化,升级时要留意。其一,useRef 必须传初值,useRef<HTMLDivElement>() 会报 TS2554: Expected 1 arguments, but got 0.;其二,ref 可以作为普通 prop 传递,forwardRef 不再是必需,上一节 11.1.5 提到的那类断言因此可以删掉。

取值时的空值收窄是第二常见的问题:

const inputRef = useRef<HTMLInputElement>(null);

function focus() {
  inputRef.current.focus();
  // TS18047: 'inputRef.current' is possibly 'null'.
  inputRef.current?.focus();                       // 正解一:可选链
  if (inputRef.current) inputRef.current.focus();   // 正解二:判空
}

注意 ?. 只解决「不存在」,不解决「挂载时序」:在 effect 里读 current 是安全的,在事件处理器里读也是安全的,但在渲染期读永远拿不到(首帧 DOM 还没生成)。

不要用 useRef 代替 useState 来存会影响渲染的值。 修改 ref.current 不触发重渲染,编译器也不会阻止你这么做,于是表现为「状态改了但界面没变」。判断标准很直接:这个值参与渲染输出吗?参与就用 useState,不参与(计时器 id、上一次的 props、订阅句柄)才用 useRef。

11.2.4 useEffect 与副作用回调的类型

useEffect 的参数类型是 EffectCallback = () => void | Destructor。注意是 void | Destructor,不是 any,这带来两个必须记住的报错:

// 错误一:effect 回调隐式返回值
useEffect(() => setTimeout(tick, 1000), []);
// TS2322: Type '() => number' is not assignable to type 'EffectCallback'.
//   Type 'number' is not assignable to type 'void | Destructor'.
useEffect(() => {
  const id = setTimeout(tick, 1000);
  return () => clearTimeout(id);   // 正解:显式返回清理函数
}, [tick]);

// 错误二:async 回调
useEffect(async () => {
  const res = await fetch('/api');
}, []);
// TS2322: Argument of type '() => Promise<void>' is not assignable to
//   parameter of type 'EffectCallback'.
useEffect(() => {
  const ac = new AbortController();
  void (async () => { await fetch('/api', { signal: ac.signal }); })();
  return () => ac.abort();
}, []);

第二条报错的价值不只是类型正确,它同时避免了「异步回调返回 Promise 被 React 当成清理函数」这一真实隐患——React 会尝试调用返回的 Promise,运行时报 TypeError: destroy is not a function。

依赖数组的类型不被检查:[] 与 [a, b] 都是 DependencyList,编译器无法知道你在 effect 里读了哪些值。这部分只能交给 eslint-plugin-react-hooks 的 exhaustive-deps 规则。实践建议是:把 effect 里用到的所有外部值都列进依赖,需要稳定引用时用 useCallback / useMemo 固定,而不是删依赖。

11.2.5 useMemo 与 useCallback 的类型陷阱

useMemo 的推导是可靠的,只要返回值本身没有 widen 问题:

const [items, setItems] = useState<string[]>([]);
const sorted = useMemo(() => [...items].sort(), [items]); // string[]
const total = useMemo(() => items.length, [items]);        // number

// 需要联合类型时,返回值会 widen,必须显式标注
const mode = useMemo<'asc' | 'desc'>(() => (asc ? 'asc' : 'desc'), [asc]);

useCallback 的两个常见坑:

// 坑一:回调参数没有上下文类型
const onChange = useCallback((e) => setValue(e.target.value), [setValue]);
// TS7006: Parameter 'e' implicitly has an 'any' type.
// 原因:useCallback 的类型参数由泛型推导,回调不在 JSX 属性位置上,
//       拿不到 ChangeEvent<HTMLInputElement> 这个上下文类型
const onChange2 = useCallback(
  (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value),
  [setValue],
);

// 坑二:泛型函数被 useCallback 包一层后,泛型签名可能丢失
const createHandler = useCallback(<T,>(value: T) => value, []);

坑一的通用规律值得记住:上下文类型只沿「直接赋值给已标注位置」的方向传播。onChange={(e) => …} 里的 e 有类型,是因为 JSX 属性本身有类型;而 useCallback((e) => …, []) 里的 e 没有任何标注位置,所以只能是隐式 any。

坑二在 React 19 的类型定义下多数情况已能保留泛型,但跨版本行为不一致。稳妥写法是把泛型函数定义在组件外部——与渲染无关的纯函数根本不需要 useCallback。

11.2.6 useContext 与 useSyncExternalStore

useContext 的类型完全由 createContext 决定,本身没有推导空间:

const ThemeContext = createContext<'light' | 'dark'>('light');
const theme = useContext(ThemeContext); // 'light' | 'dark'

如果 context 的默认值是 null 或 undefined(11.3 会详细展开),每个消费点都得判空,这时应该封装成自定义 Hook 并在内部抛错,而不是让 TS18047 散落到各处。

useSyncExternalStore<T>(subscribe, getSnapshot, getServerSnapshot) 是 React 18 引入的订阅原语,T 由 getSnapshot 的返回值推导。唯一的硬性约束是 getSnapshot 必须返回缓存过的引用——每次调用都构造新对象会让 React 判定「快照变了」从而无限重渲染,运行时报 The result of getSnapshot should be cached to avoid an infinite loop。类型层面拦不住这个错误,所以返回对象时要显式标注类型参数并在 store 侧做缓存。

11.2.7 自定义 Hook 的返回值形状

自定义 Hook 的类型设计,本质是选一个返回值形状。两种主流做法各有明确的适用面:

形状写法优点缺点
元组[value, setValue]调用方可重命名,位置语义紧凑扩展字段要改所有调用点
对象{ data, loading, refetch }可扩展、可选择性解构需要多写一层类型

元组的经典陷阱是漏写 as const:

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle];
  // 推导为 (boolean | (() => void))[]
}

const [on, toggle] = useToggle();
toggle(); // TS2349: This expression is not callable.
          //   Not all constituents of type 'boolean | (() => void)' are callable.

// 正解一:在上面 useToggle 内把 return 改为 return [on, toggle] as const
// 正解二:显式写返回类型(推荐,签名即文档)
function useToggleTyped(initial = false): [boolean, () => void] {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle];
}

对象形状适合「有多个可选输出」的 Hook。 类型先定义、实现再补,是这类 Hook 的正确写法:

type UseFetchResult<T> =
  | { status: 'loading'; data: null; error: null }
  | { status: 'done'; data: T; error: null }
  | { status: 'error'; data: null; error: Error };

// 这里只展示契约,完整实现还需请求、取消与状态更新
declare function useFetch<T>(url: string): UseFetchResult<T>;

调用方写 if (res.status === 'done') res.data.name 时,data 一定是 T 而不是 T | null——把 11.2.2 的判别联合思想用在 Hook 返回值上,比「三个布尔量 + 一个可空数据」少掉一整类判空代码。

自定义 Hook 还有一条命名约束:必须以 use 开头,否则 eslint-plugin-react-hooks 不会把它当作 Hook 检查,条件调用、依赖数组等问题都会漏掉。

11.2.8 泛型 Hook 与运行时校验

泛型自定义 Hook 最常见的需求是「读写带类型的持久化状态」。先看一个看起来对、实际在撒谎的版本:

function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : initial;
  });

  const update = useCallback((next: T) => {
    setValue(next);
    localStorage.setItem(key, JSON.stringify(next));
  }, [key]);

  return [value, update] as const;
}

JSON.parse 的返回类型是 any,所以 as T 一定通过——这个断言把「存储里可能是旧版本结构」这一事实彻底掩盖了。类型 T 是调用方指定的,而磁盘上的 JSON 可能是上个月写的。正确做法是要求调用方提供一个解码器,把校验责任显式化:

type Decoder<T> = { parse: (raw: unknown) => T };

function useStored<T>(key: string, initial: T, decoder: Decoder<T>) {
  const [value, setValue] = useState<T>(() => {
    const raw = localStorage.getItem(key);
    if (raw === null) return initial;
    try {
      return decoder.parse(JSON.parse(raw));
    } catch {
      return initial; // 结构不兼容时回落到初值
    }
  });
  // setValue 与写入逻辑同上
  return [value, setValue] as const;
}

decoder 可以是 zod schema,也可以是手写的窄化函数。手写版的要点是断言必须写在真实检查之后:先判断 raw 是对象、含 id 字段,再收窄成 User,这样 as 就不再是撒谎。zod 方案能完全避免手写断言,见 《TypeScript编程实战》13.1 React Hook Form + Zod 。

11.2.9 常见错误对照表

错误码与信息真实原因修复
TS7006: Parameter 'e' implicitly has an 'any' type.回调不在有上下文类型的位置显式标注参数类型,或内联传入 JSX
TS2322: Type '() => number' is not assignable to type 'EffectCallback'.effect 回调隐式返回值改成块体并显式返回清理函数
TS2349: This expression is not callable.元组返回值没加 as const加 as const 或写显式返回类型
TS2345: ... is not assignable to parameter of type 'Action'.action 先存变量被 widen 成 string内联传入或加 as const
TS2554: Expected 1 arguments, but got 0.React 19 的 useRef 必须传初值传 null 或真实初值
TS18047: 'x.current' is possibly 'null'.ref 未收窄判空或用可选链

11.2.10 与本书其它章节的衔接

Hook 内部拿到的数据往往来自网络层,其类型推导与缓存失效策略见 《TypeScript编程实战》14.1 TanStack Query 类型推导 与 《TypeScript编程实战》14.2 乐观更新与缓存失效 。Hook 抛出的错误如何被兜住,见 《TypeScript编程实战》3.2 全局错误边界与未捕获异常 。而状态一旦需要跨组件共享,就该离开 Hook 走进 Context 或状态库,这正是下一节的主题。

站内既有专题对 React 做过系统性梳理,可作延伸阅读:React Hooks 完全指南 、React + TypeScript 实战 、TypeScript 类型层编程 。

小结

本节的主线是「推导从哪里开始、在哪里断掉」。推导层:useState 从初值推类型,字面量会 widen,函数状态必须显式传类型参数;对象更新要用函数式合并,否则会撞上 TS2345。状态层:超过三个互斥状态就该上判别联合 + useReducer,用 satisfies 校验初值、用 never 做穷尽性检查,action 不要先存变量再加 as 断言。引用层:useRef 的三种形态决定了 current 可不可写,React 19 要求传初值且 ref 可直接作为 prop;current 永远要判空。副作用层:effect 回调只能返回 void | 清理函数,async 回调必须包一层;依赖数组的类型不被检查,靠 lint 规则兜。封装层:元组返回值要加 as const,对象返回值适合用判别联合表达状态,泛型 Hook 的 as T 断言必须换成显式解码器。

三个最容易犯的错:给 useState(null) 却不写类型参数,导致后续每次赋值都要断言;useRef 当 useState 用,状态改了界面不更新;在泛型 Hook 里用 as T 信任 JSON.parse。它们的共同点是「类型看起来是对的」——所以判断标准不该是「编译过没过」,而是「类型有没有覆盖真实存在的分支」。

到目前为止,状态都还活在单个组件内部。跨组件共享时,Context 与状态库各有自己的类型陷阱——默认值该不该是 null、selector 为什么会让组件无限重渲染、createSlice 的 PayloadAction 从哪里推类型,都是下一节的内容。接下来 《TypeScript编程实战》11.3 Context 与状态管理(Zustand / RTK) 会把这三套方案放在一起,讲清它们的类型模型与选型边界。

阅读导航:上一节:11.1 组件 props 与泛型组件 · 下一节:11.3 Context 与状态管理(Zustand / RTK) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes