Islands & Components
| Field | Value |
|---|---|
| Type | Agent Reference |
| Source | ~/.copilot/agents/_refs/astro/islands-and-components.md |
| Description | Not specified |
Source Content
Islands & Components
Island Hydration Directives
The default is zero JavaScript. Add a client:* directive only when the component genuinely needs client-side interactivity. Every directive is a deliberate decision.
| Directive | When the JS loads | When to use |
|---|---|---|
| (none) | Never | Static HTML — no interactivity needed |
client:load | Immediately on page load | Immediately interactive above-the-fold content |
client:idle | When the browser is idle | Non-critical interactive content |
client:visible | When the component enters the viewport | Below-the-fold interactive content |
client:media="(query)" | When a CSS media query matches | Interaction only needed at certain viewport sizes |
client:only="react" | Client-only render | Components that cannot SSR (charts, maps, 3rd-party widgets) |
Decision rule: Start with no directive. Add the least aggressive one that solves the actual need.
---import { CaseList } from '../components/CaseList'import { FilterPanel } from '../components/FilterPanel'import { ActivityChart } from '../components/ActivityChart'import { NotificationBell } from '../components/NotificationBell'---
<!-- Static — no hydration needed --><CaseList cases={cases} />
<!-- Immediately interactive — user can click right away --><NotificationBell client:load />
<!-- Below the fold — wait until visible --><FilterPanel client:visible />
<!-- Non-critical — hydrate during idle time --><ActivityChart client:idle />
<!-- Can't SSR — e.g., depends on window --><MapWidget client:only="react" />Passing Data to Islands
Islands receive props from Astro frontmatter. Props must be serializable (JSON-safe). No functions, no class instances, no non-serializable objects.
---import { CaseDetail } from '../components/CaseDetail'import { getCase } from '../lib/db'
const { id } = Astro.paramsconst case_ = await getCase(id)
// Transform to a serializable shape before passingconst caseProps = { id: case_.id, title: case_.title, type: case_.type, filedAt: case_.filedAt.toISOString(), // Dates → strings // clientId only, not the full client object clientId: case_.client.id, clientName: case_.client.name,}---
<CaseDetail client:load case={caseProps} />Rules:
- Dates are always strings (ISO 8601) at the Astro→React boundary.
- Never pass database model instances as props — serialize to a plain object first.
- If the island needs more data, it fetches via TanStack Query using the IDs passed as props.
Smart vs Dumb Islands
Apply the same dumb/smart split from the React agent — but the “smart” layer can be Astro fetching + React displaying:
---// Astro does the SSR data fetch (smart)const cases = await db.cases.findMany({ where: { userId: locals.user.id } })---
<!-- React island is dumb — receives pre-fetched data --><CaseTable client:load cases={cases.map(serialize)} />For client-side dynamic data (filters, pagination, real-time), the React island uses TanStack Query:
// CaseTable.tsx — starts from SSR data, then fetches dynamicallyinterface CaseTableProps { initialCases: SerializedCase[] // pre-fetched by Astro}
function CaseTable({ initialCases }: CaseTableProps) { const [filters, setFilters] = useState({ page: 1, q: '' })
const { data: cases } = useQuery({ queryKey: ['cases', filters], queryFn: () => fetchCases(filters), initialData: filters.page === 1 && !filters.q ? initialCases : undefined, }) // ...}Sharing State Between Islands
Islands are isolated by default. For state shared between multiple islands on a page, use one of:
- Nano Stores (
nanostores) — tiny reactive store, works across frameworks:
import { atom } from 'nanostores'export const notificationCount = atom(0)
// In any islandimport { useStore } from '@nanostores/react'import { notificationCount } from '../stores/notifications'
function NotificationBell() { const count = useStore(notificationCount) // ...}-
URL params — for navigational state, use
window.locationor TanStack Router if the app is SPA-mode. -
Custom events — for simple cross-island communication:
// Dispatch from island Awindow.dispatchEvent(new CustomEvent('case-created', { detail: { id: newCase.id } }))
// Listen in island BuseEffect(() => { const handler = (e: CustomEvent) => refetch() window.addEventListener('case-created', handler) return () => window.removeEventListener('case-created', handler)}, [])Avoid Zustand for cross-island state — it hydrates per island. Use Nano Stores.
Astro Components
.astro files are not React. They’re server-only templates. Write them like HTML with a frontmatter script block:
---// This runs on the server only — no browser APIsimport type { GetStaticPaths } from 'astro'import { Layout } from '../layouts/Layout.astro'import { CaseDetail } from '../components/CaseDetail'import { getCaseById } from '../lib/cases'import { z } from 'zod'
interface Props { caseId: string}
const { caseId } = Astro.params
// Validate paramsconst result = z.string().uuid().safeParse(caseId)if (!result.success) { return Astro.redirect('/cases')}
const case_ = await getCaseById(result.data, Astro.locals.user.id)if (!case_) { return Astro.redirect('/cases')}---
<Layout title={case_.title}> <main> <CaseDetail client:load case={serialize(case_)} /> </main></Layout>Astro component rules:
- No hooks, no
useState, nouseEffect— those are React-only. ---frontmatter is server-only. Don’t put API keys in client-visible scope.- Use
Astro.localsfor user/session data set by middleware. - Use
Astro.redirect()for navigation — it returns aResponse. <slot />is how Astro layouts accept children (equivalent to Reactchildren).