Interactive components
Every component renders native HTML that works from the server. A few also need the browser: Tabs switch panels, a searchable Select filters its list, a toggle chip flips. Each of those behaviours is written once, as a small DOM enhancer, and runs two ways.
Quick path
Section titled “Quick path”| Your app | What to do |
|---|---|
| React (Next.js, Vite) | Nothing. Render the component; its client part runs the enhancer after hydration. |
| No React (Astro, templates) | Render the HTML, then run the enhancer once in a page script (below). |
| Both on one page | Give each element one owner: scope the page script to the part React does not own. |
---import { Tabs, TabPanel } from '@zitdevs/ui';---
<Tabs label="Filter resources" items={items} /><TabPanel id="panel-all" tabId="tab-all" selected>…</TabPanel>
<script> import { enhanceTabs } from '@zitdevs/ui/enhance';
enhanceTabs(document);</script>@zitdevs/ui/enhance imports no React and no styling code: the Tabs enhancer is about 1 kB gzipped.
How it works
Section titled “How it works”server HTML (first state) ──► enhancer: listeners + the state attributes from then on ▲ ▲ a page's <script> ──┘ └── React: the component's `.client` part, enhanceTabs(document) in an effect after hydration- The server renders the first state — the selected tab and its panel — so the page reads right before any script, and without one.
- The enhancer takes over the state attributes (for Tabs:
aria-selectedandtabindexon the tabs,hiddenandaria-labelledbyon the panels). After the first render it is their only writer. - It is idempotent. It marks what it enhances with
data-enhanced="tabs"and enhances each element once, however often it runs: a page script that runs twice, or React Strict Mode mounting an effect twice.destroy()removes its listeners and the mark. - In React, the component is a Server Component around one small client part (the tab list element). That part runs the enhancer on its own element once React has hydrated, and destroys it on unmount. It renders no state itself, so a re-render never writes back over the enhancer.
Why React and the enhancer never fight
Section titled “Why React and the enhancer never fight”React patches an attribute only when the prop it renders changes. The component derives the state attributes from props that do not change after mount — the first state — so React never touches them again, and the enhancer owns them. Two cases are handled on purpose:
- A new
defaultValue(a server re-render with new search params) remounts the tab list, and the enhancer, starting again, brings every panel in line with the newly selected tab. - Controlled usage, below, keeps React and the enhancer writing the same values.
Controlled usage
Section titled “Controlled usage”A caller that holds the selection passes value and onValueChange. The enhancer then stops
selecting: a click or a key only asks (its onRequest option), React calls onValueChange,
and the new value reaches the enhancer through select() in an effect, which writes the tabs and
the panels — including panels a Server Component rendered and React never re-renders.
This island refuses “Custom”, to show that the caller decides:
'use client';
const [tab, setTab] = useState('plan-monthly');
<Tabs label="Billing" items={items} value={tab} onValueChange={setTab} />;Without value, onValueChange still reports every change the reader makes.
One owner per element
Section titled “One owner per element”A page that runs enhanceTabs(document) and also hydrates a React island with Tabs would enhance
the island’s list before React does, and the island’s options (controlled usage) would be lost. On
such a page, scope the script to the static part — enhanceTabs(staticRoot) — or let the islands
enhance themselves.
Write a behaviour
Section titled “Write a behaviour”The pattern for every interactive component, as Tabs does it:
- The enhancer,
packages/ui/src/enhance/<name>.ts: plain DOM. Find the elements withmatching(scope, '[data-<name>]'), enhance each withenhanceOnce(element, '<name>', setup), return{ destroy() }and any imperative method a controlled caller needs (select). Read the elements when an event needs them rather than keeping them. Announce changes with a bubblingzd:<name>event. Never import React,cnor any component. - The component,
<Name>.tsx: a Server Component that renders all the markup, the first state, and thedata-<name>hook. - The client part,
<Name>.client.tsx, with"use client": the one element the enhancer needs, which runs it in an effect and destroys it on unmount; controlled props map to the enhancer’s options and methods. - Tests: the enhancer on plain server HTML (no React on the page); the component hydrated, under Strict Mode, re-rendered, and controlled.
@zitdevs/ui/enhance and the package’s barrels are generated from the folders, so a new enhancer
is published by adding its file.