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.
// `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.
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
// 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.
// `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.
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
// 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
// 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() 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
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.
<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-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()
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
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()
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()
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()
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
- SSR & islands hydration — the wire format + the classifier
- page() — page-level fields + streaming
- state · watch · derived