← Writing
tutorial·2026-07-05·5 min read

React 19 + Next.js 16: How Three Lint Errors Caused a Hydration Mismatch

From eslint errors to zero console errors — fixing client boundary instability in practice

On this page

After finishing the site-wide color system refactor, npm run lint was green and tsc reported no errors. But when I opened an article page in dev mode, the Next.js overlay showed a red error badge. The console reported a hydration mismatch:

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

The diff pointed at an Expressive Code <pre> element — the server render lacked tabindex="0" and role="region", while the client render had them.

This post documents how I traced the issue through three seemingly unrelated lint errors and fixed the hydration warning along the way.

The three lint errors

npm run lint was reporting three issues:

  1. FilterBar.tsx: the count prop was defined but never used.
  2. TocNav.tsx: synchronous setMounted(true) inside useEffect.
  3. ArticleImageProvider.tsx: reading slidesRef.current during render.

The first two looked like quality issues; the third was a pattern React explicitly discourages. None of them lived inside Expressive Code, yet fixing all three made the hydration mismatch disappear.

Pitfall 1: Redundant props in FilterBar

FilterBar accepted both count and 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>
}

Callers already passed a formatted count string via countLabel, so the component never needed count. This API redundancy does not directly cause hydration, but it adds unnecessary variance between server and client prop resolution — especially when countLabel is generated through i18n.

The fix was simple: drop count and let FilterBar only worry about displaying text.

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

Both call sites in PostList.tsx and PhotoGrid.tsx were updated.

Pitfall 2: Mounted detection via useEffect

TocNav renders a fixed TOC into document.body through createPortal, so it must wait for client mount. The original implementation looked like this:

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

React 19's eslint rule react-hooks/set-state-in-effect flags this because a synchronous setState inside an effect causes a cascading render: first render with mounted=false, effect fires, immediate second render with mounted=true.

The better tool is useSyncExternalStore, which React designed exactly for "subscribing" to an external source — here, the fact that we are on the client:

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

The three arguments are:

  • subscribe: no-op, because we do not need to listen for changes.
  • getSnapshot: returns true on the client.
  • getServerSnapshot: returns false on the server.

React can now know during SSR that this component should render null, and on the client that it should render the portal — without a second render pass.

Pitfall 3: Reading a ref during render

ArticleImageProvider registers article images as lightbox slides. The original implementation stored slides in a ref:

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>
)

The problem is the last line: slidesRef.current is read during render. React forbids this because refs are not render inputs; reading them produces different snapshots on the server and client.

The fix is to promote slides to 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
}, [])

The returned index is computed inside the state updater, so it stays consistent with React's batching. PhotoLightbox receives the slides state directly, and render no longer touches any ref.

Why the hydration mismatch disappeared

The original hydration error was attributed to Expressive Code's <pre>, yet the fix lived in three unrelated components. My interpretation:

Hydration compares the entire tree. When a client boundary behaves unstably during the first render — for example, TocNav re-rendering immediately or ArticleImageProvider reading a ref — React can mis-attribute attribute differences further down the tree. The Expressive Code <pre> was likely the symptom; the real issue was unstable client boundaries polluting the hydration diff.

Once those boundaries became deterministic, the hydration path cleared up and Expressive Code's attribute differences stopped triggering a warning.

Verification

After the fixes:

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

Results:

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

Opening an article page in dev mode now shows a clean Next.js indicator and 0 console errors.

Takeaways

  1. Do not ignore lint errors. The TocNav mounted effect and ArticleImageProvider ref read were "works but wrong" patterns. They do not crash immediately, but they create uncertainty at hydration time.
  2. Use useSyncExternalStore for mount detection. It is the React-approved tool and avoids cascading renders.
  3. Never read refs during render. Any data needed in render should come from state or props; refs belong in event handlers, effects, or imperative APIs.
  4. Hydration errors can have upstream causes. When the console blames component A, inspect the whole client tree for unstable boundaries — the real fix may be elsewhere.