← 文章
tutorial·2026-07-04·11 min read

React 19 + Next.js 16:三个 lint 错误如何引发 hydration mismatch

从 eslint error 到 dev 模式零报错,修复 client boundary 的实战经验

目录

在做完整站配色系统改造后,npm run lint 全绿、tsc 无错,但我打开 dev 模式的文章详情页时,Next.js 左下角却亮起了红色错误标记。控制台报的是 hydration mismatch:

A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.

差异点落在 Expressive Code 渲染的 <pre> 上——服务端没有 tabindex="0"role="region",客户端却有。

这篇文章记录我怎么从三个看似无关的 lint error 出发,把 hydration 也一起修干净。

三个 lint error

当时 npm run lint 报了三个问题:

  1. FilterBar.tsxcount 参数定义了但从未使用。
  2. TocNav.tsx:在 useEffect 内同步调用 setMounted(true)
  3. ArticleImageProvider.tsx:在 render 阶段读取 slidesRef.current

前两个是 warning/quality 问题,第三个是 React 官方明确不推荐的 pattern。它们都不是 Expressive Code 的代码,但修复后 hydration mismatch 却消失了。

坑一:FilterBar 的多余 props

FilterBar 同时接收 countcountLabel

export default function FilterBar({
filter,
onChange,
count,
options,
countLabel,
}: {
filter: string
onChange: (value: string) => void
count: number
options: readonly FilterOption[]
countLabel: string
}) {
// ...
<span className="ml-auto text-[13px] text-dim">{countLabel}</span>
}

调用方已经把数量格式化成字符串传进来,组件内部却又要一个 count。这种 API 冗余本身不会触发 hydration,但它会让组件在服务端和客户端的 prop 比较路径上多一层不确定性——尤其是当 countLabel 的生成逻辑涉及 i18n 时。

修复很简单:删掉 count,让 FilterBar 只关心显示文本。

export default function FilterBar({
filter,
onChange,
options,
countLabel,
}: {
filter: string
onChange: (value: string) => void
options: readonly FilterOption[]
countLabel: string
}) {
// ...
}

同步更新 PostList.tsxPhotoGrid.tsx 两处调用即可。

坑二:TocNav 的 mounted effect

TocNavcreatePortal 把目录注入到 document.body,所以必须等客户端 mount 后才能渲染。原来的写法是:

const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
return createPortal(gutter, document.body)

这个 pattern 在 React 19 + Next.js 16 下被 eslint 的 react-hooks/set-state-in-effect 规则拦截。规则认为 effect 里同步 setState 会导致 cascading renders,确实如此:首次渲染 mounted=false → effect 触发 → 立刻 setState → 第二次渲染 mounted=true

更干净的写法是用 useSyncExternalStore,这是 React 专门给"订阅外部 store"(此处是"是否已 mount")提供的 API:

import { useSyncExternalStore } from 'react'
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
)

三个参数分别是:

  • subscribe:空实现,因为我们不需要监听变化。
  • getSnapshot:客户端返回 true
  • getServerSnapshot:服务端返回 false

这样 React 在服务端就知道要渲染 null,客户端知道要渲染 portal,不需要二次渲染,hydration 路径更稳定。

坑三:render 阶段读取 ref

ArticleImageProvider 负责给文章内所有图片注册 lightbox slides。原来的实现用 ref 存储 slides:

const slidesRef = useRef<LightboxSlide[]>([])
const register = useCallback((slide: LightboxSlide) => {
const existing = slidesRef.current.findIndex(s => s.src === slide.src)
if (existing >= 0) return existing
slidesRef.current.push(slide)
return slidesRef.current.length - 1
}, [])
return (
<ArticleImageContext.Provider value={{ register, open }}>
{children}
<PhotoLightbox
slides={slidesRef.current}
// ...
/>
</ArticleImageContext.Provider>
)

问题在最后一行:slidesRef.current 在 render 阶段被读取。React 明确禁止这种行为,因为 ref 不是 render 的输入,读取它会让组件在 SSR 和客户端产生不一致的快照。

修复方式是把 slides 提升为 state:

const [slides, setSlides] = useState<LightboxSlide[]>([])
const register = useCallback((slide: LightboxSlide) => {
let index = -1
setSlides(prev => {
const existing = prev.findIndex(s => s.src === slide.src)
if (existing >= 0) {
index = existing
return prev
}
index = prev.length
return [...prev, slide]
})
return index
}, [])

register 返回的 index 在 state updater 内部计算,保证与 React 的批处理一致。PhotoLightbox 直接接收 slides state,render 阶段不再触碰 ref。

为什么 hydration mismatch 消失了?

最初的 hydration error 明明指向 Expressive Code 的 <pre>,修完这三个组件后却没了。我的理解是:

React 的 hydration 是一棵树的整体比对。如果树里某个 client boundary 在首次渲染时不稳定(比如 TocNav 的二次渲染、ArticleImageProvider 的 ref 读取),React 可能会对整棵子树的 attribute diff 产生误判,或者把后续组件的客户端 patch 也连带报错。

换句话说,Expressive Code 的 <pre> 可能只是"症状",真正的"病灶"是这几个 client boundary 组件在 SSR/Client 切换时的不稳定行为。把它们修干净后,hydration 路径变清晰了,Expressive Code 的 attribute 差异也就没再触发。

验证

修复后运行:

Terminal window
npm run lint && npx tsc --noEmit

结果:

  • eslint:0 errors, 0 warnings
  • color audit:All tracked color pairings pass WCAG AA
  • TypeScript:无错误

打开 dev 模式的文章详情页,Next.js 左下角红色错误指示器消失,控制台 0 errors

经验总结

  1. 不要轻视 lint error。 TocNav 的 mounted effect 和 ArticleImageProvider 的 ref 读取都是"能跑但不对"的代码,短期内不会崩,但会在 hydration 这种边界场景积累不确定性。
  2. useSyncExternalStore 是 mount 检测的正确工具。 它比 useEffect + setState 更符合 React 的渲染模型,也不会触发 cascading renders。
  3. render 阶段不读 ref。 任何需要在 render 中使用的数据都应该提升到 state 或 props;ref 只用于事件处理、effect 或命令式 API。
  4. hydration mismatch 的根源可能在别处。 当报错指向 A 组件时,先检查整棵 client tree 的边界稳定性,往往会有意外发现。