当你看到
Cannot read properties of null (reading 'useState'),不要只检查业务数据。它通常意味着 Hook 的调用上下文或 React 单例出现了问题。
现象
某个页面、弹窗或抽屉在本地开发环境渲染时崩溃,调用栈指向第三方组件库的 Hook:
text
Uncaught TypeError: Cannot read properties of null (reading 'useState')
at useWatch (...)
at useSomeFeatureState (...)
at SomeModal (...)
常见特点:
- 对应页面可能在线上正常;
- 同一库的其他 Hook 在当前项目中也可能正常;
- 报错发生在渲染阶段,尚未执行接口请求或业务事件;
- 重新启动开发服务、切换分支或 HMR 更新后,复现概率可能变化。
本文以 Ant Design 的 Form.useWatch 为例,但诊断方法适用于所有内部调用 React Hook 的 UI 库、状态库和自定义组件。
结论先说
这类异常最常见的两类原因是:
- Hook 不在函数组件或自定义 Hook 的顶层执行,违反了 Hook 规则。
- 执行 Hook 的 React 实例,与渲染当前组件的 React 实例不是同一个。
第二类尤其容易被忽略。它往往由依赖版本重复、软链接/monorepo、包的 CJS/ESM 双入口、深路径导入,或 Vite 等开发服务器的预构建和 HMR 模块图造成。
一个典型触发方式
以下写法类型检查完全正常,却可能在某些本地开发环境出问题:
ts
// sharedFormHooks.ts
import Form from 'antd/es/form';
export function useFormFeatureState(form: FormInstance) {
return Form.useWatch('feature', form);
}
而调用页面使用的是:
ts
import { Form } from 'antd';
const [form] = Form.useForm();
const feature = useFormFeatureState(form);
修复方式是统一入口:
ts
import { Form, type FormInstance } from 'antd';
export function useFormFeatureState(form: FormInstance) {
return Form.useWatch('feature', form);
}
重点不是"antd/es/form 一定不能用"。深路径本身可能是库支持的 API;真正的风险是同一条组件调用链混用了不同入口,导致开发构建的依赖解析结果不一致。
原理:为什么会读到 null
React Hook 并不是独立运行的普通函数。React 渲染函数组件时,会在 React 单例上设置当前 Hook dispatcher;useState、useMemo、useContext 等 Hook 都通过这个 dispatcher 工作。
text
组件由 React A 开始渲染
→ React A 的 Hook dispatcher 已设置
→ 某依赖模块从 React B 调用 useState
→ React B 的 Hook dispatcher 仍为 null
→ 抛出"Cannot read properties of null (reading 'useState')"
因此,堆栈指向 Form.useWatch、useSelector 或某个库的 Hook,不代表表单实例、store 或业务参数为 null。需要检查的是:该 Hook 最终从哪个 React 实例执行。
为什么只在本地出现
生产构建和开发服务的模块处理方式不同:
| 场景 | 模块处理特征 | 可能结果 |
|---|---|---|
| 生产构建 | 打包器会重新分析并合并依赖 | 最终可能只有一个 React 实例 |
| Vite 本地开发 | 依赖预构建、按入口缓存、HMR 按模块替换 | 深路径或双格式依赖可能形成另一条模块图 |
所以"线上没问题"只能说明生产产物的依赖图没有触发异常,不能证明源码中的导入方式安全。应始终在冷启动后的开发环境验证。
排查步骤
1. 确认 Hook 调用规则
先排除最基础的问题:
- Hook 是否只在函数组件或自定义 Hook 内调用;
- 是否位于顶层,而不是
if、循环、事件回调、异步函数或普通工具函数中; - 同一组件的不同渲染之间,Hook 调用顺序是否稳定。
2. 从报错位置向上检查 import
重点看报错 Hook 的定义文件与调用组件:
text
[ ] 是否混用了库的桶导出和深路径导出?
[ ] 是否混用了 ESM 路径与 CJS 路径?
[ ] 是否从两个不同封装包间接引入同一个 UI 库?
[ ] 共享 Hook 是否自行引入了调用方未使用的组件入口?
例如 antd、antd/es/*、antd/lib/* 不应在同一个组件调用链中随意混用。
3. 检查 React 是否被重复安装或解析
执行以下命令确认依赖树:
bash
pnpm why react
pnpm why react-dom
pnpm list react react-dom -r
尤其关注:
- monorepo 中每个子包是否都安装了 React;
link:、file:、workspace:或pnpm link的组件库是否把 React 放到了dependencies,而不是peerDependencies;react与react-dom的版本是否匹配;- lockfile 是否同时存在无法去重的 React 版本。
4. 冷启动,排除陈旧的预构建缓存
关闭开发服务器后,删除 Vite 缓存并重新启动:
bash
rm -rf node_modules/.vite
pnpm dev
Windows PowerShell:
powershell
Remove-Item -Recurse -Force node_modules\.vite
pnpm dev
如果冷启动后不再复现,说明缓存/HMR 参与了触发;但仍应统一有风险的导入路径,避免下次更新再次出现。
5. 在浏览器中确认实际加载模块
若仍需进一步确认:
- 打开浏览器 DevTools 的 Network 或 Sources;
- 搜索
react、目标库和目标深路径的模块 URL; - 检查它们是否从不同的预构建产物或不同的
node_modules目录加载; - 对照 source map 确认报错 Hook 依赖的 React 解析位置。
这一步能把"怀疑存在两份 React"变成可验证的结论。
修复策略
优先级从高到低:
- 在同一调用链统一 React 和 UI 库的导入入口。
- 将 React、React DOM 声明为共享组件库的 peer dependency,避免组件库自行打包 React。
- 解决 monorepo、软链接或嵌套
node_modules引入的重复 React。 - 清理开发服务器依赖缓存并冷启动。
- 必要时在构建工具中配置 React 去重,例如 Vite 的
resolve.dedupe。
示例:
ts
// vite.config.ts
export default defineConfig({
resolve: {
dedupe: ['react', 'react-dom'],
},
});
dedupe 是解决"实际解析到多份 React"的工具配置,不应用来掩盖业务代码中的错误 Hook 调用或不一致的依赖声明。
预防清单
text
[ ] React 与 react-dom 版本匹配,且依赖树中只有预期的实例。
[ ] 共享组件库将 React 声明为 peerDependencies。
[ ] 同一组件调用链使用统一的 UI 库导入入口。
[ ] 不在组件/Hook 以外调用 Hook,也不在条件或回调中调用。
[ ] 依赖升级、软链接变更或 HMR 异常后,进行一次冷启动验证。
[ ] 线上正常时,仍在本地开发环境验证模块解析和热更新行为。
如何真正防止下一个项目再踩坑
单靠 IDE 规则、Code Review 提醒或某个开发者的经验都不够:它们只覆盖部分人和部分场景。更可靠的做法是把防线分成"规范、静态检查、依赖控制、CI 验证"四层,并将它们放进项目模板或组织级脚手架。
1. 写入团队工程规范
将以下规则放进团队通用的 React 开发规范、项目 CONTRIBUTING.md 或模板仓库:
md
# React Hook 与依赖单例规则
1. Hook 只能在函数组件或自定义 Hook 顶层调用;不得在条件分支、循环、回调、异步函数或普通工具函数中调用。
2. 同一组件调用链必须统一 React 与 UI 库的导入入口。优先使用包的公开入口;禁止把公开入口与同一库的深路径默认组件导入混用。
3. 共享组件库、monorepo 包和软链接包必须将 react、react-dom 声明为 peerDependencies,不得把它们作为会被重复安装的运行时依赖。
4. 遇到 Invalid hook call、Hook dispatcher 为 null 等错误时,依次检查 Hook 调用位置、import 路径和重复 React 实例;不要先归因于业务数据或最近的无关改动。
5. 仅当确认实际解析到多份 React 时,才使用构建工具去重配置;该配置不能替代修复错误的 Hook 调用或依赖声明。
这里的"统一入口"同样适用于 React Router、状态管理库和组件库:例如不要在同一调用链混用包公开入口、CJS 入口与 ESM 深路径入口,除非项目明确约定且已经验证其模块解析结果。
2. 用 ESLint 阻止可识别的问题
eslint-plugin-react-hooks 能静态阻止第一类问题(违反 Hook 调用规则)。项目应在基础 ESLint 配置中启用:
js
export default [
{
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
];
对于已确定不应使用的深路径入口,可用 no-restricted-imports 将经验变成硬约束。下面以 Ant Design 为例;规则应只限制团队确认有风险的路径,不能笼统禁止所有深路径导入:
js
export default [
{
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['antd/es/form', 'antd/lib/form'],
message: '请从 antd 统一导入 Form,避免混用桶导出与 Form 深路径入口。',
},
],
},
],
},
},
];
3. 在依赖层保证单例
共享组件库的 package.json 应采用 peer dependency,而不是自行携带 React:
json
{
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
devDependencies 仅用于组件库自身的开发和测试;消费应用负责提供实际运行时的 React。对于 monorepo,需要在根目录统一版本策略,并定期检查是否出现嵌套 node_modules/react。
4. 在 CI 中验证依赖树
将依赖检查加入依赖升级、发布共享组件库或变更软链接配置的 CI 任务。根据包管理器选择等价命令:
bash
# pnpm
pnpm why react
pnpm why react-dom
# npm
npm ls react react-dom
# Yarn
yarn why react
yarn why react-dom
CI 不应简单要求输出中"只能有一行 React":在 monorepo、测试工具或多版本迁移期可能存在合理的多版本。应由脚本或人工审查确认,最终浏览器运行时的应用模块图只解析到预期的 React 实例。
5. 配置是最后一道防线
确认存在多份 React 解析实例后,可按构建工具增加去重配置。例如 Vite:
ts
export default defineConfig({
resolve: {
dedupe: ['react', 'react-dom'],
},
});
该配置用于统一解析结果,不能掩盖源码中的违规 Hook 调用、深路径入口混用或共享库依赖声明错误。依赖入口、软链接或 HMR 行为发生变化后,仍要清理预构建缓存并冷启动验证。
小结
useState 读取 null 往往不是数据问题,而是 React Hook 的运行上下文出了问题。先检查 Hook 规则,再检查 import 入口和 React 的实际解析路径;不要只因某次无关业务改动后复现,就把它直接归因到那次改动。