hydration-safety

Hydration Safety Skill

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "hydration-safety" with this command: npx skills add omerakben/omer-akben/omerakben-omer-akben-hydration-safety

Hydration Safety Skill

Overview

Next.js uses Server-Side Rendering (SSR), which means components render twice:

  • Server-side - Initial HTML generation

  • Client-side - React hydration with JavaScript

Mismatches between these two renders cause hydration errors. This skill covers patterns to prevent and fix these issues.

The Core Problem

// ❌ WRONG - Causes hydration mismatch function Component() { const user = localStorage.getItem("user"); // localStorage not available on server return <div>Welcome {user}</div>; }


**Error:** "Text content does not match server-rendered HTML"

**Why:** Server renders `&#x3C;div>Welcome &#x3C;/div>`, client tries to render `&#x3C;div>Welcome John&#x3C;/div>`

## The isMounted Pattern (CRITICAL)

This is the **standard solution** for hydration-safe components:

```typescript
"use client";
import { useState, useEffect } from "react";

function Component() {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  // Return null or skeleton during SSR
  if (!isMounted) {
    return null; // or &#x3C;Skeleton />
  }

  // Only runs on client after hydration
  const user = localStorage.getItem("user");
  return &#x3C;div>Welcome {user}&#x3C;/div>;
}
```typescript

### How It Works

1. **Server-side:** `isMounted = false`, component returns `null`
2. **Client-side (first render):** `isMounted = false`, still returns `null`
3. **Client-side (after useEffect):** `isMounted = true`, renders with browser APIs
4. **Result:** Server and first client render match perfectly

## Common Hydration Triggers

### 1. Browser APIs

❌ **Causes Hydration Errors:**

- `localStorage`
- `sessionStorage`
- `window`
- `document`
- `navigator`
- Date/time without consistent timezone

✅ **Solution:** Use isMounted pattern

### 2. Random Values

```typescript
// ❌ WRONG
function Component() {
  const id = Math.random(); // Different on server vs client
  return &#x3C;div id={id}>Content&#x3C;/div>;
}

// ✅ CORRECT
function Component() {
  const [id, setId] = useState&#x3C;string>();

  useEffect(() => {
    setId(String(Math.random()));
  }, []);

  return &#x3C;div id={id}>Content&#x3C;/div>;
}
```typescript

### 3. Date/Time

```typescript
// ❌ WRONG
function Clock() {
  return &#x3C;div>{new Date().toLocaleTimeString()}&#x3C;/div>;
}

// ✅ CORRECT
"use client";
function Clock() {
  const [time, setTime] = useState&#x3C;string>();

  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
    const interval = setInterval(() => {
      setTime(new Date().toLocaleTimeString());
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  if (!time) return &#x3C;div>--:--:--&#x3C;/div>; // Placeholder
  return &#x3C;div>{time}&#x3C;/div>;
}
```typescript

## Implementation Patterns

### Pattern 1: Null During SSR

```typescript
"use client";
function Component() {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  if (!isMounted) return null;

  return &#x3C;div>{/* Browser-dependent content */}&#x3C;/div>;
}
```typescript

**Use when:** Component doesn't need to show during SSR

### Pattern 2: Skeleton During SSR

```typescript
"use client";
function Component() {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  if (!isMounted) {
    return &#x3C;div className="animate-pulse bg-surf-1 h-10 w-32" />;
  }

  return &#x3C;div>{/* Actual content */}&#x3C;/div>;
}
```typescript

**Use when:** Need loading state visible during SSR

### Pattern 3: Safe Defaults

```typescript
"use client";
function Component() {
  const [data, setData] = useState&#x3C;string>("default");

  useEffect(() => {
    const stored = localStorage.getItem("key");
    if (stored) setData(stored);
  }, []);

  return &#x3C;div>{data}&#x3C;/div>;
}
```typescript

**Use when:** Can show default value during SSR

## Project-Specific Patterns

### Sidebar Assistant (Example from codebase)

Location: `src/components/chat/chat-sidebar.tsx`

```typescript
"use client";
export function ChatSidebar() {
  const [isMounted, setIsMounted] = useState(false);
  const { isPinned, width } = useChatSidebar();

  useEffect(() => {
    setIsMounted(true);
  }, []);

  if (!isMounted) {
    return null; // Sidebar hidden during SSR
  }

  return (
    &#x3C;div style={{ width: `${width}px` }}>
      {/* Sidebar content */}
    &#x3C;/div>
  );
}
```typescript

### Global Chat Button

Location: `src/components/global-chat-button.tsx`

```typescript
"use client";
export function GlobalChatButton() {
  const { isOpen } = useChatSidebar();
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, [isOpen]); // Added isOpen to dependency array

  // Early return AFTER all hooks
  if (!isMounted || isOpen) {
    return null;
  }

  return &#x3C;button>Open Chat&#x3C;/button>;
}
```typescript

**Note:** All hooks must be called before conditional returns!

## Testing Hydration Safety

### Unit Tests

```typescript
import { render, screen } from "@testing-library/react";
import { act } from "react";

describe("Hydrated Component", () => {
  it("should not show content during SSR", () => {
    render(&#x3C;Component />);
    expect(screen.queryByTestId("hydrated-content")).not.toBeInTheDocument();
  });

  it("should show content after mount", async () => {
    render(&#x3C;Component />);

    // Wait for useEffect
    await act(async () => {
      await new Promise((resolve) => setTimeout(resolve, 0));
    });

    expect(screen.getByTestId("hydrated-content")).toBeInTheDocument();
  });
});
```typescript

### E2E Tests (Playwright)

```typescript
import { test, expect } from "@playwright/test";

test("should handle hydration correctly", async ({ page }) => {
  await page.goto("/", { waitUntil: "networkidle" });

  // Wait for React hydration to complete
  await page.waitForSelector('[data-testid="hydrated-component"]', {
    state: "attached",
    timeout: 10000,
  });

  // Wait for loading spinner to disappear (if present)
  await page.waitForSelector(".animate-spin", {
    state: "detached",
    timeout: 10000,
  });

  // Additional stabilization time
  await page.waitForTimeout(500);

  // Now safe to interact
  await page.click('[data-testid="interactive-element"]');
});
```typescript

### Why E2E Waits Are Critical

Without waits, E2E tests run on intermediate DOM states:

```typescript
// ❌ WRONG - Runs too early
test("wrong timing", async ({ page }) => {
  await page.goto("/");
  await page.click("button"); // May not be hydrated yet!
});

// ✅ CORRECT - Waits for hydration
test("correct timing", async ({ page }) => {
  await page.goto("/", { waitUntil: "networkidle" });
  await page.waitForSelector('[data-testid="ready"]');
  await page.waitForTimeout(500); // DOM stabilization
  await page.click("button"); // Now safe!
});
```typescript

## Debugging Hydration Errors

### Error Messages

**"Text content does not match server-rendered HTML"**

- Cause: Different text rendered on server vs client
- Solution: Use isMounted pattern or ensure consistent data

**"Hydration failed because the initial UI does not match"**

- Cause: Different structure rendered on server vs client
- Solution: Make server and client render the same initially

**"There was an error while hydrating"**

- Cause: Component error during hydration
- Solution: Check browser console for specific error

### Debugging Steps

1. **Check Browser Console** - Specific error details appear here
2. **Inspect Network Tab** - Compare SSR HTML vs hydrated DOM
3. **Use React DevTools** - Highlight hydration mismatches
4. **Add data-testid After Mount** - Verify component is hydrated

```typescript
return (
  &#x3C;div data-testid={isMounted ? "hydrated" : undefined}>
    Content
  &#x3C;/div>
);
```typescript

5. **Check localStorage/sessionStorage Access** - Most common cause

## React Hooks Rules with Hydration

### ⚠️ Critical: Hooks Before Returns

```typescript
// ❌ WRONG - Hook after conditional return
function Component() {
  const { isOpen } = useContext();

  if (!isOpen) return null; // Early return

  useEffect(() => {  // ❌ Hook after return!
    // ...
  }, []);
}

// ✅ CORRECT - All hooks before returns
function Component() {
  const { isOpen } = useContext();
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {  // ✓ Hook before return
    setIsMounted(true);
  }, []);

  if (!isOpen || !isMounted) return null; // After all hooks
}
```typescript

## Common Mistakes

### Mistake 1: Checking window Directly

```typescript
// ❌ WRONG
function Component() {
  if (typeof window === "undefined") return null;
  return &#x3C;div>{localStorage.getItem("key")}&#x3C;/div>;
}
```typescript

**Problem:** First client render still won't match server

### Mistake 2: Forgetting "use client"

```typescript
// ❌ WRONG - Missing directive
import { useState, useEffect } from "react";

function Component() {
  const [mounted, setMounted] = useState(false);
  // ...
}
```typescript

**Problem:** Server components can't use hooks

### Mistake 3: Using useLayoutEffect

```typescript
// ❌ WRONG - Causes SSR warnings
useLayoutEffect(() => {
  setIsMounted(true);
}, []);

// ✅ CORRECT - Use useEffect for hydration
useEffect(() => {
  setIsMounted(true);
}, []);
```typescript

## Checklist for Hydration-Safe Components

- [ ] Add "use client" directive if using hooks
- [ ] All hooks called before conditional returns
- [ ] Use isMounted pattern for browser APIs
- [ ] Return consistent structure during SSR and first client render
- [ ] Add data-testid after mount for E2E tests
- [ ] Test component in both SSR and client contexts
- [ ] Verify no hydration errors in browser console
- [ ] E2E tests wait for hydration before interactions

## Quick Reference

```typescript
// Standard isMounted pattern
const [isMounted, setIsMounted] = useState(false);
useEffect(() => { setIsMounted(true); }, []);
if (!isMounted) return null;

// E2E wait pattern
await page.goto("/", { waitUntil: "networkidle" });
await page.waitForSelector('[data-testid="ready"]');
await page.waitForTimeout(500);

// Debug in browser
// Check: document.documentElement.dataset.reactHydrated
```typescript

## Related Files

- `src/components/chat/chat-sidebar.tsx` - Production example
- `src/components/global-chat-button.tsx` - Production example
- `e2e/a11y.spec.ts` - E2E hydration patterns
- `src/lib/chat-sidebar-context.tsx` - Context with hydration safety

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

bundle-optimization

No summary provided by upstream source.

Repository SourceNeeds Review
General

doc-coauthoring

No summary provided by upstream source.

Repository SourceNeeds Review
General

data-architecture

No summary provided by upstream source.

Repository SourceNeeds Review
General

mcp-builder

No summary provided by upstream source.

Repository SourceNeeds Review