Component primitives
The typed components that compose pages: the island boundary, conditional rendering, the streaming suspense boundary, the form helper, list keying, and the windowed list. Each runs on both the server and the client; islands additionally chunk the client portion.
island()
The hydration boundary. island(import.meta.url, fn) wraps a render function as a JSX-callable component; pages that render the result emit a typed data-view="island" marker, and the framework's bundler produces a per-island chunk that the marker's auto-mount wrapper hydrates into the existing DOM.
// `island`, `state`, `onMount` auto-imported via the @place/component
// Bun plugin (registered via bunfig.toml `preload`).
const Counter = island(import.meta.url, (props: { start?: number }) => {
const n = state(props.start ?? 0)
return (
<button onClick={() => n.set(n() + 1)}>
count: {n}
</button>
)
})
// Use anywhere — JSX-callable, props flow naturally:
<Counter start={5} />
Pages without any island call ship zero framework JS. Pages with islands ship one chunk per distinct island used on the page, plus a small shared client runtime emitted by per-route bundle splitting.
preload in bunfig.toml) rewrites island(import.meta.url, fn) to island(import.meta.url, fn) at load time so the bundler can locate the source. You write the sugar form; the build emits the typed shape.Without the plugin
// Equivalent to the sugar form above; the plugin rewrites
// island(fn) → island(import.meta.url, fn) at load time. Reach for the
// explicit form only if you're NOT using the framework's Bun plugin
// (tests importing island directly, custom build pipelines, etc.).
const Counter = island(import.meta.url, (props: { start?: number }) => {
const n = state(props.start ?? 0)
return <button onClick={() => n.set(n() + 1)}>count: {n}</button>
})
Hydration strategies
// The framework-reserved 'client' prop picks a hydration strategy.
// Defaults to 'load' (hydrate as soon as the bundle parses).
<Counter /> // load
<Counter client="visible" /> // hydrate on IntersectionObserver
<Counter client="idle" /> // hydrate on requestIdleCallback
<Counter client="interaction" /> // hydrate on first hover/focus
The client prop is reserved by the framework and stripped before props reach the impl. The strategy controls when the auto-mount wrapper attaches; it never affects SSR output.
Show
import { Show, state } from '@place/component'
const open = state(false)
<Show when={() => open()} fallback={null}>
{() => <Modal />}
</Show>
Conditional render. when is a reactive predicate; truthy renders children(), falsy renders fallback (or nothing). Both branches are lazy — only the active branch evaluates.
<Show when={...}> over {() => cond ? <X /> : null} in JSX children — same semantics, reads better, and the intent is named.Tabs & Tab
// `Tabs` and `Tab` are auto-imported. Pass <Tab> children with a
// label; the framework SSR-renders triggers + panels and inlines a
// tiny delegated click handler for interactivity (no per-instance JS).
<Tabs group="hello">
<Tab label="place">
<CodeBlock code={PLACE_HELLO} />
</Tab>
<Tab label="Next.js">
<CodeBlock code={NEXT_HELLO} />
</Tab>
</Tabs>
Composable tabs primitive. Pass <Tab label="..."> children; each tab's label travels with its panel content (no parallel arrays to keep in sync). When group is set, the framework auto-wires a place-tab-${group} cookie so the active tab persists across reloads. First paint shows the cookie-resolved tab with no JS.
Interactivity rides on a single document-level delegated click handler the framework inlines once per page (when any <Tabs> renders) — no per-instance JS bundle. Keyboard navigation (Arrow keys, Home, End) is included.
Variants & customization
// Quick visual variants. `classes` still overrides everything
// for full control.
<Tabs group="hello" variant="card" /> // default — bordered box
<Tabs group="hello" variant="underline" /> // bottom-rule, no outer border
<Tabs group="hello" variant="pill" /> // rounded pill triggers
<Tabs group="hello" variant="ghost" /> // minimal — no chrome
// Full custom theming:
<Tabs group="hello" classes={{
root: 'rounded-xl border border-cyan-500/30',
list: 'flex bg-cyan-950/20 border-b border-cyan-500/30',
trigger: 'py-2 px-4 text-cyan-200/70 hover:text-cyan-100 cursor-pointer',
triggerActive: 'text-cyan-300 underline underline-offset-4',
}}>
...
</Tabs>
<CodeBlock> directly inside a <Tab> and the docs styles automatically strip its outer border so the two don't double up visually.As a filter trigger
Tabs without panel content work as a filter selector. Each tab click dispatches a place:tabs CustomEvent that bubbles to document, with detail.group and detail.value. Subscribe from any island to drive reactive state.
// Tabs as a filter trigger — no panel content.
// `tabsState(group)` returns a reactive State<string> bound to the
// active tab: cookie-persisted on the server, auto-updated on clicks
// on the client. One line — no event listeners, no manual binding.
<Tabs group="todo-filter" variant="pill" classes={{ root: 'mb-3' }}>
<Tab label="all" />
<Tab label="active" />
<Tab label="done" />
</Tabs>
// In an island anywhere on the page:
const TodoList = island(import.meta.url, () => {
const filter = tabsState('todo-filter', 'all')
return (
<ul>
{() => items
.filter(item => filter() === 'all' || item.status === filter())
.map(item => <li>{item.label}</li>)}
</ul>
)
})
Cookie persistence still applies — reload the page and the same filter tab stays active. The framework's tabs runtime handles the click → cookie write → DOM swap atomically; islands subscribe to the event for the reactive side.
Suspense
import { Suspense } from '@place/component'
<Suspense fallback={<Spinner />}>
{() => <AsyncRenderedChild />}
</Suspense>
Streaming SSR boundary. The fallback ships in the initial HTML; once the async children resolve, the framework streams a swap chunk that replaces the placeholder anchors. Works pre-hydration; no client JS required for the swap itself. Views that read from an unresolved Suspense get classified as island+stream — the auto-mount wrapper subscribes to the Channel B state envelope on the client.
errorBoundary()
import { errorBoundary } from '@place/component'
errorBoundary({
children: () => <Risky />,
fallback: (err) => <p>Failed: {err.message}</p>,
})
Catches synchronous render errors in the wrapped subtree. Returns the fallback view. Async errors inside a streaming Suspense boundary surface here too.
Form
import { Form, action, shape } from '@place/component'
const subscribe = action({
path: 'POST /api/subscribe',
input: shape({ email: 'string' }),
fn: async ({ email }) => { /* ... */ return { ok: true } },
})
<Form action={subscribe}>
<input name="email" type="email" required />
<button>Subscribe</button>
</Form>
Submits to a typed action(). With JS: fetch + JSON, typed return. Without JS: form-encoded POST that the same action accepts. CSRF token + same-origin + body-limit pipeline applies either way.
keyed()
import { keyed } from '@place/component'
<ul>
{keyed(() => items, (item) => item.id, (item) => (
<li>{() => item.label}</li>
))}
</ul>
Stable identity for list children. Without it, the framework treats each list as opaque and re-creates the DOM on every change; with it, only added/removed/reordered children mutate.
virtualList()
import { virtualList } from '@place/component'
const list = virtualList({
count: () => rows.length,
estimateSize: () => 32,
})
<div ref={list.scrollEl} style="height: 400px; overflow: auto;">
<div style={() => `height: ${list.totalSize()}px; position: relative;`}>
{() => list.visible().map((v) => (
<div style={`position: absolute; top: ${v.start}px; height: ${v.size}px;`}>
{rows[v.index].name}
</div>
))}
</div>
</div>
Windowed render for long lists. Returns reactive totalSize() and visible(); you place the items at absolute positions. ADR 0008 has the rationale.
component()
import { component } from '@place/component'
// Plain components compose on both runtimes. No SSR opt-out flag
// needed — anything that requires interactivity goes inside an
// island() instead.
const Heading = component((props: { children: unknown }) => (
<h2 class="prose-heading">{props.children}</h2>
))
Plain render function. Runs on both server and client. Reach for component() when you want a typed wrapper for shared chrome; reach for island() when the subtree needs interactivity.
See also
- SSR & islands hydration — the wire format + the classifier
- page() — page-level fields + streaming
- state · watch · derived