Kylndocs

Templates & reactivity

Kyln's markup is written with the html tagged template literal, and it stays in sync with your data through signals. There's no virtual DOM and no re-render pass; when a signal changes, only the parts of the DOM that read it update.

The html template

html is a tagged template that describes markup. It runs on the server for the first paint and hydrates in the browser.

import { Route, html } from "@kyln/core";

export default class Hello extends Route {
  render() {
    return html`
      <div class="greeting">
        <h1>Hello</h1>
        <p>Welcome to Kyln.</p>
      </div>
    `;
  }
}

Interpolate any expression with ${...}:

const name = "Ada";
return html`<p>Hello, ${name}.</p>`;

Signals

A signal is a reactive value. Create one with signal(initial), read it by calling it, and write it with .set():

import { signal } from "@kyln/core";

const count = signal(0);

count();        // read  → 0
count.set(5);   // write → 5
count();        // read  → 5

When you read a signal inside html, that spot in the DOM becomes reactive automatically. Update the signal and the text updates; nothing else re-runs:

import { Route, signal, html } from "@kyln/core";

export default class Counter extends Route {
  private count = signal(0);

  render() {
    return html`
      <div>
        <p>Count: ${this.count()}</p>
        <button @click=${this.count.set(this.count() + 1)}>Increment</button>
      </div>
    `;
  }
}

Reading this.count() in the markup is all it takes. You don't wrap it in a function or list it as a dependency, and Kyln tracks the read and re-runs only that interpolation when count changes.

Opt out of tracking. Occasionally you want a value evaluated once and never updated. Append the /*@static*/ hint to the interpolation to make it one-shot.

Computed values

computed() derives a signal from other signals. It recomputes only when something it reads changes:

import { signal, computed } from "@kyln/core";

const tasks = signal<Task[]>([]);

const remaining = computed(() =>
  tasks().filter((t) => !t.done).length
);

Read remaining() in your markup like any signal; it stays current as tasks changes.

Reactive attributes

Attributes track signals the same way text does. Interpolate an expression into an attribute and it updates in place when a signal it reads changes:

import { Route, signal, html } from "@kyln/core";

export default class Panel extends Route {
  private active = signal(false);

  render() {
    return html`
      <div class=${this.active() ? "panel on" : "panel off"}>
        <button @click=${this.active.set(!this.active())}>Toggle</button>
      </div>
    `;
  }
}

The expression can also be a method call that reads signals, which keeps markup tidy when the attribute logic grows; the scaffolded task tracker computes its filter-button classes exactly this way.

Conditionals and lists

Branching is a ternary inside an interpolation. Each branch is markup (or an empty string), and the spot re-renders when a signal it reads changes:

html`${this.loggedIn()
  ? html`<p>Welcome back</p>`
  : html`<a href="/login">Log in</a>`}`

Lists are .map() over an array signal, returning markup per item:

html`
  <ul>
    ${this.items().map((item) => html`<li>${item.name}</li>`)}
  </ul>
`

The two compose. The scaffolded task tracker renders its whole list area from one interpolation: a loading message, an empty state, or the mapped tasks, chosen by ternary; when the array signal changes, the list updates.

Event handlers

Bind an event with @ followed by the event name. Kyln recognizes a method call and wires it as the handler; it does not run at render time:

html`<button @click=${this.save()}>Save</button>`

This reads as though save() runs immediately, but Kyln compiles @click=${this.save()} into a click listener that calls save() when the button is clicked. Pass arguments the same way:

html`<button @click=${this.remove(task.id)}>Delete</button>`

A bare function reference (no call) is used directly as the listener:

html`<button @click=${this.save}>Save</button>`

An arrow function works too, and is the familiar form if you'd rather be explicit:

html`<button @click=${() => this.save()}>Save</button>`

All three forms produce the same listener. Use whichever reads best: the bare call is the most concise, the arrow the most explicit.

Modifiers

Append a modifier to the event name to handle common cases inline. .prevent calls preventDefault() before your handler, which is what you usually want on a form submit:

html`<form @submit.prevent=${this.save()}>...</form>`

.stop (stop propagation), .enter, and .escape (fire only on that key) compose the same way.

Components

A component is a reusable piece of view. Extend Component<Props>, read this.props, and return markup from render():

import { Component, html } from "@kyln/core";

interface Task {
  id: number;
  title: string;
  done: boolean;
}

interface TaskCardProps {
  task: Task;
  onToggle: (id: number) => void;
}

export class TaskCard extends Component<TaskCardProps> {
  render() {
    const { task, onToggle } = this.props;
    return html`
      <div class="task">
        <button @click=${onToggle(task.id)}>${task.done ? "✓" : "○"}</button>
        <span>${task.title}</span>
      </div>
    `;
  }
}

Instantiate a component in markup with .create(props):

import { TaskCard } from "../components/task-card";

// inside a render():
html`
  <div class="list">
    ${TaskCard.create({ task, onToggle: (id) => this.toggle(id) })}
  </div>
`

Lifecycle

Routes and components can run code when they mount and unmount. Use onMount() for setup, such as loading data, and onDestroy() for teardown:

import { Route, signal, html } from "@kyln/core";

export default class Tasks extends Route {
  private tasks = signal<Task[]>([]);

  onMount() {
    this.loadTasks();
  }

  private async loadTasks() {
    const res = await fetch("/api/tasks");
    if (res.ok) this.tasks.set(await res.json());
  }

  render() {
    return html`<p>${this.tasks().length} tasks</p>`;
  }
}

onMount and onDestroy are also importable as standalone functions from @kyln/core for use outside a class.

Next

Data loading and shared logic belong in services. Services & DI covers how Kyln wires them.