MenuChevron Down
Hydration - Docs - Artefact

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

interactivehasSignalResultMeaning
falseanyfalseExplicitly forbidden to hydrate (pure static)
trueanytrueExplicitly forced to hydrate
undefinedtruetrueSmart-detect: signal present → hydrate
undefinedfalsefalseSmart-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

an interactive 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)

ComponentRuleTriggerStatus
dialogshouldHydrate(interactive, true)Always hydrates unless interactive={false}dialog.tsx
drawershouldHydrate(interactive, true)Always hydrates unless interactive={false}drawer.tsx
splittershouldHydrate(interactive, true)Always hydrates unless interactive={false}splitter.tsx
tooltipshouldHydrate(interactive, true)Always hydratestooltip.tsx
hover-cardshouldHydrate(interactive, true)Always hydrateshover-card.tsx
popovershouldHydrate(interactive, true)Always hydratespopover.tsx
menushouldHydrate(interactive, true)Always hydratesmenu.tsx
selectshouldHydrate(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)
collapsibleshouldHydrate(interactive, true)Always hydrates (expand/collapse needs JS)collapsible.tsx (Tier-1)
toastAlways island (client singleton)No prop, always an islandtoast.tsx

Tier-2 (smart auto-detect)

ComponentBehaviour signal (hasSignal is true when…)Status
buttononClick / onPointerDown / onSubmitbutton.tsx
cardonClick / onPointerDowncard.tsx
tableany row.onClicktable.tsx
segment-groupvalue / defaultValue / onValueChangesegment-group.tsx
toggle-groupvalue / defaultValue / onValueChangetoggle-group.tsx
slidervalue / defaultValue / onChange / onDraggingChangeslider.tsx
checkboxchecked / defaultChecked / onCheckedChangecheckbox.tsx
switchchecked / defaultChecked / onCheckedChangeswitch.tsx
textareavalue / defaultValue / onValueChange / validator / minLengthtextarea.tsx
fieldvalue / defaultValue / onValueChange / validator / minLengthfield.tsx
comboboxopen / inputValue / onToggle / onInputChange / onItemSelectcombobox.tsx
radio-groupvalue / defaultValue / onValueChangeradio-group.tsx
avatarsrc (async image load / error lifecycle)avatar.tsx (Tier-2)
paginationonPageChange, or non-link page / defaultPage / pageSize / defaultPageSizepagination.tsx
tags-inputonValueChange / onInputValueChange / value / inputValue / defaultValue / defaultInputValuetags-input.tsx
paginated-tableAlways island (manages internal pagination state)paginated-table.tsx (Tier-2 logic)
date-pickervalue / defaultValue / focusedValue / open / defaultOpen / onValueChange / onOpenChange / (keyboard/click/typing events)date-picker.tsx
color-pickervalue / defaultValue / format / defaultFormat / open / defaultOpen / onValueChange / onFormatChange / onOpenChange / (pointer/keyboard/input events)color-picker.tsx

Tier-3 (presentational)

ComponentNotesStatus
textTypographic text
headingHeading
badgeBadge✅ (dead interactive prop removed)
fieldsetForm fieldset✅ (dead interactive prop removed)
alertAlert box
breadcrumbBreadcrumb
groupLayout grouping
absolute-centerCentering layout
loaderLoading indicator
skeletonSkeleton screen
spinnerSpinner indicator
progressProgress bar (value-driven, static by default)
iconSVG 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).

  • toast is 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:

  1. Controlled state (value / checked / open / inputValue) → needs JS to stay in sync.
  2. Uncontrolled initial value (defaultValue / defaultChecked) → needs JS to hold internal state.
  3. Event handlers (onChange / onClick / onValueChange / onItemSelect …) → needs JS to respond.
  4. Validation / constraints (validator / minLength) → needs JS to execute.
  5. Async / client-only cuessrc on avatar (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.

  1. Any one of the above being present makes hasSignal true, which triggers hydration;

if all are absent, the component renders as pure static markup.

avatar is special among Tier-2 components: its signal is the async-load cue src. When src is present the image needs client-side load/error handling, so shouldHydrate(interactive, Boolean(src)) hydrates it; an avatar with no src (e.g. a initials fallback) stays static. An explicit interactive={false} suppresses hydration even when src exists (consistent with the library-wide "false wins" semantics).

pagination link-mode exception: a type="link" pagination that supplies getPageUrl is pure navigation (each page is an anchor), so it stays static unless an explicit onPageChange handler is supplied. Only in button mode (or with onPageChange) do the page / defaultPage / pageSize / defaultPageSize props count as signals.

Tier-3 conditions

  • The component holds no client state and responds to no events.
  • It does not declare an interactive prop. (Historically badge / 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:

  1. Does its existence depend entirely on client JS?

Overlay / modal / drag / expand-collapse → Tier-1, use

shouldHydrate(interactive, true).
  1. 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).

  1. Is it purely typographic / layout / decorative?

text / heading / alert / group / progress … → Tier-3, no interactive prop, no island.

Hard implementation requirements:
  • No component may write a bare if (interactive) { … } branch; always go through shouldHydrate.
  • interactive is only a "knob": true forces, false forbids, undefined defers to hasSignal.
  • Every Tier-1 / Tier-2 component should add a # Hydration section 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:

#ComponentOriginal divergenceFix
1splitter / dialog / drawerHardcoded interactive = true + if (interactive), bypassing shouldHydrateSwitched to shouldHydrate(interactive, true), restoring the interactive={false} opt-out
2radio-groupinteractive ? Island : Root, forcing callers to pass interactiveSwitched 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 / fieldset would render interactive as 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 point
  • content/components/<Component>.mdx (each Tier-1 / Tier-2 component) — its own # Hydration section, plus hydration/category frontmatter