Files
2026-03-02 21:16:26 +01:00

4.8 KiB

ssr-hydration-safety: Prevent Hydration Mismatches

Priority: MEDIUM

Explanation

Hydration errors occur when server-rendered HTML doesn't match what the client expects. This causes React to discard server HTML and re-render, losing SSR benefits. Ensure consistent rendering between server and client.

Bad Example

// Using Date.now() - different on server and client
function Timestamp() {
  return <span>Generated at: {Date.now()}</span>
}

// Using Math.random() - always different
function RandomGreeting() {
  const greetings = ['Hello', 'Hi', 'Hey']
  return <h1>{greetings[Math.floor(Math.random() * 3)]}</h1>
}

// Checking window - doesn't exist on server
function DeviceInfo() {
  return <span>Width: {window.innerWidth}px</span>  // Error on server
}

// Conditional render based on time
function TimeBasedContent() {
  const hour = new Date().getHours()
  return hour < 12 ? <Morning /> : <Evening />
  // Server might render Morning, client renders Evening
}

Good Example: Consistent Server/Client Rendering

// Pass data from server to avoid mismatch
export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    return {
      generatedAt: Date.now(),
    }
  },
  component: Dashboard,
})

function Dashboard() {
  const { generatedAt } = Route.useLoaderData()
  // Both server and client use same value
  return <span>Generated at: {generatedAt}</span>
}

Good Example: Client-Only Components

// Use lazy loading for client-only features
import { lazy, Suspense } from 'react'

const ClientOnlyMap = lazy(() => import('./Map'))

function LocationPage() {
  return (
    <div>
      <h1>Our Location</h1>
      <Suspense fallback={<MapPlaceholder />}>
        <ClientOnlyMap />
      </Suspense>
    </div>
  )
}

// Or use useEffect for client-only state
function WindowSize() {
  const [size, setSize] = useState<{ width: number; height: number } | null>(null)

  useEffect(() => {
    setSize({
      width: window.innerWidth,
      height: window.innerHeight,
    })
  }, [])

  if (!size) {
    return <span>Loading dimensions...</span>
  }

  return <span>{size.width} x {size.height}</span>
}

Good Example: Stable Random Values

// Generate random value on server, pass to client
export const Route = createFileRoute('/onboarding')({
  loader: () => ({
    welcomeVariant: Math.floor(Math.random() * 3),
  }),
  component: Onboarding,
})

function Onboarding() {
  const { welcomeVariant } = Route.useLoaderData()
  const messages = ['Welcome aboard!', 'Let's get started!', 'Great to have you!']

  return <h1>{messages[welcomeVariant]}</h1>  // Same on server and client
}

Good Example: Handling Time Zones

// Pass formatted date from server
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await getPost(params.postId)
    return {
      ...post,
      // Format on server to avoid timezone mismatch
      formattedDate: new Intl.DateTimeFormat('en-US', {
        dateStyle: 'long',
        timeStyle: 'short',
        timeZone: 'UTC',  // Consistent timezone
      }).format(post.createdAt),
    }
  },
  component: PostPage,
})

// Or use client-only formatting
function RelativeTime({ date }: { date: Date }) {
  const [formatted, setFormatted] = useState<string>('')

  useEffect(() => {
    // Format in user's timezone after hydration
    setFormatted(formatDistanceToNow(date, { addSuffix: true }))
  }, [date])

  // Show absolute date initially (same server/client)
  return <time dateTime={date.toISOString()}>
    {formatted || date.toISOString().split('T')[0]}
  </time>
}

Common Hydration Mismatch Causes

Issue Solution
Date.now() / new Date() Pass timestamp from loader
Math.random() Generate on server, pass to client
window / document Use useEffect or lazy loading
User timezone differences Use UTC or client-only formatting
Browser-specific APIs Check typeof window !== 'undefined'
Extension-injected content Use suppressHydrationWarning

Debugging Hydration Errors

// React 18+ provides detailed hydration error messages
// Check the console for:
// - "Text content does not match"
// - "Hydration failed because"
// - The specific DOM element causing the issue

// For difficult cases, use suppressHydrationWarning sparingly
function UserContent({ html }: { html: string }) {
  return (
    <div
      suppressHydrationWarning
      dangerouslySetInnerHTML={{ __html: html }}
    />
  )
}

Context

  • Hydration compares server HTML with client render
  • Mismatches force full client re-render (slow, flash)
  • Use loaders to pass dynamic data consistently
  • Defer client-only content with useEffect or Suspense
  • Test SSR by disabling JavaScript and checking render
  • Development mode shows hydration warnings in console