Hydration
This project uses HonoX's Islands Hydration architecture and @hono/vite-ssg for Static Site Generation of pages, emitting static HTML by default, and only components that genuinely need client-side interactivity are "promoted" to islands (client JS snippets).
The Core Predicate
app/components/ui/island-utils.ts:
/**
* Decide whether a component should hydrate as a client-side island.
*
* @param interactive - the component's `interactive` prop (boolean | undefined)
* @param hasSignal - whether the component carries a "behaviour signal": an event
* handler (onClick / onValueChange …) or a controlled/default
* state (value / checked / open …) that only makes sense with JS.
*
* Semantics:
* - interactive === false → never hydrate (explicit opt-out)
* - interactive === true → always hydrate (explicit opt-in)
* - interactive omitted → hydrate iff hasSignal is true
*/
export function shouldHydrate(interactive: unknown, hasSignal: boolean): boolean {
return interactive !== false && Boolean(interactive || hasSignal);
}
Truth table
interactive | hasSignal | Result | Meaning |
|---|---|---|---|
false | any | false | Explicitly forbidden to hydrate (pure static) |
true | any | true | Explicitly forced to hydrate |
undefined | true | true | Smart-detect: signal present → hydrate |
undefined | false | false | Smart-detect: no signal → static |
The 3-Tier Model
Tier-1 — Auto-interactive
Core rule:
shouldHydrate(interactive, true)
These components are interaction — their entire value depends on client JS
(overlays, modals, drag handles, expand/collapse). They hydrate
unless the caller explicitly passes interactive={false}.
Applies to:
- Overlay / popover families (tooltip, hover-card, popover, menu)
- Modals / drawers / drag (dialog, drawer, splitter)
- Expand / collapse (collapsible)
- Pure client singletons (toast)
Tier-2 — Smart auto-detect
Core rule:
shouldHydrate(interactive, hasSignal)
These components are static by default, interactive only when a signal is present.
They are controlled/uncontrolled form controls or selectable groups: hydration only
matters when state (value / checked / defaultValue) or a handler
(onChange / onClick …) is supplied; otherwise static markup is enough.
Applies to:
- Form controls (button, checkbox, switch, textarea, field, slider, combobox, radio-group)
- Selectable groups (segment-group, toggle-group)
- Tables with row clicks (table)
- Avatar with a
src(the async image load / error lifecycle is a client-only cue) - Pagination / tags-input (state + handlers; a
type="link"pagination that supplies
getPageUrl is pure navigation and stays static)
Tier-3 — Presentational
Never mounts an island
Pure typographic / decorative components with no client behaviour. They must not declare
aninteractive prop (historically badge / heading / text / fieldset mistakenly
declared it and leaked the attribute onto the DOM — now removed).
Applies to:
- Typography (text, heading, badge)
- Layout (group, absolute-center, fieldset)
- Status indicators (alert, breadcrumb, loader, skeleton, spinner, progress)
- Graphics (icon)
Full Component Classification
Status legend:
✅conforms to the convention;⚠️diverges from the convention and needs migration (see Section 7). After the latest cleanup pass, all components are✅.
Tier-1 (auto-interactive)
| Component | Rule | Trigger | Status |
|---|---|---|---|
dialog | shouldHydrate(interactive, true) | Always hydrates unless interactive={false} | ✅ dialog.tsx |
drawer | shouldHydrate(interactive, true) | Always hydrates unless interactive={false} | ✅ drawer.tsx |
splitter | shouldHydrate(interactive, true) | Always hydrates unless interactive={false} | ✅ splitter.tsx |
tooltip | shouldHydrate(interactive, true) | Always hydrates | ✅ tooltip.tsx |
hover-card | shouldHydrate(interactive, true) | Always hydrates | ✅ hover-card.tsx |
popover | shouldHydrate(interactive, true) | Always hydrates | ✅ popover.tsx |
menu | shouldHydrate(interactive, true) | Always hydrates | ✅ menu.tsx |
select | shouldHydrate(interactive, true) | Always hydrates — opening the dropdown and selecting an item require JS; there is no static fallback (the native <select> is visually hidden and exists only for form submission) | ✅ select.tsx (Tier-1) |
collapsible | shouldHydrate(interactive, true) | Always hydrates (expand/collapse needs JS) | ✅ collapsible.tsx (Tier-1) |
toast | Always island (client singleton) | No prop, always an island | ✅ toast.tsx |
Tier-2 (smart auto-detect)
| Component | Behaviour signal (hasSignal is true when…) | Status |
|---|---|---|
button | onClick / onPointerDown / onSubmit | ✅ button.tsx |
card | onClick / onPointerDown | ✅ card.tsx |
table | any row.onClick | ✅ table.tsx |
segment-group | value / defaultValue / onValueChange | ✅ segment-group.tsx |
toggle-group | value / defaultValue / onValueChange | ✅ toggle-group.tsx |
slider | value / defaultValue / onChange / onDraggingChange | ✅ slider.tsx |
checkbox | checked / defaultChecked / onCheckedChange | ✅ checkbox.tsx |
switch | checked / defaultChecked / onCheckedChange | ✅ switch.tsx |
textarea | value / defaultValue / onValueChange / validator / minLength | ✅ textarea.tsx |
field | value / defaultValue / onValueChange / validator / minLength | ✅ field.tsx |
combobox | open / inputValue / onToggle / onInputChange / onItemSelect | ✅ combobox.tsx |
radio-group | value / defaultValue / onValueChange | ✅ radio-group.tsx |
avatar | src (async image load / error lifecycle) | ✅ avatar.tsx (Tier-2) |
pagination | onPageChange, or non-link page / defaultPage / pageSize / defaultPageSize | ✅ pagination.tsx |
tags-input | onValueChange / onInputValueChange / value / inputValue / defaultValue / defaultInputValue | ✅ tags-input.tsx |
paginated-table | Always island (manages internal pagination state) | ✅ paginated-table.tsx (Tier-2 logic) |
date-picker | value / defaultValue / focusedValue / open / defaultOpen / onValueChange / onOpenChange / (keyboard/click/typing events) | ✅ date-picker.tsx |
color-picker | value / defaultValue / format / defaultFormat / open / defaultOpen / onValueChange / onFormatChange / onOpenChange / (pointer/keyboard/input events) | ✅ color-picker.tsx |
Tier-3 (presentational)
| Component | Notes | Status |
|---|---|---|
text | Typographic text | ✅ |
heading | Heading | ✅ |
badge | Badge | ✅ (dead interactive prop removed) |
fieldset | Form fieldset | ✅ (dead interactive prop removed) |
alert | Alert box | ✅ |
breadcrumb | Breadcrumb | ✅ |
group | Layout grouping | ✅ |
absolute-center | Centering layout | ✅ |
loader | Loading indicator | ✅ |
skeleton | Skeleton screen | ✅ |
spinner | Spinner indicator | ✅ |
progress | Progress bar (value-driven, static by default) | ✅ |
icon | SVG icon wrapper (size/color only, no client state) | ✅ icon.tsx |
Trigger Conditions per Tier
Tier-1 conditions
- The component's core interaction (opening an overlay, dragging a splitter, expand/collapse,
modal focus-trap) cannot be expressed in pure HTML, so hasSignal
defaults to true.
- The only legal opt-out is
interactive={false}(e.g. force-disabling an overlay inside a
purely static document).
toastis special: it is a global client singleton (toaster.create(...)), and does not
expose an interactive prop.
Tier-2 conditions
Each component's hasSignal is a boolean OR over "is this prop defined?":
// Typical pattern (segment-group shown)
const hasSignal =
rest.value !== undefined ||
rest.defaultValue !== undefined ||
rest.onValueChange !== undefined;
if (shouldHydrate(interactive, hasSignal)) return <SegmentGroupIsland {...rest} />;
return <Root {...rest}>{/* static structure */}</Root>;
Decision principles:
- Controlled state (
value/checked/open/inputValue) → needs JS to stay in sync. - Uncontrolled initial value (
defaultValue/defaultChecked) → needs JS to hold internal state. - Event handlers (
onChange/onClick/onValueChange/onItemSelect…) → needs JS to respond. - Validation / constraints (
validator/minLength) → needs JS to execute. - Async / client-only cues —
srconavatar(implies a load/error lifecycle),
or any prop whose only purpose is a client-side effect (media, intersection, lazy
loading). These cannot resolve without JS, so they count as a signal.
- Any one of the above being present makes
hasSignaltrue, which triggers hydration;
if all are absent, the component renders as pure static markup.
avataris special among Tier-2 components: its signal is the async-load cuesrc. Whensrcis present the image needs client-side load/error handling, soshouldHydrate(interactive, Boolean(src))hydrates it; anavatarwith nosrc(e.g. a initials fallback) stays static. An explicitinteractive={false}suppresses hydration even whensrcexists (consistent with the library-wide "falsewins" semantics).
paginationlink-mode exception: atype="link"pagination that suppliesgetPageUrlis pure navigation (each page is an anchor), so it stays static unless an explicitonPageChangehandler is supplied. Only in button mode (or withonPageChange) do thepage/defaultPage/pageSize/defaultPageSizeprops count as signals.
Tier-3 conditions
- The component holds no client state and responds to no events.
- It does not declare an
interactiveprop. (Historicallybadge/heading/text/
fieldset wrongly declared it and leaked interactive="true" onto the DOM; that has
been removed in cleanup.)
Decision Checklist for New Components
Walk the list in order; stop at the first match:
- Does its existence depend entirely on client JS?
Overlay / modal / drag / expand-collapse → Tier-1, use
shouldHydrate(interactive, true).
- Is it a form control or a visually-selectable component that may be controlled or
\\ uncontrolled?\\
button / checkbox / switch / slider / combobox / row-click table … → Tier-2,
define hasSignal (state + handlers) then call shouldHydrate(interactive, hasSignal).
- Is it purely typographic / layout / decorative?
text / heading / alert / group / progress … → Tier-3, no interactive prop, no island.
- No component may write a bare
if (interactive) { … }branch; always go throughshouldHydrate. interactiveis only a "knob":trueforces,falseforbids,undefineddefers tohasSignal.- Every Tier-1 / Tier-2 component should add a
# Hydrationsection to its
content/components/<Component>.mdx and cross-reference this file, and set
its frontmatter hydration field (1 / 2 / 3) to match.
Historical Cleanup Log (already fixed)
The following divergences were resolved during convention rollout; kept here for traceability:
| # | Component | Original divergence | Fix |
|---|---|---|---|
| 1 | splitter / dialog / drawer | Hardcoded interactive = true + if (interactive), bypassing shouldHydrate | Switched to shouldHydrate(interactive, true), restoring the interactive={false} opt-out |
| 2 | radio-group | interactive ? Island : Root, forcing callers to pass interactive | Switched to shouldHydrate(interactive, hasSignal), signals value / defaultValue / onValueChange |
| 3 | avatar | Ad-hoc \if (rest.src | | interactive)\ | Switched to shouldHydrate(interactive, Boolean(rest.src)), unified entry point |
| 4 | badge / heading / text / fieldset | Dead interactive prop declared, leaked onto the DOM via restProps (interactive="true") | Removed the interactive prop declaration |
| 5 | collapsible | Tier not documented explicitly | Added a # Hydration section to docs/Collapsible.md, marking it Tier-1 |
| 6 | tags-input | Bare if (isInteractive) branch, no interactive prop, no shouldHydrate, and defaultValue / defaultInputValue omitted from the signal set (an uncontrolled tags-input rendered static) | Switched to shouldHydrate(interactive, hasSignal), added the interactive knob, extended the signal set to include defaultValue / defaultInputValue |
| 7 | pagination / avatar | Missing from the tier tables (pagination absent entirely; avatar mis-classified as Tier-1) and pagination over-hydrated in link mode | Added pagination + tags-input to Tier-2; moved avatar to Tier-2 (load-cue signal); gated pagination link-mode so pure-navigation stays static |
Note: item 4 was a real bug —
badge/heading/text/fieldsetwould renderinteractiveas an invalid HTML attribute on the DOM; it was prioritised for repair.
Related Documentation
- UI Components Architecture — the project-level overview
app/components/ui/island-utils.ts— the single decision entry pointcontent/components/<Component>.mdx(each Tier-1 / Tier-2 component) — its own# Hydrationsection, plushydration/categoryfrontmatter