Client interactivity | Koze | Primitives Docs

Client interactivity

Single-script routes, SSR data hydration, and the `on<event>={fn(args)}` directive

Koze routes render on the server, but the authored top <script> model is client-first. There is one main script to write, one mental model to hold, and one file to edit when you want to add browser behavior.

Single-script routes

A route's top <script> block is authored as client code with explicit server escape hatches. The framework server-runs the parts needed for SSR, serializes awaited results into the HTML response, and hydrates those values into the browser copy of the script so references like boardViews below resolve immediately on first paint.

<script>
  import { listBoardViews, getBoard } from '$server/board';
  import { searchParams } from 'koze:request';

  const slug = searchParams.get('board') || 'default';
  const boardViews = await listBoardViews();
  const board = await getBoard(slug);

  function badgeClass(priority) {
    return priority === 'high' ? 'badge-red' : 'badge-gray';
  }

  function openPrompt(slug) {
    const title = window.prompt('Title?');
    if (title) location.assign(`/board/${slug}/new?title=${encodeURIComponent(title)}`);
  }
</script>

<h1>{board.name}</h1>
<ul>
  for (const item of board.items) {
    <li class={badgeClass(item.priority)}>{item.title}</li>
  }
</ul>

<button>Add item</button>

Note: You author one top <script> block per route. Add a second <script> block and the compiler reports an error. If a route script gets large, move helpers into $lib/* modules and import them from the top script.

Reactive UI

Use $: for client reactivity. Koze keeps ordinary JavaScript ordinary until a top-level let is read by $:, bind:value, or a live template expression.

<script>
  const allCells = await listCells();

  let selectedLocationId = '';
  let selectedCellId = '';

  $: selectOptions = allCells.filter(
    cell => cell.locationId === selectedLocationId,
  );

  $: if (!selectOptions.some(cell => cell.id === selectedCellId)) {
    selectedCellId = '';
  }
</script>

<select bind:value={selectedLocationId}>
  <option value="">Choose...</option>
  for (const location of locations) {
    <option value={location.id}>{location.name}</option>
  }
</select>

<select bind:value={selectedCellId}>
  <option value="">Choose...</option>
  for (const cell of selectOptions) {
    <option value={cell.id}>{cell.name}</option>
  }
</select>

Reactive reads update these template surfaces in the browser after the server-rendered first paint:

  • text expressions like {selectOptions.length}
  • normal attributes like hidden={...}, class={...}, and value={...}
  • if (...) { ... } else { ... } blocks
  • for (const item of items) { ... } blocks
  • bind:value={form.field} form controls

bind:value is only the DOM write-back API. $: is still where derived state and effects live.

Declaring reactive state

Top-level let bindings become reactive state when they are read by $:, bind:value, or a live template expression. Use them for mutable source values:

<script>
  let search = '';
  let selectedLocationId = '';
</script>

Derived values can be declared directly with $:. You do not need to predeclare a placeholder let first:

<script>
  const allCells = await listCells();

  let selectedLocationId = '';

  $: filteredCells = allCells.filter(
    cell => cell.locationId === selectedLocationId,
  );
  $: hasCells = filteredCells.length > 0;
</script>

<select bind:value={selectedLocationId}>...</select>

if (hasCells) {
  for (const cell of filteredCells) {
    <p>{cell.name}</p>
  }
}

In that example filteredCells and hasCells are both defined by their $: assignments and exposed to live template blocks. Top-level const bindings, like allCells, stay readonly; they can be read by reactive state but are not converted into mutable state themselves.

Inside template loops, use loop locals directly. Koze carries those locals into client bindings, so bind:value={forms[item.id].selected} works without DOM traversal.

How hydration works

At render time the framework:

  1. Runs the top <script> on the server. await $server/* calls resolve against real modules; koze:request / koze:environment imports resolve against the live request.
  2. Captures every top-level binding that was populated by an await (direct or ternary) and every top-level let that was mutated inside an await-bearing block.
  3. Serializes those values into an inline <script type="application/json" id="__koze_data"> element.
  4. Emits a <script type="module"> for the same top-script code, rewritten so that const X = await fn() reads become const X = __kozeReadData("X").

In the browser the client copy of the top script runs with those hydrate reads already wired up — no extra network round-trips for data that was already computed server-side. Helper functions, $: reactivity, and synchronous bindings stay available in that browser copy.

<!-- Rendered HTML, abbreviated -->
<script type="application/json" id="__koze_data">
  {"boardViews":[...],"board":{...},"__params":{}}
</script>
<script type="module" src="/assets/koze-client-<hash>.js"></script>

The JSON blob is data, not code — browsers never parse it as JavaScript — so the payload can safely carry arbitrary strings, including values that contain <script> or </script> fragments.

What gets hydrated

Pattern Hydrated? Notes
const x = await fn() Yes Direct top-level await.
const x = cond ? await fn() : other Yes Ternary with an await in either branch.
let x = null; + if (...) { x = await fn(); } Yes let bindings reassigned inside an await-bearing top-level block.
const x = computeSync() No Pure synchronous code re-runs in the browser.
function helper() { ... } No Declarations run on both sides.
Anything inside a function body No Nested await only fires when the function is called.

Stripping SSR-only code from the browser bundle

The framework strips every top-level statement whose body contains an await from the browser bundle — those blocks already ran server-side and their mutations are captured in the hydrate payload. Your event handlers, helper functions, non-async conditionals, and template expressions are preserved.

Browser RPC after hydration

When a $server/* import is called in the browser, the generated stub returns the same thenable async-value contract the server uses for non-awaited calls:

<script>
  import { refreshBoard } from '$server/board';

  async function onRefresh() {
    const result = refreshBoard();

    if (result.pending) {
      console.log('Refreshing…');
    }

    await result;
  }
</script>

That means you can either await refreshBoard() for blocking flow or inspect .pending, .error, and .success immediately for reactive UI.

Reading additional SSR values

Sometimes you want SSR to compute a value the template doesn't need but a client handler does. Use __kozeReadData, the runtime helper the framework injects when hydration is in play:

<script>
  import { listUsers } from '$server/users';

  const users = await listUsers();
  const now = Date.now();

  function greet() {
    // `users` is hydrated (from `await`). `now` is not — it re-runs
    // client-side. Read any extra SSR value explicitly by key:
    const firstName = __kozeReadData('users')[0]?.name;
    alert(`Hi ${firstName}, current time ${now}`);
  }
</script>

<button>Greet</button>

Event handler directive

Author DOM handlers declaratively with on<event>={expr}:

<button type="button">Archive</button>
<input type="text" />
<form>...</form>
<dialog>...</dialog>

Supported events: click, change, input, submit, keydown, keyup, focus, blur.

The expr between {…} must be a call expression:

<!-- ✅ Supported -->
<button>Save</button>
<button>Save</button>
<button 'x')}>Save</button>
<button>Save</button>

<!-- ❌ Not supported — arrow bodies, statements, bare identifiers -->
<button => save(item.id)}>Save</button>
<button>Save</button>
<button track()}>Save</button>

Dispatch rules

The framework picks the dispatch path based on what the callee is:

Callee shape Dispatch What runs
$server/* import (e.g. deleteItem imported from $server/items) Server action — POSTs to the route URL The route's actions[fnName] server function, invoked as fn(...args, ctx) where ctx is { formData, request, url, params }. See Actions for the full signature.
Function declared in the top <script> OR $lib/* import Client handler — runs in the browser The function, with the args evaluated at SSR time.
Anything else (bare expression) Native inline — legacy escape hatch The literal expression in an inline onclick=. Requires CSP unsafe-inline.

The args (item.id, filter.key, …) are evaluated at SSR time — they are plain serialized values by the time the browser sees them. To access the DOM event, receive it as a trailing parameter in the handler function:

<script>
  function handleDialogKey(event) {
    if (event.key === 'Escape') event.currentTarget.close();
  }
</script>

<dialog>...</dialog>

The bridge passes (…args, event, element) to every handler, so an author-declared function with fewer parameters just ignores the trailing ones.

Client handler vs. direct server-action dispatch

Both paths work. Pick based on what the interaction needs:

<!-- Direct server-action dispatch: one line. Framework POSTs, the
     browser re-renders on success. Use for fire-and-refresh clicks
     where you don't need UI between the click and the response. -->
<button>Delete</button>

<!-- Client handler wrapper: use when you need confirmation, optimistic
     updates, inline error display, or anything between the click and
     the network round-trip. `$server/*` imports are real functions in
     the browser bundle — the framework auto-generates an RPC stub —
     so calling `await deleteItem(id)` works inside the handler. -->
<script>
  import { deleteItem } from '$server/items';

  async function onDelete(id) {
    if (!confirm('Delete?')) return;
    await deleteItem(id);
    location.reload();
  }
</script>

<button>Delete</button>

The server side is the same function in both cases. See Actions for the server function signature.

Client bridge

When a route uses on<event>={…}, the framework injects a ~1 kB bridge into the route's client bundle. The bridge:

  • Registers a single document-level listener per event type.
  • Looks up the ancestor carrying data-client-* attributes.
  • Dispatches to the handler registered via window.__kozeClient.register(routeId, {…}) for client handlers.
  • POSTs _action / _args to the route URL for server-action callees ($server/* imports).

Handler expressions are stored in a per-route table keyed by a short id (h0, h1, …) and referenced from the HTML via data-client-handler="h0". Handler bodies live in the client bundle — data-client-args="[1]" only carries the serialized args.

The bridge is idempotent: multiple registrations against the same routeId merge their handler tables, so HMR and client-side navigations that recycle the document don't double-bind.

Security

  • The JSON hydration payload escapes < to \u003c so an attacker can't close the <script> tag early with user-supplied data.
  • Handler ids are validated against a strict identifier regex and prototype-pollution names (__proto__, constructor, prototype) are blocked at both register and invoke time.
  • Server-action POSTs are same-origin and include the action name from a trusted compile-time attribute, not a user-controlled identifier.
  • Native inline onclick="expr" (the legacy escape hatch) requires a CSP that allows unsafe-inline. The declarative onclick={expr} directive does not — the bridge is a normal same-origin module script.

Browser-only DOM code

Route files allow one <script> block: the top script. That script is authored client-first, but it must still be safe for SSR analysis and initial render. Keep direct DOM access inside event handlers or lazily called functions, not in top-level module execution:

<script>
  import { getItems } from '$server/items';
  import { mountWidget } from '$lib/widget';

  const items = await getItems();

  function activateWidget() {
    mountWidget(document.querySelector('[data-widget]'));
  }
</script>

<div data-widget>...</div>
<button>Activate widget</button>

Modules imported from $lib/* should be SSR-safe at import time. If a third-party browser library touches window during import, wrap it in a small $lib/* helper that loads it lazily inside the browser-only function.