Skip to content

The compound-component gotcha

Why Dialog and Tabs must live in one island.

This is the most common mistake when adding interactive components to an Astro project. Read it once and remember it.

The rule

Components that share state internally — Dialog, DropdownMenu, Tabs, Toast, controlled accordion — must be wrapped in a single React file and hydrated as one island. You cannot spread the parts across multiple islands or multiple client:* usages in the same .astro file.

Why

Astro’s partial hydration creates a separate React root for each client:* directive. State stored inside one React root is invisible to another. A Dialog.Root in island A cannot communicate its open state to Dialog.Content in island B — they are different React trees, even on the same page.

The wrong pattern

---
// BAD: do not do this
import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog';
---
<!-- These are in separate React roots — state is broken -->
<Dialog client:visible>
  <DialogTrigger client:visible>Open</DialogTrigger>
  <DialogContent client:visible>...</DialogContent>
</Dialog>

The correct pattern

Create one React file that owns the entire composition:

// src/components/islands/ShowcaseDialog.tsx
import { Button } from '@/components/ui/button';
import { Dialog, DialogTrigger, DialogContent,
         DialogHeader, DialogTitle, DialogDescription,
         DialogFooter, DialogClose } from '@/components/ui/dialog';

export default function ShowcaseDialog() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>
        Open dialog
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Example dialog</DialogTitle>
          <DialogDescription>Content goes here.</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose render={<Button />}>Confirm</DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Then hydrate the whole file as one island:

---
import ShowcaseDialog from '../components/islands/ShowcaseDialog';
---
<ShowcaseDialog client:visible />

What’s already following this pattern

ShowcaseDialog.tsx, ShowcaseTabs.tsx, ShowcaseDropdown.tsx, ShowcaseToast.tsx, and ShowcaseForm.tsx all live in src/components/islands/ and are hydrated as single islands.

Does not apply to

Stateless presentational components like Button, Card, Badge, Input in isolation — these can appear directly in .astro files without hydration if they need no interactivity.

Full guide → docs/COMPONENTS.md §4 · also see CLAUDE.md

Report an issue

Show diagnostics

Environment

 

Console errors

 

Failed requests