Chapter 3: HTML Templates#
DOM Without the Framework Tax#
Open your browser's DevTools. Inspect a React application. You see a <div id="root"> containing a tree of elements that a virtual DOM diffing algorithm built for you. Somewhere between your JSX and those elements, a reconciler compared two trees, computed a minimal set of patches, and applied them. For a counter displaying the number 5.
tina4-js skips all of that. The html tagged template creates real DOM nodes. It binds signals to specific text nodes and attributes. When a signal changes, that one text node updates. No tree comparison. No patch computation. No framework standing between your data and your DOM.
This chapter covers every binding syntax in the html tagged template -- reactive text, reactive blocks, events, boolean attributes, property bindings, and class bindings. You will know which ones update and which ones are static, and you will never fall into the false/null rendering trap.
1. What html`` Does#
import { html } from 'tina4js';โconst fragment = html`<h1>Hello, World!</h1>`;document.body.appendChild(fragment);html is a tagged template literal function. It parses your markup once, caches the result, then clones it for each call. The return value is a real DocumentFragment -- actual DOM nodes. Not a string. Not a virtual tree. Not an intermediate representation.
No render() loop. No reconciliation. No diffing. The template creates DOM, binds signals to specific nodes, and walks away. Updates happen through direct signal subscriptions on individual nodes. The framework is not in the middle of the conversation between your data and your page.
2. Static Values: ${value}#
Plain values go in as text nodes. Once. Never again.
const name = 'Alice';html`<p>Hello, ${name}</p>`The template evaluates name, gets the string "Alice", creates a text node, and inserts it. If name changes later, the DOM does not update. It is static.
Everything is XSS-safe by default. Values are inserted as text nodes, not HTML:
const userInput = '<script>alert("xss")</script>';html`<p>${userInput}</p>`// Renders as the literal text: <script>alert("xss")</script>// NOT executed as HTMLYou cannot accidentally inject HTML through interpolation. This is by design.
3. Reactive Text: ${signal}#
Pass a signal directly (not .value) to create a reactive text node:
import { signal, html } from 'tina4js';โconst count = signal(0);โhtml`<p>Count: ${count}</p>`When count.value changes, the text node updates. Nothing else in the DOM moves. The <p> element stays. The "Count: " text stays. Only the number changes.
This is the most common pattern in tina4-js. One signal. One text node. Live updates forever.
Critical rule: Pass the signal, not its value.
// WRONG -- static, evaluates oncehtml`<p>${count.value}</p>`โ// RIGHT -- reactive, updates on changehtml`<p>${count}</p>`When you write ${count.value}, JavaScript evaluates count.value (gets 0) and passes the number 0 to the template. The template sees a plain number, not a signal. It creates a static text node.
When you write ${count}, JavaScript passes the signal object. The template detects it (via isSignal()), creates a text node, and subscribes to changes. When the signal updates, the text node updates.
4. Reactive Blocks: ${() => expr}#
Functions are reactive blocks. The function runs immediately, and re-runs whenever any signal read inside it changes:
const show = signal(true);const name = signal('Alice');โhtml` <div> ${() => show.value ? html`<p>Hello, ${name}!</p>` : null } </div>`When show or name changes, the function re-runs. The previous nodes are removed and new nodes are inserted. This is how you do conditional rendering and lists.
Conditional Rendering#
const isLoggedIn = signal(false);โhtml` ${() => isLoggedIn.value ? html`<p>Welcome back!</p>` : html`<a href="/login">Log in</a>` }`Lists#
const items = signal(['Apple', 'Banana', 'Cherry']);โhtml` <ul> ${() => items.value.map(item => html`<li>${item}</li>`)} </ul>`When items changes, the entire <ul> content is replaced. Old nodes out. New nodes in. This is not keyed reconciliation like React -- it is a full swap. For lists under a few hundred items, the browser handles this in under a millisecond. For massive lists, consider a virtualization library.
Nested Reactivity#
Reactive blocks can contain signals:
const items = signal([ { name: signal('Apple'), price: signal(1.50) },]);โhtml` <ul> ${() => items.value.map(item => html` <li>${item.name} - $${item.price}</li> `)} </ul>`Each ${item.name} is a reactive text node. If you change items.value[0].name.value = 'Pear', only that text node updates. The list does not re-render.
5. DocumentFragments and Arrays#
You can nest templates and pass arrays:
const header = html`<h1>Title</h1>`;const items = ['one', 'two', 'three'];โhtml` ${header} <ul> ${items.map(i => html`<li>${i}</li>`)} </ul>`DocumentFragment values are inserted directly. Arrays are flattened -- each item is converted to nodes.
6. Event Handlers -- @event#
The @ prefix binds event listeners:
html` <button @click=${() => console.log('clicked!')}>Click me</button> <input @input=${(e: Event) => console.log((e.target as HTMLInputElement).value)} /> <form @submit=${(e: Event) => { e.preventDefault(); handleSubmit(); }}> ... </form>`Any DOM event works. @click. @input. @change. @submit. @keydown. @mouseenter. @focus. @blur. If the browser fires it, tina4-js can bind it.
Auto-Batching#
Since v1.0.9, all event handlers are automatically wrapped in batch(). This means you can write to multiple signals in one handler and only get one DOM update:
html` <button @click=${() => { firstName.value = 'Bob'; lastName.value = 'Jones'; age.value = 30; }}>Update All</button>`// Three signal writes, one DOM updateYou do not need explicit batch() inside event handlers. It happens automatically.
7. Boolean Attributes -- ?attr#
A button should be disabled while a form submits. A div should be hidden until data loads. A checkbox should be checked when a task is done. HTML boolean attributes -- disabled, hidden, checked, readonly, required -- need the ? prefix:
const isDisabled = signal(false);const isHidden = signal(true);โhtml` <button ?disabled=${isDisabled}>Submit</button> <div ?hidden=${isHidden}>Secret content</div>`When the value is truthy, the attribute is added (e.g., <button disabled>). When falsy, the attribute is removed entirely.
Without the ? prefix, you get a string attribute:
// WRONG -- sets disabled="false" (which is still disabled!)html`<button disabled=${isDisabled}>Submit</button>`โ// RIGHT -- adds/removes the disabled attributehtml`<button ?disabled=${isDisabled}>Submit</button>`This distinction catches everyone at least once. In HTML, <button disabled="false"> is still disabled. The attribute exists. The browser does not read its value. The ? prefix solves this by adding or removing the attribute entirely -- present means true, absent means false.
Boolean attributes accept signals, functions, and computed values:
// Signalhtml`<button ?disabled=${isDisabled}>Submit</button>`โ// Functionhtml`<button ?disabled=${() => items.value.length === 0}>Submit</button>`โ// Computedconst canSubmit = computed(() => name.value.length > 0);html`<button ?disabled=${() => !canSubmit.value}>Submit</button>`8. Property Bindings -- .prop#
The . prefix sets DOM properties (not HTML attributes):
const inputValue = signal('hello');โhtml` <input .value=${inputValue} /> <div .innerHTML=${html`<strong>Bold text</strong>`} />`.value is the most common property binding. It sets the input's value property directly, which is different from the value attribute (the attribute is the initial value; the property is the current value).
.innerHTML is how you inject raw HTML. This is the only way to render HTML strings or inline SVG in tina4-js:
const svgIcon = '<svg viewBox="0 0 24 24"><path d="M12 2L2 22h20L12 2z"/></svg>';โ// WRONG -- renders as escaped texthtml`<div>${svgIcon}</div>`// Shows: <svg viewBox="0 0 24 24">...โ// RIGHT -- renders as HTMLhtml`<div .innerHTML=${svgIcon}></div>`// Shows the actual SVG triangleWarning: .innerHTML bypasses XSS protection. Only use it with trusted content. Never pass user input to .innerHTML.
When a signal is passed to a property binding, it updates reactively:
const content = signal('<em>Loading...</em>');โhtml`<div .innerHTML=${content}></div>`โ// Later:content.value = '<em>Done!</em>';// The div's innerHTML updatesCRITICAL: Never Put Inputs Inside Reactive Blocks#
Inputs inside
${() => ...}lose focus on every keystroke. The most common mistake in tina4-js is wrapping<input>elements inside reactive arrow functions. When a signal changes, the reactive block re-renders its entire DOM subtree, destroying the input and creating a new one. The cursor disappears, the input loses focus, and typing is interrupted.
BAD - input inside a reactive block (broken):
const name = signal('');const showExtra = signal(false);โ// WRONG: the input is INSIDE ${() => ...} - it gets destroyed on every keystrokehtml` <div> ${() => html` <input .value=${name} @input=${(e: Event) => { name.value = (e.target as HTMLInputElement).value; }} /> ${showExtra.value ? html`<p>Extra info</p>` : null} `} </div>`;GOOD - input outside, only dynamic content in reactive blocks:
const name = signal('');const showExtra = signal(false);โ// RIGHT: the input stays in the static template - only the conditional goes in ${() => ...}html` <div> <input .value=${name} @input=${(e: Event) => { name.value = (e.target as HTMLInputElement).value; }} /> ${() => showExtra.value ? html`<p>Extra info</p>` : null} </div>`;The rule: Inputs, textareas, and selects belong in the static part of your template. Use .value, @input, and ?disabled bindings to make them reactive. Only wrap computed output (conditional messages, dynamic lists, calculated text) in ${() => ...} blocks.
9. Dynamic Attributes: attr=${value}#
Regular attributes accept signals and functions for reactive updates:
const color = signal('red');const className = signal('active');โhtml` <div class=${className} style=${() => `color: ${color.value}`}> Styled text </div>`When className changes, the class attribute updates. When color changes, the style attribute updates.
Reactive Classes#
const isActive = signal(true);โ// Simple string signalconst cls = signal('btn btn-primary');html`<button class=${cls}>Click</button>`โ// Function-basedhtml`<div class=${() => isActive.value ? 'tab active' : 'tab'}>Tab</div>`10. The false/null/undefined Trap#
You will hit this. Everyone does. Here are the rules:
${false} // Renders nothing (empty) - treated the same as null${null} // Renders nothing (empty)${undefined} // Renders nothing (empty)${true} // Renders the TEXT "true"${0} // Renders the TEXT "0"false, null, and undefined all render as nothing; they are explicitly swallowed by the template engine. Every other value is converted to text via String(value).
This means the && shortcut actually works for hiding content: false disappears cleanly:
// This works -- false renders nothinghtml`${() => show.value && html`<p>Content</p>`}`โ// This also works -- explicit ternary is clearer and preferredhtml`${() => show.value ? html`<p>Content</p>` : null}`The ternary is still the recommended style because intent is explicit and it handles both branches. But unlike React, you will not see the stray text "false" appear in the DOM if you forget.
11. Template Caching#
The html tag caches parsed templates by the identity of the template strings array. The first call parses the HTML and creates a <template> element. Subsequent calls with the same template literal clone the cached template and bind fresh values.
This means:
- Templates in loops are only parsed once
- Cloning a
<template>is fast (browser-native) - The overhead per render is binding, not parsing
You do not need to do anything to benefit from this. It is automatic.
12. Putting It All Together#
Static text. Reactive text. Reactive blocks. Event handlers. Boolean attributes. Property bindings. Conditional rendering. Here they all are, working together in a login form:
import { signal, computed, html } from 'tina4js';โfunction loginForm() { const email = signal(''); const password = signal(''); const loading = signal(false); const error = signal<string | null>(null);โ const isValid = computed(() => email.value.includes('@') && password.value.length >= 8 );โ const handleSubmit = async (e: Event) => { e.preventDefault(); loading.value = true; error.value = null;โ try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.value, password: password.value, }), });โ if (!response.ok) throw new Error('Invalid credentials'); // handle success... } catch (err) { error.value = (err as Error).message; } finally { loading.value = false; } };โ return html` <form @submit=${handleSubmit}> <h2>Login</h2>โ ${() => error.value ? html`<p style="color: red">${error}</p>` : null }โ <label> Email <input type="email" .value=${email} @input=${(e: Event) => { email.value = (e.target as HTMLInputElement).value; }} ?disabled=${loading} /> </label>โ <label> Password <input type="password" .value=${password} @input=${(e: Event) => { password.value = (e.target as HTMLInputElement).value; }} ?disabled=${loading} /> </label>โ <button type="submit" ?disabled=${() => !isValid.value || loading.value} > ${() => loading.value ? 'Logging in...' : 'Login'} </button> </form> `;}Every binding type from this chapter appears in this form:
.value=${email}-- property binding keeps the input's DOM property in sync with the signal@input-- event handler updates the signal when the user types?disabled=${loading}-- boolean attribute toggles from a signal?disabled=${() => !isValid.value || loading.value}-- boolean attribute from a function${() => loading.value ? 'Logging in...' : 'Login'}-- reactive text block swaps the button label${() => error.value ? html\...\: null}-- conditional rendering with the ternary pattern
One template. Six binding types. Zero manual DOM updates. The template engine handles every transition between states, and the form responds the moment data changes.
Summary#
| Syntax | What it does | Reactive? | ||
|---|---|---|---|---|
${value} | Static text node, XSS-safe | No | ||
${signal} | Reactive text node | Yes | ||
${() => expr} | Reactive block (conditionals, lists) | Yes | ||
${fragment} | Insert DocumentFragment | No | ||
${array} | Render each item | No | ||
| `${false\ | null\ | undefined}` | Renders nothing (empty) | - |
@click=${fn} | Event listener (auto-batched) | - | ||
?disabled=${x} | Boolean attribute (add/remove) | If signal/function | ||
.value=${x} | DOM property binding | If signal | ||
.innerHTML=${x} | Raw HTML injection | If signal | ||
class=${x} | Regular attribute | If signal/function |