Overview
Hydration mismatches occur when server-rendered HTML differs from the initial client render. This is especially common when using persisted state (localStorage, sessionStorage) that isn’t available during server-side rendering.
Poke-Nex implements a custom hydration guard using the useHydrated hook to eliminate UI flickering and provide a smooth user experience.
Hydration mismatches cause React to throw warnings, create visual flickers, and force expensive re-renders. Always guard client-only state access.
The Hydration Problem
Why Hydration Mismatches Happen
Consider this problematic code:
'use client'
import { useFavoriteStore } from '@/stores/favorite.store'
export const FavoriteButton = ({ pokemon }) => {
const isFavorite = useFavoriteStore((s) => s.favorites.some(p => p.id === pokemon.id))
return (
<button>
{isFavorite ? 'Remove from favorites' : 'Add to favorites'}
</button>
)
}
What happens:
- Server (SSR): Renders with empty favorites (no access to localStorage)
- HTML delivered: Button says “Add to favorites”
- Client hydrates: Zustand loads favorites from localStorage
- Component re-renders: Button suddenly says “Remove from favorites”
- Visual flicker: User sees button text change abruptly
React’s hydration expects the initial client render to match the server HTML exactly. Any mismatch forces React to discard the server HTML and re-render from scratch.
The Solution: useHydrated Hook
Implementation
Poke-Nex uses a simple but effective hydration guard:
'use client'
import { useEffect, useState } from 'react'
/**
* Hook to verify if the component is mounted on the client.
* Useful to prevent hydration errors when using storage or browser APIs.
*/
export const useHydrated = () => {
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
// This code only runs once the component mounts in the browser
setHydrated(true)
}, [])
return hydrated
}
How it works:
- Initial State:
hydrated starts as false (matches server state)
- Server Render: Component renders with
hydrated = false
- Client Hydration: React hydrates with same
hydrated = false
- useEffect Runs: After hydration,
setHydrated(true) triggers
- Component Re-renders: Now with access to client-only APIs
Why This Works
- Server and initial client render match: Both see
hydrated = false
- No hydration mismatch: React successfully attaches event handlers
- useEffect only runs client-side:
setHydrated(true) happens after hydration
- Single re-render: Clean transition from skeleton to real content
The useHydrated hook is a simple pattern that solves 90% of hydration issues. Use it whenever accessing localStorage, sessionStorage, or browser APIs.
Real-World Usage
PokeGallery Component
The main gallery uses useHydrated to prevent flickering when loading user preferences:
'use client'
import { useHydrated } from '@/hooks/useHydrated'
import { PokeGallerySkeleton } from '../skeletons'
import { useTweaksStore } from '@/stores/tweaks.store'
export const PokeGallery = ({ content }: Props) => {
const isHydrated = useHydrated()
const view = useTweaksStore((s) => s.view)
const setView = useTweaksStore((s) => s.setView)
const {
list: filteredList,
state: filterState,
setSearch,
setRegion,
setSort,
toggleType,
clearTypes,
} = usePokeFilters(content, { debounce: 250 })
const {
paginated: paginatedList,
current,
pages,
next,
prev,
setCurrent,
} = usePaginate(filteredList, 24)
if (!isHydrated) return <PokeGallerySkeleton />
return (
<section className="flex flex-col gap-8 max-w-7xl min-h-[68vh]">
<FilterBar
search={filterState.search}
region={filterState.region}
selectedTypes={filterState.types}
sort={filterState.sort}
view={view}
onSearch={setSearch}
onRegionUpdate={setRegion}
onToggleType={toggleType}
onClearTypes={clearTypes}
onSort={setSort}
onViewUpdate={setView}
/>
{paginatedList.length > 0 ? (
view === 'grid' ? (
<GridContainer>
{paginatedList.map((pokemon) => (
<PokemonCard key={pokemon.id} content={pokemon} />
))}
</GridContainer>
) : (
<PokemonTable content={paginatedList} />
)
) : (
<div className="col-span-full py-20 text-center">
<p className="text-zinc-500 italic">
No specimens match your current filters.
</p>
</div>
)}
<PaginationControl
current={current}
total={pages}
onNext={next}
onPrev={prev}
onPageSelect={setCurrent}
/>
</section>
)
}
What this prevents:
- View Mode Flicker: User’s grid/list preference loads smoothly
- Filter State Flash: Persisted filters don’t cause visual jumps
- Region Selection Jump: Stored region selection appears without flickering
- Type Filter Pop-in: Selected types render correctly from the start
The favorite button doesn’t use useHydrated because it handles state internally:
src/components/pokemon/FavoriteButton.tsx
'use client'
import { useFavoriteActions, useIsFavorite } from '@/stores/favorite.store'
export const FavoriteButton = ({ pokemon }: Props) => {
const { id } = pokemon
const { toggleFavorite } = useFavoriteActions()
const handleToggleFavorite = () => toggleFavorite(pokemon)
const isFavorite = useIsFavorite(id)
return (
<Button
className={`flex items-center justify-center gap-3 w-full md:w-fit ${
isFavorite ? 'bg-white! text-black! font-semibold!' : ''
}`}
onClick={handleToggleFavorite}
>
{isFavorite ? (
<>
<CgClose className="text-xl" />
<span>Remove from favorites</span>
</>
) : (
<>
<IoHeart className="text-2xl" />
<span>Add to favorites</span>
</>
)}
</Button>
)
}
Why this works without useHydrated:
- Button is only rendered on detail pages (already hydrated)
- Initial flicker is acceptable for a single button
- Zustand handles the state synchronization internally
- The button is not critical for initial page render
Not every component needs useHydrated. Use it for layout-critical components that affect the initial visual experience, like galleries, navigation, or view modes.
State Persistence Strategy
SessionStorage for Temporary State
Poke-Nex uses sessionStorage for view preferences that should reset between sessions:
export const useTweaksStore = create<TweaksState>()()
persist(
(set, get) => ({
page: 1,
region: 'all',
types: [],
query: '',
sort: 'id-asc',
view: 'grid',
// ... actions
}),
{
name: 'pokenex-tweaks',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
// Only persist these fields:
view: state.view,
region: state.region,
types: state.types,
// page and query are NOT persisted
}),
}
)
)
Design decisions:
- Persisted:
view, region, types - User preferences within session
- Not Persisted:
page, query - Volatile navigation state
- SessionStorage: Clears when tab closes, fresh start in new tabs
LocalStorage for Permanent State
Favorites use localStorage to persist across all sessions:
export const useFavoriteStore = create<Store>()()
persist(
(set, get) => ({
favorites: [],
isFavorite: (id) => get().favorites.some((p) => p.id === id),
toggleFavorite: (pokemon) => {
const { favorites, isFavorite } = get()
const newPokemon: PokemonSummary = {
id: pokemon.id,
name: pokemon.name,
types: pokemon.types,
image: pokemon.image,
}
set({
favorites: isFavorite(newPokemon.id)
? favorites.filter((p) => p.id !== newPokemon.id)
: [...favorites, newPokemon],
})
},
}),
{
name: 'POKENEX-FAVORITE-LIST',
// Uses localStorage by default
}
)
)
Why localStorage for favorites:
- User’s collection should persist across sessions
- Losing favorites on tab close would be frustrating
- No performance impact (favorites list is small)
Skeleton Loading Pattern
Why Skeletons Matter
Skeletons provide instant visual feedback during hydration:
if (!isHydrated) return <PokeGallerySkeleton />
Benefits:
- Perceived Performance: User sees layout immediately
- No Content Flash: Smooth transition from skeleton to content
- No Layout Shift: Skeleton matches final content dimensions
- Professional UX: Matches modern web application standards
Skeleton Design Principles
- Match Layout: Skeleton dimensions should match final content
- Minimal Animation: Subtle pulse or shimmer, not distracting
- Accessible: Use proper ARIA attributes (
aria-busy="true")
- Fast: Skeleton should render in < 50ms
Design your skeletons to match the final content’s layout as closely as possible. This prevents Cumulative Layout Shift (CLS) and improves Core Web Vitals scores.
Common Pitfalls
❌ Accessing Storage Directly
// BAD: Direct localStorage access without hydration guard
'use client'
export const MyComponent = () => {
const saved = localStorage.getItem('my-key') // Error on server!
return <div>{saved}</div>
}
✅ Using Hydration Guard
// GOOD: Guard storage access with useHydrated
'use client'
export const MyComponent = () => {
const isHydrated = useHydrated()
const saved = isHydrated ? localStorage.getItem('my-key') : null
if (!isHydrated) return <Skeleton />
return <div>{saved}</div>
}
❌ Conditional Rendering Without Guard
// BAD: Zustand loads from storage immediately
'use client'
export const Gallery = () => {
const view = useTweaksStore((s) => s.view) // Hydration mismatch!
return view === 'grid' ? <Grid /> : <List />
}
✅ Guard Before Conditional Rendering
// GOOD: Wait for hydration before using persisted state
'use client'
export const Gallery = () => {
const isHydrated = useHydrated()
const view = useTweaksStore((s) => s.view)
if (!isHydrated) return <Skeleton />
return view === 'grid' ? <Grid /> : <List />
}
Advanced Patterns
Hybrid Server/Client Rendering
Some components can render partial content on the server:
'use client'
export const HybridComponent = ({ staticData }) => {
const isHydrated = useHydrated()
const userPrefs = useTweaksStore((s) => s.view)
return (
<div>
{/* Always rendered (server + client) */}
<StaticContent data={staticData} />
{/* Only after hydration */}
{isHydrated && <DynamicContent prefs={userPrefs} />}
</div>
)
}
Deferred Hydration
For non-critical components, defer hydration until needed:
export const DeferredComponent = () => {
const [shouldLoad, setShouldLoad] = useState(false)
const isHydrated = useHydrated()
useEffect(() => {
// Defer until idle
requestIdleCallback(() => setShouldLoad(true))
}, [isHydrated])
if (!isHydrated || !shouldLoad) return <Placeholder />
return <ExpensiveComponent />
}
Without Hydration Guard
- Hydration mismatch warning: React logs console errors
- Forced re-render: React discards server HTML
- Layout thrashing: Visual elements jump around
- Poor Core Web Vitals: High CLS score
With Hydration Guard
- Clean hydration: Server and client HTML match
- Single re-render: Clean transition after hydration
- Stable layout: No unexpected shifts
- Better Core Web Vitals: Low CLS score
Performance cost: One additional re-render after hydration. This is negligible compared to the cost of hydration mismatches and forced re-renders.
Testing Hydration Issues
Detect Hydration Mismatches
// next.config.ts
const nextConfig = {
reactStrictMode: true, // Enables hydration checks in development
}
React will log warnings like:
Warning: Text content did not match. Server: "Add to favorites" Client: "Remove from favorites"
Visual Testing
- Disable JavaScript in DevTools
- Refresh the page
- Check if content matches your expectations
- Re-enable JavaScript
- Watch for visual flashes or layout shifts
Best Practices
- Always use
useHydrated when accessing localStorage/sessionStorage
- Provide skeleton loaders that match final content layout
- Use sessionStorage for temporary preferences (view mode, filters)
- Use localStorage for permanent data (favorites, settings)
- Test with JavaScript disabled to verify server rendering
- Monitor hydration warnings in development console
- Measure CLS scores to ensure stable layouts
- Keep skeletons simple to maintain fast initial render
- Defer non-critical hydration until after initial page load
- Document which components need hydration guards