Menu

Guides

View as Markdown

Interactive islands & client state (L1)

Most interactivity isn't a write to the server — it's a toggle, a tab, a filter, a carousel. Kovo's Interaction Ladder says to use the lowest layer that suffices, and for client-only UI that layer is L1: a pure client island — local state plus the update plan (bindings, derives, stamps). You author an inline closure that flips a state field; the compiler lowers it to a named handler that loads on first touch and updates the DOM by walking self-describing attributes. No re-render, no virtual DOM, no eager JavaScript.

This guide is about authoring that interactivity. (For copy-in primitive components — the headless UI library — see components.)

The Interaction Ladder#

Reach for the lowest layer that does the job:

Layer Mechanism Example JS shipped
L0 Platform behavior: invoker commands, <details>, <dialog>, :has() Open a drawer 0
L1 Pure client island: local state + update plan (this guide) Tabs, toggle, filter handler module on first touch
L2 Mutation: real form + enhanced fetch → fragment/query patch Add to cart loader + form module
L3 Optimistic: transform over query values Instant badge tick transform module
L4 Roadmap live transport over the same fragment/query chunks Order status, presence Not in technical preview

If a <details> element or commandfor/command does it, write that and ship zero JS — the compiler enforces L0 substitutions. L1 is for state the platform can't express on its own: a pressed toggle, a checked-vs-indeterminate checkbox, a selected tab.

Author local state#

A component declares its private, client-owned state with state: () => ({...}). The return value must satisfy JsonValue — no Date, Map, functions, or class instances — so serializability is a compile error, not a runtime surprise. The smallest island is a state value plus a button that changes it:

tsx
export const Toggle = component({
  state: () => ({ pressed: false }),
  render: (_queries, state) => (
    <button aria-pressed={String(state.pressed)} onClick={() => (state.pressed = !state.pressed)} />
  ),
});

Here is the gallery's toggle island, authored TSX verbatim:

tsx
import { component } from '@kovojs/core';

export interface GalleryToggleDemoState {
  pressed: boolean;
}

export const GalleryToggleDemo = component({
  state: () => ({ pressed: false }), // local, JsonValue-constrained
  render: (_queries, state: GalleryToggleDemoState) => (
    <section data-gallery-interactive="toggle">
      <button
        aria-pressed={String(state.pressed)}
        data-state={state.pressed ? 'pressed' : 'off'}
        onClick={() => {
          state.pressed = !state.pressed; // mutate state; the compiler lowers this closure
        }}
        type="button"
      >
        Dense rows
      </button>
      <output data-demo-state="pressed">{state.pressed ? 'pressed' : 'off'}</output>
    </section>
  ),
});

Local state and query data are different channels and the compiler keeps them separate:

  • Query data is shared and server-owned. It lives in <kovo-query> and refreshes when a mutation invalidates it. See queries.
  • Local state is private and client-owned. It never touches the server.

Putting a server fact in local state is lint KV301 — if a value comes from the database, it belongs in a query (and, if you want instant feedback, an optimistic transform), not in state.

Named handler exports (event, ctx)#

You author inline closures; the compiler extracts each into a named, exported handler with the signature (event, ctx). This is the contract the loader invokes. The gallery toggle above lowers to this generated client module:

ts
// generated — but valid, authorable Kovo source
import { handler } from '@kovojs/browser';

export const GalleryToggleDemo$button_click = handler<{ pressed: boolean }>((event, ctx) => {
  ctx.state.pressed = !ctx.state.pressed;
});
html
<button on:click="/c/toggle-demo.client.js#GalleryToggleDemo$button_click">Dense rows</button>

Names are source-derived (Component$fnName, or Component$element_event when anonymous — lint KV210 nudges you to name it) and minification never renames them, because they are load-bearing in the HTML. ctx carries the island's state, typed element params, and an AbortSignal (ctx.signal) the loader aborts when the morph layer removes the island — that's the whole lifecycle, no mount/unmount callbacks. Handlers unit-test as plain (event, ctx) functions.

A closure may only capture three channels: component/query state (via ctx), element params (data-p-*, typed — non-string params declare coercion once, schema-style), and module scope. Anything else is compile error KV201, whose message shows what the closure would have compiled to and the three fixes.

The update plan: bindings, derives, stamps#

When state (or query data — same machinery, two sources) changes, the loader runs three steps in order by walking self-describing attributes. The DOM is the plan — there is no separate compiled artifact. The author writes typed expressions; the compiler emits the residual strings.

1. Bindings — path writes. {state.pressed} as an element's sole text child lowers to data-bind; an expression in attribute position lowers to a named derive. Binding paths type-check against the state/query shape and are null-aware: traversing a nullable segment without ?. is error KV227.

2. Named derives — the expression layer. The toggle's aria-pressed={String(state.pressed)} and data-state={...} expressions lower to named, exported, pure derives with declared inputs:

js
// Compiler-emitted artifact for inspection; `derive` is supplied by Kovo's generated ABI.

export const GalleryToggleDemo$button_aria_pressed_derive = derive(['state'], (state) =>
  String(/** @type {{ pressed: boolean }} */ (state).pressed),
);
export const GalleryToggleDemo$button_data_state_derive = derive(['state'], (state) => {
  const localState = /** @type {{ pressed: boolean }} */ (state);
  return localState.pressed ? 'pressed' : 'off';
});
export const GalleryToggleDemo$output_text_derive = derive(['state'], (state) => {
  const localState = /** @type {{ pressed: boolean }} */ (state);
  return localState.pressed ? 'pressed' : 'off';
});

This is compiler-emitted IR, so its generated-only import deliberately retains the inspectable ['state'] runtime name. If you author a derive directly, import from @kovojs/browser and pass opaque handles such as derive.query(cart), derive.state<State>(), or derive.clock<Date>(); the public API rejects raw input strings. Declared inputs tell the loader which changes re-run the derive — no dependency tracking — and the module loads lazily on the first relevant change.

3. Template stamps — keyed list reconciliation. Lists lower to a data-bind-list with a kovo-key and a <template kovo-stamp>; on change the loader keys existing children against the new array, cloning/removing/reordering by key.

Stamps are derived, never hand-written. {cart.count} and data-bind="cart.count" are one fact; you write the expression, the compiler emits the stamp. A hand-written stamp that disagrees with the expression it wraps is error KV222; a redundant hand-written stamp the compiler could derive is lint KV223. Author TSX (queries, key, typed expressions); the IR carries the residual strings.

Every query- or state-dependent DOM position must have a declared update status — plan / isomorphic / fragment / renderOnce. A position fitting none is KV311, and the fix menu is the ladder: extract a derive, lower to a CSS/attribute toggle, make the component a server-refreshable fragment target, or mark isomorphic: true (lint-gated escape hatch for logic beyond paths/derives/ keyed lists).

Run it#

Render the page, then inspect both source and the live DOM:

  1. Use View Source to confirm the server sent on:* handler refs and a kovo-state stamp.
  2. Click the toggle and watch the same element flip in the Elements panel without a full re-render.

That is the whole L1 contract: stamped HTML first, then a small handler module on first touch.

Handle time-dependent UI#

Relative time, countdowns, and expiring badges need a cadence. Do not call Date.now() or new Date() inside a derive. Component definitions accept a clocks field for named now.* inputs, so declare the cadence once and let the compiler thread that time source into derives:

tsx
export const RelativeTimestamp = component({
  clocks: { ago: { every: '30s' } },
  render: ({ now }: { now: { ago: Date } }) => <time>{formatRelative(now.ago)}</time>,
});

Use query refresh when the value itself is server truth; use clocks when the rendered position is time-dependent but local presentation can tick from the declared cadence.

For time-dependent query data, put the cadence on the query binding so Kovo knows when to ask the server for fresh truth:

tsx
export const TrialBadge = component({
  queries: {
    trial: trialQuery.refresh({ every: '1m' }),
  },
  render: ({ trial }: { trial: { daysLeft: number } }) => (
    <strong>{trial.daysLeft} days left</strong>
  ),
});

export const AuctionBadge = component({
  queries: {
    auction: auctionQuery.refresh({ at: (auction) => auction.endsAt }),
  },
  render: ({ auction }: { auction: { status: string } }) => <strong>{auction.status}</strong>,
});

export const QueueBadge = component({
  queries: {
    queue: queueQuery.refresh({ until: (queue) => queue.done }),
  },
  render: ({ queue }: { queue: { position: number } }) => <strong>{queue.position}</strong>,
});

Use an app-local helper when freezing a value for the document lifetime is intentional, such as a published date that should not tick while the page is open:

tsx
import { component } from '@kovojs/core';

const renderOnce = <Value,>(value: Value) => value;

export const PublishedAt = component({
  queries: { post: postQuery },
  render: ({ post }: { post: { publishedAt: string } }) => (
    <time dateTime={post.publishedAt}>{renderOnce(formatDate(post.publishedAt))}</time>
  ),
});

That renderOnce helper is app code, not a framework export. If the value should change while the page is open, use a query refresh cadence or a client handler instead.

Execution triggers: on:click, on:visible#

Interaction is the default trigger. Three declared alternatives extend the same on:* → delegate → import() → named-export model, and each is legible in markup:

  • on:visible — one shared IntersectionObserver, fires once on first intersection. Charts, maps, carousels, lazy embeds.
  • on:idlerequestIdleCallback; warm-up work.
  • on:load — fires at parse. Reintroduces eager JS, so it requires a justification comment (lint KV211) — grep 'on:load' is the app's eager-JS budget.

The devtool reference app bootstraps its pan/zoom canvas island on first visibility. The server- rendered graph is fully usable with the module absent (selection is real <a href> navigation); the island only enhances:

js
// devtool-pz.client.js — an on:visible bootstrap that owns a widget
export function Devtool$init(_event, ctx) {
  const root = document.querySelector('[data-pz-root]');
  if (!root || root.__pzInit) return; // idempotent — on:visible may re-fire after morph
  root.__pzInit = true;

  const signal = ctx && ctx.signal; // register cleanup on the island's AbortSignal
  const on = (el, ev, fn, opts) =>
    el.addEventListener(ev, fn, signal ? Object.assign({ signal }, opts || {}) : opts);

  on(window, 'resize', fit); // torn down automatically when the island is removed
  // …wheel zoom, drag-to-pan, hover highlight, keyboard a11y…
}
html
<div
  class="canvas"
  data-pz-root
  kovo-c="dataflow-canvas"
  kovo-state="{}"
  on:visible="/c/devtool-pz.client.js#Devtool$init"
></div>

The trigger set is closed (on:media is CSS's job; timers belong inside handlers). Islands patched in by a morph — from a mutation response or a deferred stream — are observed like everything else; a fragment update is a tiny navigation, not a different programming model.

Cross-island coordination#

When one island's change must reach another, prefer them in this order (SPEC §7):

  1. The URL. A filter writes ?max=500 or is a GET form whose fragment response is the grid, both typed against the route's search schema. See routing. This is the default — it's shareable, bookmarkable, and survives reload.
  2. Scoped client state. Use it only for local UI intent, not server/query facts. It stays lint-gated with a required justification comment.

Typed fire-and-forget events are not a shipped authoring surface yet. If two islands need to share server facts, route that through queries, mutations, and optimistic transforms instead of a client event bus.

State inside server-refreshable targets#

Fragment morphs preserve browser-owned DOM state: focus, caret/selection, scroll position, transitions, <details> state, and media element state. They do not serialize an island's private kovo-state. If a parent component is an inferred server-refreshable fragment target, its subtree is re-rendered from declared queries plus stamped props; a nested island's private state would be re-emitted at the render-time default and clobbered.

The compiler rejects that position as KV420: an island declaring local state may not render inside another component's server-refreshable target. Use one of the explicit fixes:

  • Lift the value into a declared query so it travels in the server-refreshable channel.
  • Mark the child isomorphic: true so it self-renders through the client update plan.
  • Set disableServerRefresh: true on the enclosing component when that component should not be a server fragment target.
  • Move the stateful island outside the refreshable region.
  • Use renderOnce only for document-lifetime-immutable local state.

This is why the mutations guide says fragments preserve browser state but not nested island-local state. The boundary keeps fragment responses fully describable by the server.

The loader and the 10,500-byte budget#

The always-loaded bootstrap is capped at 10,500 gzip bytes. That inline bootstrap owns the first-touch capture path: it catches eligible clicks/submits, safely queues or falls back while the runtime loads, promotes deferred styles, and imports the versioned deferred runtime module.

The deferred runtime module is outside that first-paint byte cap. It owns the heavier work: delegation for the full on:* surface, trigger wiring, query hydration/refetch, mutation response application, morphing, and enhanced navigation.

Handler modules and derives are still fetched lazily, per island, on first interaction. The compiler derives the exact module URL and exported symbol name from the component and event site.

Next#

Spec & diagnostics

Component anatomy, state / JsonValue, and the query-vs-local-state split: SPEC §4.1. Handler lowering and capture channels: SPEC §4.3. The 10,500-byte bootstrap plus deferred runtime split: SPEC §4.4 and §8. Execution triggers (on:visible/on:idle/on:load): SPEC §4.7. The update plan (bindings, derives, stamps): SPEC §4.8. Update coverage exhaustiveness: SPEC §4.9. The Interaction Ladder and cross-island coordination order: SPEC §7. Server-refreshable fragment targets and KV420: SPEC §4.5 and §9.1. Server fact in local state is KV301; unserializable closure capture is KV201; hand-written stamp disagreement is KV222, redundant stamp is KV223; on:load without justification is KV211; an uncovered query/state-dependent position is KV311. Time-dependent rendered positions and derives require declared clocks, query .refresh({ every | at | until }), or an app-local renderOnce helper: SPEC §4.1, §4.8, and §4.9; missing cadence is KV312, and raw clock reads in derives are KV315.

API reference: @kovojs/browser, @kovojs/core.