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 → 5When 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.