place

Component primitives

The typed components that compose pages: the view (import.meta.url, hydration) 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; views additionally chunk the client portion (unless asserted level: 'static', in which case they ship zero JS).

view()

The unified hydration boundary (ADR 0030). view(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 by default, and the framework's bundler produces a per-island chunk that the marker's auto-mount wrapper hydrates into the existing DOM. An optional level option picks among four emit shapes — most apps never set it.

ts
// `view`, `state`, `onMount` auto-imported via the @place-ts/component // Bun plugin (registered via bunfig.toml `preload`). const Counter = view(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 view call ship zero framework JS. Pages with views ship one chunk per distinct view 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 view(import.meta.url, fn) to view(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. Same treatment for the legacy island() alias.

Without the plugin

ts
// Equivalent to the sugar form above; the plugin rewrites // view(fn) → view(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 view directly, custom build pipelines, etc.). const Counter = view(import.meta.url, (props: { start?: number }) => { const n = state(props.start ?? 0) return <button onClick={() => n.set(n() + 1)}>count: {n}</button> })

level: 'static' — zero JS for pure components

The immediate win of the unified factory. When a component has no reactive effects, no lifecycle hooks, no timers, no DOM mutations beyond what JSX already renders, assert level: 'static' to opt out of hydration entirely. The build emits no per-view bundle and no marker; the SSR'd HTML ships verbatim.

ts
// `level: 'static'` (L0) — ships ZERO per-island JS. For pure- // render components that need the view() shape (typed JSX-callable // props, classifier integration, prop serialization) but have no // reactive effects, lifecycle hooks, timers, or DOM mutations. // // The framework: // - Does NOT generate a per-island bundle for this name // - Does NOT emit a `<div data-view="island" data-view-id="…">` marker // - Does NOT load any client JS for it // The SSR'd HTML is the final state. const StaticGreeting = view(import.meta.url, (props: { name: string }) => <h2>Hello, {props.name}!</h2>, { level: 'static' }, ) // Build-time validation (Phase 2): if you assert level: 'static' on a // body that has effects (calls state(), onMount(), watch(), etc.), the // build FAILS with the offending primitive named. Drop the option, or // remove the effect — silent zero-JS-for-broken-code can't happen.
Build-validated, no silent regressions
The build's view-classifier reads the source for every view() call. If you assert level: 'static' and the body actually uses state(), onMount(), or any other effect-producing primitive, the build fails with the offending identifier named. Wrong assertions can't ship.

The level matrix

text
// The level option's full matrix: // // level: 'static' L0 no marker, no bundle, no hydration. // SSR HTML is the final state. // Build-validated against the classifier. // level: 'island' L2 the default. Per-island bundle, full // hydration. Identical to today's island(). // level: 'island+stream' L3 alias for 'island'. Streaming is wrapped // from outside via <Suspense> + renderToStream // (ADR 0029); no separate per-island shape. // level: 'thaw' L1 throws at definition time. The L1 thaw // runtime is deferred (ADR 0027); drop the // option to fall back to 'island'. // level: undefined defaults to 'island'. Classifier output // is informational — opt in to 'static' // explicitly.

Most authors leave level unset — the default 'island' matches the legacy island() behavior. Set 'static' for pure components that genuinely have no effects. 'thaw' currently throws — the L1 thaw runtime is deferred to a future tier.

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. Strategies apply to level: 'island' only — 'static' views ship no JS, so there's nothing to schedule.

island() → view() migration
island() is a JSDoc-deprecated alias for view() with no level option set. Existing code keeps working unchanged. Migration is a rename: import { island }import { view }, and island(import.meta.url, fn)view(import.meta.url, fn). No behavior change.

Show

ts
import { Show, state } from '@place-ts/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-ts/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-ts/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-ts/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-ts/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-ts/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-ts/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