place

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.

ts
// `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.

What the plugin does for you
The framework's Bun plugin (registered via 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

ts
// 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

ts
// 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

ts
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.

Replaces the function-in-JSX pattern
Prefer <Show when={...}> over {() => cond ? <X /> : null} in JSX children — same semantics, reads better, and the intent is named.

Tabs & Tab

ts
// `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

ts
// 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 inside Tabs
Drop a <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.

ts
// 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

ts
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()

ts
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

ts
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()

ts
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()

ts
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()

ts
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