Advanced Islands
Deep dive into dynamic islands patterns with prestruct.
How Islands Work
Islands punch holes through prerendered HTML for client-only content. The static HTML renders with fallback content (visible to crawlers), then React hydrates and replaces it.
Build Time Client Time
──────────── ───────────
<pre-island>Hello</pre-island> → <pre-island>Hello</pre-island>
(static, visible to crawlers) (replaced by React component)
Basic Setup
1. Register in AppIslands.jsx
// src/AppIslands.jsx
import CartWidget from './islands/CartWidget.jsx'
import RecentlyViewed from './islands/RecentlyViewed.jsx'
export const islands = {
'cart-widget': CartWidget,
'recently-viewed': RecentlyViewed,
}
2. Use in Your Components
// In any page component
export default function Shop() {
return (
<div>
<h1>Shop</h1>
<pre-island data-pre-island="cart-widget">
<span className="loading">Loading cart...</span>
</pre-island>
</div>
)
}
3. Mount in AppLayout
// src/AppLayout.jsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { mountIslands } from './islands.js'
import { islands } from './AppIslands.jsx'
export default function AppLayout() {
const { pathname } = useLocation()
useEffect(() => {
// Mount islands after each navigation
const timer = setTimeout(() => mountIslands(islands), 0)
return () => clearTimeout(timer)
}, [pathname])
return (
// ... your routes
)
}
Load Strategies
eager (default)
Mounts immediately after hydration. Use for above-fold content.
<pre-island data-pre-island="header-cart" />
visible
Mounts when element enters viewport via IntersectionObserver.
<pre-island data-pre-island="footer-newsletter" data-pre-load="visible">
<div className="skeleton" />
</pre-island>
idle
Mounts during browser idle time. Best for non-critical content.
<pre-island data-pre-island="chat-widget" data-pre-load="idle" />
Island Component Patterns
Reading Data from localStorage
// src/islands/CartWidget.jsx
import { useState, useEffect } from 'react'
export default function CartWidget() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// Read from localStorage on mount
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
setItems(cart)
setLoading(false)
}, [])
if (loading) {
return <div className="cart-skeleton">Loading...</div>
}
return (
<div className="cart-widget">
<span>{items.length} items</span>
<span>${items.reduce((sum, i) => sum + i.price, 0)}</span>
</div>
)
}
Fetching Data
// src/islands/RecentlyViewed.jsx
import { useState, useEffect } from 'react'
export default function RecentlyViewed() {
const [products, setProducts] = useState([])
useEffect(() => {
fetch('/api/recently-viewed')
.then(r => r.json())
.then(setProducts)
.catch(() => setProducts([]))
}, [])
if (!products.length) return null
return (
<div className="recently-viewed">
{products.map(p => (
<div key={p.id}>{p.name}</div>
))}
</div>
)
}
Using a Global Store
// If using Zustand, Redux, etc.
import { useStore } from './store.js'
export default function UserMenu() {
const user = useStore(s => s.user)
if (!user) {
return <a href="/login">Login</a>
}
return <span>Welcome, {user.name}</span>
}
Multiple Islands
You can use multiple islands on a single page:
export default function ProductPage() {
return (
<div>
<header>
<pre-island data-pre-island="header-cart" />
</header>
<main>
<h1>Product Name</h1>
{/* Product content */}
</main>
<aside>
<pre-island data-pre-island="recently-viewed" data-pre-load="visible" />
<pre-island data-pre-island="recommendations" data-pre-load="idle" />
</aside>
</div>
)
}
Islands with State Sharing
Since islands are independent React roots, they can’t share state directly. Options:
1. Custom Events
// In cart island
useEffect(() => {
const handleUpdate = (e) => setCount(e.detail.count)
window.addEventListener('cart-update', handleUpdate)
return () => window.removeEventListener('cart-update', handleUpdate)
}, [])
// In product island
const addToCart = (item) => {
// ... add to localStorage
window.dispatchEvent(new CustomEvent('cart-update', {
detail: { count: newCount }
}))
}
2. BroadcastChannel
const channel = new BroadcastChannel('cart_channel')
// In one island
channel.postMessage({ type: 'ADD_ITEM', item })
// In another
channel.onmessage = (e) => {
if (e.data.type === 'ADD_ITEM') {
// Update UI
}
}
3. polling localStorage
// Simple but works
useEffect(() => {
const interval = setInterval(() => {
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
setCartCount(cart.length)
}, 1000)
return () => clearInterval(interval)
}, [])
Conditional Islands
Use conditional rendering to show islands only when needed:
{showCart && (
<pre-island data-pre-island="cart-widget">
<LoadingSkeleton />
</pre-island>
)}
##岛在 SSR
Islands have special behavior during prerendering:
<pre-island>passes through - React’srenderToStringtreats it as unknown element- Fallback content renders - Whatever is inside shows in static HTML
- Component never runs - Island JSX is not executed at build time
This means:
- ✅ Crawlers see fallback content (good for SEO)
- ✅ No private data in static HTML
- ✅ Islands load independently after hydration
Styling Islands
Since islands mount into empty elements, style the fallback too:
<pre-island data-pre-island="cart-widget">
<div className="cart-placeholder">
<span className="skeleton-line"></span>
<span className="skeleton-line short"></span>
</div>
</pre-island>
<style>{`
.cart-placeholder { padding: 1rem; }
.skeleton-line {
height: 1rem;
background: #eee;
margin-bottom: 0.5rem;
border-radius: 4px;
}
.short { width: 60%; }
`}</style>
Debugging Islands
Check if island mounted
// In your island component
useEffect(() => {
console.log('[island] mounted:', name)
}, [])
Verify elements exist
// In browser console
document.querySelectorAll('pre-island[data-pre-island]')
// Shows all island placeholders
// After mounting, they should have React-rendered content
document.querySelectorAll('.cart-widget')
Common Issues
Island not mounting:
- Check
data-pre-islandname matches registry key exactly - Verify
mountIslands(islands)is called in useEffect
Double mounting:
- Normal!
mountIslandsruns on every route change - The WeakSet in islands.js prevents actual double createRoot
FOUC on island:
- Add skeleton/loading fallback inside
<pre-island> - Use
data-pre-load="idle"for below-fold islands
Performance Tips
- Use
visiblefor below-fold - Don’t load until needed - Use
idlefor chat widgets - Low priority - Keep islands small - They’re separate React roots with overhead
- Skip islands for cached data - If data doesn’t change, render statically