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 报了三个问题:
FilterBar.tsx:count参数定义了但从未使用。TocNav.tsx:在useEffect内同步调用setMounted(true)。ArticleImageProvider.tsx:在 render 阶段读取slidesRef.current。
前两个是 warning/quality 问题,第三个是 React 官方明确不推荐的 pattern。它们都不是 Expressive Code 的代码,但修复后 hydration mismatch 却消失了。
坑一:FilterBar 的多余 props
FilterBar 同时接收 count 和 countLabel:
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.tsx 和 PhotoGrid.tsx 两处调用即可。
坑二:TocNav 的 mounted effect
TocNav 用 createPortal 把目录注入到 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 差异也就没再触发。
验证
修复后运行:
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。
经验总结
- 不要轻视 lint error。
TocNav的 mounted effect 和ArticleImageProvider的 ref 读取都是"能跑但不对"的代码,短期内不会崩,但会在 hydration 这种边界场景积累不确定性。 useSyncExternalStore是 mount 检测的正确工具。 它比useEffect+setState更符合 React 的渲染模型,也不会触发 cascading renders。- render 阶段不读 ref。 任何需要在 render 中使用的数据都应该提升到 state 或 props;ref 只用于事件处理、effect 或命令式 API。
- hydration mismatch 的根源可能在别处。 当报错指向 A 组件时,先检查整棵 client tree 的边界稳定性,往往会有意外发现。