React Islands Architecture
This site uses React Islands for client-side interactivity while keeping the server-side rendering fast and minimal with Hono JSX.
What Are Islands?
Islands architecture separates static content from interactive components:
- Sea (Server-rendered): Static HTML generated with Hono JSX
- Islands (Client-rendered): Interactive React components hydrated in browser
┌─────────────────────────────────────────────────────┐
│ HTML Page (SSR) │
│ │
│ ┌──────────────┐ Static content (Hono JSX) │
│ │ Header │ • Navigation │
│ │ (static) │ • Layout │
│ └──────────────┘ • Typography │
│ │
│ ┌──────────────┐ Island 1: React Component │
│ │ Search │ • State: query, results │
│ │ (island) │◄─ • Effects: debounce, fetch │
│ └──────────────┘ • Events: keyboard shortcuts │
│ │
│ Static paragraph text goes here... │
│ │
│ ┌──────────────┐ Island 2: React Component │
│ │Theme Switcher│ • State: theme (dark/light) │
│ │ (island) │◄─ • Effects: localStorage sync │
│ └──────────────┘ • Events: button clicks │
│ │
│ ┌──────────────┐ │
│ │ Footer │ Static content (Hono JSX) │
│ │ (static) │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────┘
Benefits:
- Performance - Only interactive parts ship JavaScript
- SEO - Content is static HTML (indexed by search engines)
- Progressive enhancement - Works without JavaScript
Server vs Client Components
Server Components (Hono JSX)
Where: app/components/, app/routes/
Rendered: On server (every request)
JSX Import Source: hono/jsx
Capabilities: Presentation only (no state, no hooks)
// app/components/DocsHeader.tsx
import type { FC } from 'hono/jsx';
export const DocsHeader: FC = () => {
return (
<header>
<nav>
<a href="/">Home</a>
<a href="/docs">Docs</a>
</nav>
</header>
);
};
Characteristics:
- ✅ Fast rendering (HTML strings)
- ✅ No JavaScript sent to browser
- ✅ Access to Cloudflare bindings (R2, AI)
- ❌ No
useState,useEffect, hooks - ❌ No event handlers (onClick, onChange)
- ❌ No browser APIs (localStorage, fetch)
Client Components (React Islands)
Where: app/islands/
Rendered: In browser (client-side only)
JSX Import Source: react
Capabilities: Full React with hooks, state, effects
// app/islands/ThemeSwitcher.tsx
import { useState, useEffect } from 'react';
export default function ThemeSwitcher() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
useEffect(() => {
document.documentElement.className = theme;
localStorage.setItem('theme', theme);
}, [theme]);
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
Characteristics:
- ✅ Full React hooks (useState, useEffect, etc.)
- ✅ Event handlers
- ✅ Browser APIs
- ✅ State management
- ❌ No access to Cloudflare bindings
- ❌ Larger bundle size (ships to browser)
JSX Import Sources
This project uses two different JSX import sources:
Default: Hono JSX (Server)
// tsconfig.json
{
"compilerOptions": {
"jsxImportSource": "hono/jsx" // ← Default for all .tsx files
}
}
All files use Hono JSX by default.
Override: React JSX (Islands)
Islands override with a pragma comment:
// app/islands/Search.tsx
/** @jsxImportSource react */
import { useState } from 'react';
export default function Search() {
const [query, setQuery] = useState('');
// ...
}
Without pragma comment, islands would try to use Hono JSX and fail.
Existing Islands
1. Search (app/islands/Search.tsx)
Purpose: Semantic search dialog with ⌘K shortcut
Features:
- Keyboard shortcut:
Cmd+K/Ctrl+K - Debounced API calls
- Search result rendering
- Click-outside to close
State:
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
Key logic:
// Keyboard shortcut
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
// Debounced search
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, limit: 10 }),
});
const data = await response.json();
setResults(data.results || []);
setLoading(false);
}, 300);
return () => clearTimeout(timer);
}, [query]);
2. ThemeSwitcher (app/islands/ThemeSwitcher.tsx)
Purpose: Toggle between light/dark themes
Features:
- Reads from localStorage on mount
- Updates DOM class on change
- Persists preference
State:
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
Key logic:
// Load saved theme
useEffect(() => {
const saved = localStorage.getItem('theme') as 'light' | 'dark' | null;
if (saved) setTheme(saved);
}, []);
// Apply theme
useEffect(() => {
document.documentElement.className = theme;
localStorage.setItem('theme', theme);
}, [theme]);
3. DocsToc (app/islands/DocsToc.tsx)
Purpose: Table of contents with active section highlighting
Features:
- Extracts headings from page
- Highlights current section
- Smooth scroll to sections
State:
const [headings, setHeadings] = useState<{ id: string; text: string; level: number }[]>([]);
const [activeId, setActiveId] = useState<string>('');
Key logic:
// Extract headings from page
useEffect(() => {
const elements = document.querySelectorAll('h2, h3');
const extracted = Array.from(elements).map((el) => ({
id: el.id,
text: el.textContent || '',
level: Number(el.tagName[1]),
}));
setHeadings(extracted);
}, []);
// Intersection Observer for active section
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ rootMargin: '-80px 0px -80% 0px' }
);
headings.forEach(({ id }) => {
const element = document.getElementById(id);
if (element) observer.observe(element);
});
return () => observer.disconnect();
}, [headings]);
Hydration Process
Islands are mounted client-side using createRoot (not hydrateRoot):
1. Server Renders Placeholder
// app/routes/index.tsx (server)
export default createRoute(async (c) => {
return c.html(
<html>
<body>
{/* Placeholder for Search island */}
<div
id="search-island"
data-hydrate="true"
data-component="Search"
data-props="{}"
/>
{/* Load client bundle */}
<script type="module" src="/client.123456789.js" />
</body>
</html>
);
});
2. Client Mounts Islands
// app/client.tsx
import { createRoot } from 'react-dom/client';
import Search from './islands/Search';
import ThemeSwitcher from './islands/ThemeSwitcher';
import DocsToc from './islands/DocsToc';
const islands = {
Search,
ThemeSwitcher,
DocsToc,
};
document.addEventListener('DOMContentLoaded', () => {
const elements = document.querySelectorAll('[data-hydrate="true"]');
for (const element of elements) {
const componentName = element.getAttribute('data-component');
const propsStr = element.getAttribute('data-props');
if (!componentName || !(componentName in islands)) continue;
const Component = islands[componentName as keyof typeof islands];
const props = propsStr ? JSON.parse(propsStr) : {};
// Mount island
const root = createRoot(element);
root.render(<Component {...props} />);
}
});
Why createRoot not hydrateRoot?
- Islands aren't pre-rendered on server
- Server only renders empty
<div>placeholders - Client mounts React from scratch
Creating a New Island
Step 1: Create Island Component
// app/islands/Counter.tsx
/** @jsxImportSource react */
import { useState } from 'react';
interface CounterProps {
initialValue?: number;
}
export default function Counter({ initialValue = 0 }: CounterProps) {
const [count, setCount] = useState(initialValue);
return (
<div className="flex items-center gap-4">
<button
onClick={() => setCount(count - 1)}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
-
</button>
<span className="text-2xl font-bold">{count}</span>
<button
onClick={() => setCount(count + 1)}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
+
</button>
</div>
);
}
Important:
- ✅ Default export (required for dynamic import)
- ✅
/** @jsxImportSource react */pragma - ✅ Props interface for type safety
- ✅ Use React hooks freely
Step 2: Register in client.tsx
// app/client.tsx
import Counter from './islands/Counter'; // ← Add import
const islands = {
Search,
ThemeSwitcher,
DocsToc,
Counter, // ← Add to registry
};
Step 3: Use in Route
// app/routes/index.tsx (server)
export default createRoute(async (c) => {
return c.html(
<html>
<body>
<h1>Counter Demo</h1>
{/* Render island placeholder */}
<div
id="counter-island"
data-hydrate="true"
data-component="Counter"
data-props='{"initialValue": 10}'
/>
<script type="module" src="/client.123456789.js" />
</body>
</html>
);
});
Props serialization:
- Must be JSON-serializable
- Use
JSON.stringify()for objects - Simple values work:
'{"count": 5}' - Complex values need serialization
Step 4: Test
- Build:
pnpm build - Deploy:
pnpm dev:remote - Check browser DevTools console for errors
- Verify island mounts and is interactive
Advanced Patterns
Passing Complex Props
For non-serializable data, fetch in island:
// ❌ Bad - Can't serialize functions/Dates
<div
data-props='{"onClick": () => {}, "date": new Date()}'
/>
// ✅ Good - Pass IDs, fetch in island
<div
data-props='{"articleId": "intro"}'
/>
// app/islands/ArticlePreview.tsx
export default function ArticlePreview({ articleId }: { articleId: string }) {
const [article, setArticle] = useState(null);
useEffect(() => {
fetch(`/api/articles/${articleId}`)
.then(r => r.json())
.then(setArticle);
}, [articleId]);
return <div>{article?.title}</div>;
}
Communicating Between Islands
Use browser events for cross-island communication:
// Island 1: Dispatch event
function ThemeSelector() {
const setTheme = (theme: string) => {
document.dispatchEvent(new CustomEvent('theme-change', { detail: { theme } }));
};
return <button onClick={() => setTheme('dark')}>Dark</button>;
}
// Island 2: Listen to event
function ThemeDisplay() {
const [theme, setTheme] = useState('light');
useEffect(() => {
const handler = (e: CustomEvent) => setTheme(e.detail.theme);
document.addEventListener('theme-change', handler);
return () => document.removeEventListener('theme-change', handler);
}, []);
return <div>Current theme: {theme}</div>;
}
Lazy Loading Islands
For heavy islands, lazy load only when needed:
// app/islands/HeavyChart.tsx
/** @jsxImportSource react */
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart'));
export default function HeavyChart() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<Chart />
</Suspense>
);
}
Portal Islands
Render islands in different parts of the DOM:
import { createPortal } from 'react-dom';
export default function Modal({ children }) {
const [container] = useState(() => document.getElementById('modal-root'));
if (!container) return null;
return createPortal(
<div className="modal">{children}</div>,
container
);
}
UI Component Library
This project uses shadcn/ui components adapted for React islands.
Example: Command Dialog
// app/islands/ui/dialog.tsx
/** @jsxImportSource react */
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogContent = ({ className, children, ...props }) => (
<DialogPortal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
<DialogPrimitive.Content
className={cn(
'fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%]',
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
);
export { Dialog, DialogTrigger, DialogContent };
Import in islands:
// app/islands/Search.tsx
import { Dialog, DialogContent } from './ui/dialog';
import { Command, CommandInput, CommandList } from './ui/command';
export default function Search() {
return (
<Dialog>
<DialogContent>
<Command>
<CommandInput placeholder="Search docs..." />
<CommandList>{/* Results */}</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
Performance Optimization
Bundle Size
Keep island bundles small:
# Check client bundle size
ls -lh dist/static/client.*.js
# Typical sizes:
# • Good: 50-150 KB (gzipped)
# • Acceptable: 150-300 KB
# • Too large: > 300 KB
Reduce bundle size:
- Avoid large dependencies in islands
- Use lazy loading for heavy components
- Code split with dynamic imports
- Tree shake unused code
Minimize Islands
Only use islands when necessary:
// ❌ Unnecessary island - could be server component
export default function StaticButton() {
return <button>Click me</button>;
}
// ✅ Good use of island - needs state
export default function ToggleButton() {
const [active, setActive] = useState(false);
return <button onClick={() => setActive(!active)}>{active ? 'On' : 'Off'}</button>;
}
Guidelines:
- Static content → Server component (Hono JSX)
- Interactive element → Island (React)
- Forms with validation → Island
- Simple links/buttons → Server component
Debounce Effects
Debounce expensive operations:
import { useState, useEffect } from 'react';
export default function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
// Debounce API calls
const timer = setTimeout(async () => {
if (!query) return;
const response = await fetch('/api/search', {
method: 'POST',
body: JSON.stringify({ query }),
});
const data = await response.json();
setResults(data.results);
}, 300); // ← Wait 300ms after user stops typing
return () => clearTimeout(timer);
}, [query]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Troubleshooting
Island Not Mounting
Symptoms:
- Island doesn't appear
- No errors in console
Check:
- data-hydrate attribute:
<div data-hydrate="true" ...> - data-component matches: Component name in
islandsregistry - Client bundle loading: Check Network tab for
client.*.js - Console errors: Any JavaScript errors?
// Debug: Add logging to client.tsx
console.log('Hydrating islands...');
console.log('Found elements:', elements.length);
console.log('Component name:', componentName);
console.log('Component exists:', componentName in islands);
JSX Import Source Errors
Symptom:
Property 'useState' does not exist on type 'typeof import("hono/jsx")'
Fix: Add pragma comment to island:
/** @jsxImportSource react */
import { useState } from 'react';
Props Not Passing
Symptom: Island receives empty props
Check:
- JSON valid: Use
JSON.stringify()for objects - Quotes escaped:
data-props='{"key":"value"}'(single quotes outside, double inside) - Props parsed: Add logging in island:
export default function MyIsland(props: any) {
console.log('Received props:', props); // ← Debug
return <div>{JSON.stringify(props)}</div>;
}
Hydration Mismatch
Symptom:
Warning: Text content did not match. Server: "..." Client: "..."
Cause: Using hydrateRoot instead of createRoot
Fix: Use createRoot in app/client.tsx:
import { createRoot } from 'react-dom/client'; // ← Not hydrateRoot
const root = createRoot(element);
root.render(<Component {...props} />);
Event Handlers Not Working
Symptom: onClick, onChange don't fire
Check:
- Island mounted: Use React DevTools to verify
- Event handler syntax:
onClick={() => ...}(camelCase) - Component is island: Not server component
Best Practices
1. Default Export for Islands
Always use default export:
// ✅ Good
export default function MyIsland() {
return <div>Island</div>;
}
// ❌ Bad - won't work with dynamic import
export function MyIsland() {
return <div>Island</div>;
}
2. Type Props
Define interfaces for all props:
interface SearchProps {
placeholder?: string;
maxResults?: number;
}
export default function Search({ placeholder = 'Search...', maxResults = 10 }: SearchProps) {
// ...
}
3. Clean Up Effects
Always return cleanup function:
useEffect(() => {
const handler = () => console.log('resize');
window.addEventListener('resize', handler);
// ← Cleanup required!
return () => window.removeEventListener('resize', handler);
}, []);
4. Use Semantic HTML
Islands should be accessible:
// ✅ Good - semantic, accessible
export default function Dialog() {
return (
<dialog role="dialog" aria-labelledby="title">
<h2 id="title">Title</h2>
</dialog>
);
}
// ❌ Bad - div soup
export default function Dialog() {
return (
<div>
<div>Title</div>
</div>
);
}
5. Minimize Re-renders
Use useMemo and useCallback for expensive operations:
import { useMemo, useCallback } from 'react';
export default function ExpensiveList({ items }) {
// Memoize filtered list
const filtered = useMemo(
() => items.filter(item => item.active),
[items]
);
// Memoize callback
const handleClick = useCallback(
(id: string) => console.log(id),
[]
);
return <ul>{filtered.map(item => <li onClick={() => handleClick(item.id)}>{item.name}</li>)}</ul>;
}
Resources
- React Docs: react.dev
- Islands Architecture: jasonformat.com/islands-architecture
- Radix UI: radix-ui.com
- shadcn/ui: ui.shadcn.com
Learn More
- HonoX Framework - Server-side rendering
- R2 Storage - Edge storage and caching
- Semantic Search - AI-powered search island