Back to Blog
guidereferencedocumentation

The Complete OlumJS Guide

KN

Kai Nakamura

Core Team

Jul 11, 2026

12 min read

One design principle

OlumJS draws a single line through its whole template syntax: is an attribute fundamentally code or a string? Native HTML already answers it — onclick="" is code, class="" is a string.

  • Code-shaped attributes — when, each, key, on*, html — take a JavaScript expression directly inside the quotes.
  • Everything else is a literal string, with {expr} for the dynamic bits.

That's the entire mental model. No naked braces, no colon-bindings, no directives to memorize. This guide walks every feature the same way: a sentence, then a snippet.

Component file structure

A component is one .html file with up to three parts: a <script> for logic, a scoped <style>, and the template (everything else). State, methods, and props are all in the same closure, so the template can reference them directly. Component tag names are PascalCase — that's how the compiler tells a component from a plain element.

<!-- Counter.html -->
<script>
  const state = { count: 0 };
  const inc = () => state.count++;
</script>

<style>
  .counter { font-weight: 700; }
</style>

<div class="counter">
  Count: {state.count}
  <button onclick="inc()">+</button>
</div>

State & reactivity

Declare reactive state as const state = { … }. Mutating or reassigning state re-renders the component. Only state is reactive — plain const/let variables are not tracked.

<script>
  const state = { count: 0, user: { name: "Ann" } };

  const inc = () => state.count++;               // mutate → re-render
  const rename = () => (state.user = { name: "Bo" });
</script>

<p>{state.count}{state.user.name}</p>

Text interpolation

Any {expr} in text is evaluated as JavaScript and HTML-escaped by default, so it's XSS-safe. null and undefined render as an empty string. There is no escape for a literal { — every {…} is treated as interpolation; use {String.fromCharCode(123)} if you need a literal brace.

<p>Hello {state.user.name}</p>
<p>Total: {state.items.length} items</p>
<p>{state.count > 0 ? 'positive' : 'zero'}</p>

Watchers

Declare const watcher = { … } with a function per state key. It fires whenever that key changes, receiving (oldValue, newValue) — handy for derived logging or side effects.

<script>
  const state = { count: 0, log: "—" };
  const watcher = {
    count(old, next) {
      state.log = `count: ${old} → ${next}`;
    },
  };
</script>

Conditionals: if / else-if / else

when is always a JS expression. The element is physically added to or removed from the DOM as the condition changes.

<if when="state.tab === 'a'">
  <p>Tab A</p>
</if>
<else-if when="state.tab === 'b'">
  <p>Tab B</p>
</else-if>
<else>
  <p>Fallback</p>
</else>

Show / hide

Like <if>, but the element stays in the DOM and only its display is toggled. Reach for <show> when you want to preserve DOM state or avoid re-mounting cost; reach for <if> when the element should truly not exist.

<show when="state.visible">
  <div class="panel">Stays in the DOM; only display toggles.</div>
</show>

Loops: for

each is a JS expression with three forms — array (item of list), numeric range (i of N, where i runs 1 → N), and object keys (key in obj). When the loop body is a component, add key so instances are reused by identity across reorders instead of by position. On plain elements, key is a harmless no-op.

<!-- array -->
<for each="fruit of state.fruits">
  <li>{fruit.name}</li>
</for>

<!-- numeric range: i goes 1 → N -->
<for each="i of 6"><span>Step {i}</span></for>

<!-- object keys -->
<for each="key in state.settings">
  <div>{key}: {state.settings[key]}</div>
</for>

<!-- keyed (only matters for component loops) -->
<for each="todo of state.todos" key="todo.id">
  <TodoRow todo="{todo}" />
</for>

Events

Native on* attributes hold code, just like real HTML. Use a method call, or an inline arrow that gets extracted into a method automatically. Pass the DOM event with $event. There are no event modifiers — call e.preventDefault() in the handler instead.

<button onclick="inc()">+</button>
<button onclick="inc(), log()">multi</button>
<input oninput="(e) => state.text = e.target.value" />
<input oninput="setValue($event)" />
<form onsubmit="(e) => { e.preventDefault(); save() }"></form>

Two-way binding

There is no model attribute — bind manually with a value plus an oninput handler. Explicit, greppable, and it works for any form field.

<input
  type="text"
  value="{state.text}"
  oninput="(e) => state.text = e.target.value"
/>
<p>Echo: {state.text}</p>

Attributes: strings vs code

String attributes are the default — a literal string with {expr} inside for the dynamic parts, including inline style. Code attributes (when, each, key, on*, html) evaluate their quoted value as JavaScript.

<!-- string attributes: literal + {expr} -->
<div class="card {state.active ? 'is-active' : ''}"></div>
<a href="/users/{state.user.id}" title="Open {state.user.name}">Profile</a>
<div style="color:{state.color}; background:{state.bg}; padding:8px;">box</div>

<!-- code attributes: value IS an expression -->
<if when="state.count > 0"></if>
<button onclick="inc()">+</button>

Raw HTML (opt-in)

All interpolation is escaped by default. Opt out only for trusted, sanitized HTML, two ways: html="expr" replaces an element's content, and olum.html(value) injects raw HTML inline inside {}. Both are deliberate, greppable bypasses.

Never pass untrusted user content to html= or olum.html() — sanitize first.

<!-- element-level: replaces children with raw HTML -->
<div html="state.articleHtml"></div>

<!-- inline, mixed with text -->
<p>Intro: {olum.html(state.snippetHtml)} — end.</p>

Components & props

Import a child in <script> and use it by its PascalCase tag. A prop is a literal string by default; prop="{expr}" passes the expression's real value and type; and {} inside text yields an interpolated string.

<!-- Parent.html -->
<script>
  import StatusBadge from "./StatusBadge";
  const state = { name: "Ann", score: 5 };
</script>

<StatusBadge
  label="Online"            <!-- "Online"  → string              -->
  count="{state.score}"     <!-- 5         → number (type kept)   -->
  data="{state.user}"       <!-- {...}     → object (type kept)   -->
  greet="Hi {state.name}"   <!-- "Hi Ann"  → interpolated string  -->
/>

Reading props in the child

Import props from olum and call it at the top level of <script> — no onMount required. Destructuring takes a one-time snapshot (the initial value); call props() again wherever you need the latest value after a parent re-render. Writing props().x = value (not a destructured local) propagates the change back up to the owner's state.

<!-- StatusBadge.html -->
<script>
  import { props } from "olum";
  const { label, color } = props();   // one-time snapshot (INITIAL)
</script>

<!-- call props() again for the LATEST value after a parent re-render -->
<span class="badge" style="color:{props().color}">{props().label}</span>

Slots

Content placed between a component's tags is exposed as children on props(). Destructuring snapshots it once; render props().children directly in the template to always reflect the latest slot content.

<!-- parent -->
<CounterCard title="Score">
  <em>passed from the parent</em>
</CounterCard>

<!-- CounterCard.html -->
<script>
  import { props } from "olum";
</script>
<div class="slot">{props().children}</div>

Scope: public / private

A component's top-level props and methods are private by default — encapsulated. Add scope keywords to the <script> tag to expose them: public (both), public-props, public-methods, and their private-* counterparts. An exclude-<name> attribute carves a single exception out of the currently open group (camelCase names become kebab-case, e.g. getAge → exclude-get-age). Attributes read left to right, so each keyword opens a group that following exclude-* attach to.

<!-- No attributes → everything PRIVATE (the default) -->
<script></script>

<!-- Expose all props AND methods -->
<script public></script>

<!-- Expose props only; methods stay private -->
<script public-props></script>

<!-- Public props EXCEPT one; private methods EXCEPT one -->
<script public-props exclude-state private-methods exclude-get-age></script>

Scoped CSS

A component's <style> is automatically scoped — selectors only affect that component's own elements, so nothing leaks. Just write normal CSS.

<style>
  .title { color: #4f46e5; }   /* won't leak to other components */
</style>
<h1 class="title">Hello</h1>

Lifecycle: onMount & host

Import onMount from olum and pass a callback that runs when the component mounts; return a function from it to run cleanup on unmount (e.g. when an <if> toggles it off or a keyed item is removed). Inside onMount, host refers to this component's own root element — query within it instead of document.querySelector, which could match another instance.

<script>
  import { onMount } from "olum";

  onMount(() => {
    const main = host.querySelector("main");   // host = this component's root
    console.log("mounted");
    return () => console.log("unmounted");     // cleanup on removal
  });
</script>

Escaping & security

OlumJS escapes by default to prevent XSS. Every text and attribute {expr} runs through olum.esc. The only way to render raw HTML is the deliberate opt-out — html="expr" or olum.html() — which you should reserve for trusted, pre-sanitized content.

Common mistakes

OlumJS has no naked braces and one way to do each thing. The patterns you might reach for from other frameworks don't work here:

  • Values for when / each / on* live inside the quotes — write when="x", not when={x}
  • Bind inputs with value="{state.x}" plus an oninput handler — there's no model
  • No prop shorthand or naked-brace props — write a="{a}", not {a} or a={a}
  • Inline styles are a string with {expr}: style="color:{x}" — not :style
  • Components are PascalCase — <Comp/>, not <comp/>
  • Any {…} in text is interpolation — for a literal brace use {String.fromCharCode(123)}
  • No event modifiers — call e.preventDefault() in the handler

Limitations to know

OlumJS is deliberately small, so a few sharp edges come with it — each with a by-design workaround:

  • Re-renders rebuild the whole stateful component (and its children), so untracked DOM state — a playing <video>, a running animation, a focused input — is lost. Keep that reactive state in a parent and let the media/input component stay stateless.
  • There's no integrated unit-testing story yet; test plain JS logic with any external tool.
  • The global store works but is awkward: reach a component through its location key, e.g. olum.app.store["page>App#0"]. For now, use one dedicated store component.
<!-- ✗ state inside the media component → re-render restarts the <video> -->
<!-- ✓ lift state to a parent, keep the media component stateless -->
<!-- Parent.html -->
<script>
  const state = { count: 0 };   // re-renders the counter, not the video
  const inc = () => state.count++;
</script>
<MediaBox />                     <!-- stateless → the <video> is safe -->
<button onclick="inc()">{state.count}</button>

Cheat-sheet

The entire template syntax on one screen — bookmark this block.

<!-- TEXT (auto-escaped) -->
{state.value}
{a > b ? 'x' : 'y'}

<!-- RAW / UNESCAPED HTML (opt out of escaping) -->
<div html="state.articleHtml"></div>
{olum.html(state.snippetHtml)}

<!-- STRING ATTRIBUTES (literal + {expr}) -->
<div class="box {state.cls}" style="color:{state.color}" title="Hi {state.name}"></div>

<!-- EVENTS (code in "") -->
<button onclick="save()">Save</button>
<input oninput="(e)=> state.text = e.target.value" />
<form onsubmit="(e)=> { e.preventDefault(); submit() }"></form>

<!-- CONDITIONALS -->
<if when="state.tab === 'a'"></if>
<else-if when="state.tab === 'b'"></else-if>
<else></else>

<!-- SHOW -->
<show when="state.visible"></show>

<!-- LOOPS -->
<for each="item of state.items" key="item.id"><Row item="{item}" /></for>
<for each="i of 6">{i}</for>
<for each="key in state.map">{key}</for>

<!-- COMPONENTS + PROPS + SLOT -->
<Card title="Hi" count="{n + 1}" data="{state.obj}">
  <span>slot content → {children}</span>
</Card>

<!-- PROPS (in the child's <script>) -->
const { title, children } = props();  // INITIAL snapshot, no onMount needed
{props().title}                       // LATEST value, call anywhere
{props().children}                    // LATEST slot content

<!-- host: this component's root element, inside onMount -->
onMount(() => { host.querySelector("main"); });

Where to go next

That's the whole framework, summarized. For the full detail on any topic, read the docs; to try it hands-on, open the Playground and edit a live example. When you're ready to build something, the todo-app pattern — state, a list, events, and a conditional empty state — is a great first project.

KN

Kai Nakamura

Core Team · Olum Team