---
title: '2. Components & islands'
description: Author a TSX component with state and a click handler; let the compiler decide what ships as JavaScript.
order: 2
---

# Components & islands

In this chapter you add two interactions to the product page — a size-guide popover and a "save
for later" button — written as one TSX component. You'll see the compiler decide, per
interaction, what actually ships to the browser, and you'll test both behaviors from strings.
Step state: `site/tutorial/steps/02-islands/`.

## Write the component

Kovo ranks every interaction on a fixed ladder and uses the lowest layer that works. **L0** is
platform behavior — popovers, dialogs, `<details>` — costing zero JavaScript. **L1** is a pure
client island whose handler module loads on first interaction. You don't pick the layer; the
compiler does. You write TSX:

```tsx
export const ProductActions = component({
  state: (): ProductActionsState => ({ saved: 0 }),
  render: (_queries: Record<string, never>, state: ProductActionsState) => (
    <product-actions>
      <button type="button" onClick={() => document.getElementById('size-guide')!.togglePopover()}>
        Size guide
      </button>
      <div id="size-guide" popover="auto">
        <p>Kettle height 24cm, base diameter 12cm.</p>
      </div>
      <button
        type="button"
        style={productActionStyles.saveButton}
        onClick={() => {
          state.saved += 1;
        }}
      >
        Save for later
      </button>
    </product-actions>
  ),
});
```

Three things to notice in what you wrote — and didn't:

- **No stamps.** You never hand-write `kovo-c`, `kovo-state`, or binding attributes; the compiler
  derives them. Hand-writing one is a lint warning, and one that disagrees with the typed
  expression it wraps is a compile error.
- **`state` is a typed, serializable fact.** The `JsonValue` constraint makes unserializable
  state a compile error: island state lives in the document, not in a JavaScript heap, so it has
  to survive serialization.
- **Two closures, two fates.** The compiler proves your size-guide closure equivalent to a
  platform invoker and emits `popovertarget` attributes instead of JavaScript. Your save closure
  becomes a named export in a per-component client module that loads on first click. To see what
  got emitted, [Compiler internals](/guides/compiler-internals/) shows real captured output; the
  served page below tells you what you need here.

## Render it into the page

The app renders the authored TSX component into the product page. The compiler may emit transient
generated artifacts for inspection during a build, but those are outputs to verify, not source you
author, commit, or import by hand:

```tsx
export const productRoute = route('/products/:id', {
  access: publicAccess('tutorial storefront browsing'),
  params: s.object({ id: s.string() }),
  page({ params }) {
    const product = catalog.find((item) => item.id === params.id);
    if (!product) return notFound();
    return (
      <html>
        <head>
          <title>{product.name} · Kovo Shop</title>
        </head>
        <body>
          <main>
            <h1>{product.name}</h1>
            <p>{formatPrice(product.unitPrice)}</p>
            <ProductActions />
            <a href="/">Back to the shop</a>
          </main>
        </body>
      </html>
    );
  },
});
```

The step's first test reads the served HTML the way you'd read it in the Elements panel: the
popover wired as plain attributes, the handler as a full URL plus a named export, and the
island's state right there in the markup. Names are load-bearing, so minification can't mangle
them:

```ts
it('serves the island as self-describing attributes, zero eager JS', async () => {
  const response = await renderProductRoute('p1');
  const html = bodyText(response.body);

  // L0: the size-guide closure was proven equivalent to a platform invoker
  // and lowered to attributes — no JavaScript ships for it.
  expect(html).toContain('popovertarget="size-guide"');
  expect(html).toContain('popovertargetaction="toggle"');

  // Island state is serialized in the markup, not hidden in a JS heap.
  expect(html).toContain('kovo-state="{&quot;saved&quot;:0}"');
});
```

Someone who has never seen this codebase can answer "what does this button do?" from devtools
alone — the answer is an attribute, not a stack trace through framework internals.

## Run the handler without a browser

Handlers are named exports with the signature `(event, ctx)`. The loader's job — delegate the
event, import the module, invoke the export, persist state — is mechanical, so the test does
exactly what the loader does, against the real emitted handler module:

```ts
it('runs the named handler export against island state without a browser', async () => {
  const root = mkdtempSync(join(tmpdir(), 'kovo-tutorial-compile-'));

  try {
    const { lowered } = compileProductActions(root);
    const element = new FakeElement({
      'kovo-state': attributeFrom(lowered, 'kovo-state'),
      'on:click': attributeFrom(lowered, 'on:click'),
    });
    const importedUrls: string[] = [];
    const importModule = async (url: string) => {
      importedUrls.push(url);
      return {
        ProductActions$button_click(_event: unknown, context: { state: { saved: number } }) {
          context.state.saved += 1;
        },
      };
    };

    await dispatchTutorialEvent({ target: element, type: 'click' }, importModule);
    await dispatchTutorialEvent({ target: element, type: 'click' }, importModule);

    expect(importedUrls[0]).toContain('/c/__v/');
    expect(importedUrls[0]).toContain('/tutorial/steps/02-islands/');
    expect(element.getAttribute('kovo-state')).toBe('{"saved":2}');
  } finally {
    rmSync(root, { force: true, recursive: true });
  }
});
```

Two clicks, state `0 → 2`, persisted back into the `kovo-state` attribute. Note what loaded when:
nothing at page load, the module on first interaction. Zero JS before interaction is the default
the markup declares, not an optimization you turn on.

## Read the compiler's diagnostics

The step's last test compiles the authored source and pins the diagnostics: the anonymous save
closure earns a naming nudge, and the popover substitution is recorded:

```ts
it('checks the authored TSX through the public kovo compile command', () => {
  const root = mkdtempSync(join(tmpdir(), 'kovo-tutorial-compile-'));

  try {
    const { lowered, output } = compileProductActions(root);

    expect(output).toContain(
      'WARN KV210 file="site/tutorial/steps/02-islands/src/components/product-actions.tsx"',
    );
    expect(output).toContain('SUMMARY artifacts=2 diagnostics=1');
    expect(lowered).toContain('popovertarget="size-guide"');
    expect(lowered).toContain('popovertargetaction="toggle"');
    expect(lowered).toMatch(
      /on:click="\/c\/__v\/[0-9a-f][0-9a-f-]*\/(?:site\/)?tutorial\/steps\/02-islands\/src\/components\/product-actions\.client\.js#ProductActions\$button_click"/,
    );
  } finally {
    rmSync(root, { force: true, recursive: true });
  }
});
```

Every Kovo diagnostic shows what would have been generated, why it can't be, and the fix menu.
The [reading kovo check guide](/guides/kovo-explain/) tours the diagnostic registry.

## Next

You now have a free popover, a lazy island, and tests proving both from strings. Next: real
data — queries, and the bindings the compiler derives from them.

<details>
<summary>Spec & diagnostics</summary>

Interaction ladder and lowest-layer rule: SPEC §7. Compiler-derived stamps: SPEC §4.1, §4.8;
hand-written stamp is **KV223**, a stamp that disagrees with its typed expression is **KV222**.
Serializable island state: SPEC §4.1. Popover lowering and naming nudge: SPEC §5.2 rule 4,
**KV210**. Generated lowered IR is a verification artifact, not app-authored source: Constitution
#3. Legible served HTML and load-bearing names: SPEC §4.2, `rules/v1-acceptance.md`, Constitution
#1. Handler signature and lazy load: SPEC §4.3, §4.4.

</details>
