HTTL-S Documentation

HTTL-S (HyperText Templating Language — Simple) is a zero-dependency, no-build-step JavaScript library that brings reactivity to vanilla HTML. Think of it like a lightweight alternative to React or Vue, but without JSX, virtual DOM, or a build pipeline. You write plain HTML with custom elements and template expressions, include one <script> tag, and everything just works.

Who is this for? Developers who want reactive UI without the complexity of a full framework. Perfect for landing pages, admin panels, dashboards, prototypes, and small-to-medium web apps.

What HTTL-S gives you

Installation

HTTL-S is a single JavaScript file. You have two options:

Option 1: CDN (recommended for getting started)

HTML
<script src="https://cdn.jsdelivr.net/gh/KTBsomen/httl-s@main/statejs.js"></script>

Option 2: Local copy

Download statejs.js from the GitHub repo and reference it locally:

HTML
<script src="statejs.js"></script>

Tip: Place the <script> tag before your application script but after your HTML body content, so the DOM is ready when HTTL-S initializes.

Hello World

Here's the simplest possible HTTL-S app — a counter that increments when you click a button:

hello.html
<!DOCTYPE html>
<html>
<body>
  <h1>Hello HTTL-S!</h1>

  <!-- This element re-renders whenever "count" changes -->
  <state-element stateId="counter">
    <template stateId="counter">
      <p>Count: {{count}}</p>
    </template>
  </state-element>

  <button onclick="count++">Increment</button>

  <script src="https://cdn.jsdelivr.net/gh/KTBsomen/httl-s@main/statejs.js"></script>
  <script>
    // 1. Create a reactive variable called "count" with initial value 0
    watch('count', (name, value) => {
      // 2. When "count" changes, re-render the state-element with stateId="counter"
      setState({ stateId: 'counter', showloader: false });
    }, 0);

    // 3. Initialize all custom elements
    initState();
  </script>
</body>
</html>

How it works, line by line:

  1. watch('count', callback, 0) — creates a global variable count with initial value 0. A deep proxy watches for any changes.
  2. When you click the button, count++ triggers the proxy's setter, which schedules the callback.
  3. The callback calls setState({ stateId: 'counter' }), which finds the <state-element stateId="counter"> and re-renders its template.
  4. The template contains {{count}}, which parseTemplate() evaluates to the current value of count.
  5. initState() registers all custom elements and triggers the first render.

Project Setup

HTTL-S doesn't need a build tool. Here's a recommended folder structure:

Folder Structure
my-app/
├── index.html          ← Main page
├── statejs.js          ← HTTL-S library (local copy)
├── components/
│   ├── header.html     ← Reusable header (loaded via include-template)
│   └── footer.html     ← Reusable footer
├── scripts/
│   └── app.js          ← Your application logic
└── styles/
    └── style.css       ← Your styles

Serving locally

You need a local HTTP server because <include-template> uses fetch() which doesn't work with file:// protocol. The simplest option:

Terminal
npx -y http-server . -p 8080 --cors -c-1

VS Code Setup

For the best developer experience with HTTL-S:

FOUC prevention: Custom elements like <for-loop> may briefly show raw content before they're defined. Add this CSS to your <head> to prevent the flash:

CSS — Anti-FOUC
<style>
  for-loop:not(:defined),
  condition-block:not(:defined),
  state-element:not(:defined),
  include-template:not(:defined) {
    visibility: hidden;
  }
</style>

Mental Model

HTTL-S works fundamentally differently from React, Vue, or Angular. Understanding this mental model will save you hours of debugging.

The core loop

HTTL-S has an extremely simple architecture:

watch() creates reactive global variable changes callback fires setState() DOM re-renders

Key mental model differences from React/Vue

Concept React/Vue HTTL-S
State Component-scoped (useState, ref) Global variables on window
Re-rendering Automatic via virtual DOM diff Manual — you call setState() to re-render
Template syntax JSX / v-for Custom HTML elements (<for-loop>)
Components Functions/classes HTML files loaded via <include-template>
Build step Required (webpack, vite) None — works directly in browser
Change detection Virtual DOM diff / signals ES6 Proxy on watched variables

Everything is global

When you call watch('count', cb, 0), HTTL-S creates window.count. This means:

Why globals? HTTL-S is designed for simplicity. Globals let you write onclick="count++" directly in HTML without any binding boilerplate. For larger apps, use naming conventions like watch('app_users', ...) to namespace your state.

Reactivity Flow (Deep Dive)

Understanding exactly what happens when you change a watched variable is critical. Here's the complete flow:

Step 1: watch() creates a deep proxy

When you call watch('todos', cb, []):

  1. HTTL-S stores the initial value []
  2. If the value is an object or array, it wraps it in a deep proxy using createDeepProxy()
  3. It defines a getter/setter on window.todos
  4. Any access to todos returns the proxied version

Step 2: Mutation triggers the callback

The deep proxy intercepts any mutation at any depth:

JavaScript
// All of these trigger the callback:
todos.push({ text: 'Buy milk', done: false });  // array push
todos[0].done = true;                           // nested property change
todos = [...todos, newItem];                    // full reassignment
delete todos[0];                                // deletion

Step 3: Batching with microtasks

If you make multiple mutations in the same synchronous block, the callback fires only once:

JavaScript
// These three mutations fire the callback ONCE, not three times:
todos.push('A');
todos.push('B');
todos.push('C');
// → callback fires once after this synchronous block completes

This works because HTTL-S uses queueMicrotask() for batching. The callback is scheduled but only executed once the current microtask queue flushes.

Step 4: Your callback calls setState()

The callback receives (propName, newValue) and is responsible for deciding what to re-render:

JavaScript
watch('todos', (name, value) => {
  // Re-render only loops and conditions, skip loader
  setState({
    loops: true,        // re-render all <for-loop> elements
    conditions: true,   // re-render all <condition-block> elements
    showloader: false,  // don't show loading spinner
    dataloops: true,    // re-render data-loop tables
  });
}, []);

Common mistake: Forgetting to call setState() inside the callback. If you don't call it, nothing re-renders — the variable changes but the DOM stays stale.

Template Expressions

HTTL-S has two template expression syntaxes, and they serve different purposes:

Syntax Used In Example
{{expression}} <state-element>, <condition-block>, <include-template> {{count}}, {{user.name}}
${expression} <for-loop> templates, data-loop templates ${item.name}, ${i + 1}

Why two syntaxes?

${expr} in loop templates is processed before parseTemplate() runs. The loop engine uses string replacement to substitute loop variables, then parseTemplate() evaluates any remaining {{expr}} for global state access.

HTML — Mixing both syntaxes
<for-loop array="items" valueVar="item" indexVar="i" loopid="myloop">
  <template loopid="myloop">
    <p>${i}: ${item.name} — Total items: {{items.length}}</p>
  </template>
</for-loop>

Here, ${i} and ${item.name} are replaced by the loop engine per iteration. {{items.length}} is evaluated by parseTemplate() against the global state.

Pitfall: Using {{expr}} inside a for-loop template for loop variables won't work. {{item.name}} will fail because item is a loop-scoped variable, not a global. Use ${item.name} instead.

Render Lifecycle

When initState() is called, here's exactly what happens in order:

  1. Register custom elementsfor-loop, condition-block, if-condition, else-condition, state-element, include-template
  2. Call renderDataLoops() — finds all [data-loop] elements and renders them
  3. Call setState() with defaults — this triggers the initial render of all for-loops, condition-blocks, data-js, data-innerhtml, and data-loops

Critical rule: All watch() calls must come before initState(). If you call watch() after initState(), the initial render won't have access to those variables, leading to template evaluation errors.

setState() default render order

When setState() is called with no arguments (or with defaults), it renders in this order:

  1. Specific elements (if loopid, ifid, or stateId is provided)
  2. data-js attributes
  3. data-innerhtml attributes
  4. All <for-loop> elements
  5. All [data-loop] elements
  6. All <condition-block> elements
  7. Show/hide the loader

watch(propName, callback, defaultValue)

Creates a reactive global variable. This is the entry point for all reactivity in HTTL-S. When the variable changes (at any depth), the callback is automatically invoked.

watch(propName: string, callback: (name: string, value: any) => void, defaultValue?: any)
Parameter Type Description
propName required string Name for the global variable. Creates window[propName]. Must be unique — calling watch() twice with the same name logs a warning and skips the second call.
callback required function Called whenever the variable changes. Receives (propName, newValue). You should call setState() here to trigger re-renders. Batched via microtask — multiple synchronous mutations fire the callback once.
defaultValue optional any Initial value. If it's an object or array, it's wrapped in a deep proxy that detects nested property changes. Default: undefined.

Deep proxy behavior

When the value is an object or array, HTTL-S creates a recursive ES6 Proxy. This means:

Self-mutation protection

If a watcher callback modifies its own watched variable (directly or indirectly), HTTL-S detects this and prevents infinite loops. The mutation succeeds but doesn't re-trigger the same callback.

JavaScript — Complete example
// Watch an array of users
watch('users', (name, value) => {
  console.log(`${name} changed, now has ${value.length} users`);
  setState({
    loops: true,       // re-render for-loops showing users
    dataloops: true,   // re-render data-loop tables
    conditions: true,  // re-render condition blocks (e.g. "empty" message)
    showloader: false  // don't show spinner for quick updates
  });
}, [
  { name: 'Alice', role: 'admin' },
  { name: 'Bob', role: 'user' }
]);

// All of these trigger the callback:
users.push({ name: 'Charlie', role: 'user' });
users[0].role = 'superadmin';
users = users.filter(u => u.role !== 'user');

Pitfall — Duplicate watch names: If you accidentally call watch('count', cb1, 0) and watch('count', cb2, 10), the second call is silently ignored. The variable keeps the first callback and initial value. Check the console for a warning message.

setState(options)

The central UI update function. Tells HTTL-S which parts of the DOM to re-render. You almost always call this inside a watch() callback.

setState({ loopid, stateId, ifid, states, showloader, datajs, innerhtml, loops, dataloops, templates, conditions })
Option Type Default Description
loopid string|false false Re-render only the <for-loop> with this specific loopid
stateId string|false false Re-render only the <state-element> with this specific stateId
ifid string|false false Re-render only the <condition-block> with this specific ifid
states boolean false Re-render all <state-element> elements
showloader boolean true Show/hide the loading spinner during render
datajs boolean true Execute all [data-js] attribute expressions
innerhtml boolean true Update all [data-innerhtml] elements
loops boolean true Re-render all <for-loop> elements
dataloops boolean true Re-render all [data-loop] table loops
templates boolean false Re-render all <include-template> elements (re-fetches HTML)
conditions boolean true Re-render all <condition-block> elements

Performance tip: Avoid calling setState() with all defaults when only one thing changed. Use targeted options like setState({ stateId: 'counter', showloader: false }) for better performance. The defaults re-render everything, which is expensive on complex pages.

Pitfall — showloader: true is the default. If you call setState({ loops: true }) without showloader: false, a full-screen loading spinner appears briefly. Always add showloader: false for instantaneous updates.

Targeted vs. broadcast rendering

JavaScript — Targeted rendering
// ✅ GOOD — Only re-render the specific counter element
setState({ stateId: 'counter', showloader: false });

// ✅ GOOD — Only re-render one specific loop
setState({ loopid: 'userList', showloader: false });

// ⚠️ OK — Re-render all loops and conditions (broad)
setState({ loops: true, conditions: true, showloader: false });

// ❌ BAD — Re-renders everything with spinner flash
setState();

initState()

Registers all HTTL-S custom elements with the browser and triggers the initial render. Call this exactly once, after all watch() declarations.

initState() : void

What it does internally:

  1. Registers 6 custom elements: for-loop, include-template, if-condition, else-condition, condition-block, state-element
  2. Calls renderDataLoops() to render all [data-loop] elements
  3. Calls setState() with defaults to trigger the first full render

Order matters! Always follow this pattern:

JavaScript — Correct order
// 1. First: declare ALL watched variables
watch('count', cb1, 0);
watch('items', cb2, []);
watch('user', cb3, { name: 'Guest' });

// 2. Last: initialize (registers elements + first render)
initState();

parseTemplate(template)

Evaluates {{expression}} placeholders in a string, replacing them with the result of the JavaScript expression. This is the engine behind all template rendering in HTTL-S.

parseTemplate(template: string) : string
Parameter Type Description
template required string String containing {{expression}} placeholders to evaluate

How expressions are evaluated

Inside {{ }}, you can use any valid JavaScript expression:

JavaScript
parseTemplate('Hello {{name}}!')         // → "Hello Alice!"
parseTemplate('{{2 + 3}}')                // → "5"
parseTemplate('{{arr.length}} items')     // → "3 items"
parseTemplate('{{flag ? "Yes" : "No"}}')  // → "Yes" or "No"

The no-parse attribute

If your data contains literal {{ }} syntax (like documentation strings), use the no-parse attribute on the parent element to prevent evaluation:

HTML
<!-- WITHOUT no-parse: tries to evaluate "expression" → error -->
<td>${api.desc}</td>

<!-- WITH no-parse: {{expression}} renders as literal text -->
<td no-parse>${api.desc}</td>

unsafeEval(expression, context)

Evaluates a JavaScript expression string with optional context variables. Used internally by parseTemplate(), but also available for direct use.

unsafeEval(expression: string, context?: object) : any
Parameter Type Description
expression required string JavaScript expression to evaluate
context optional object Key-value pairs made available as local variables during evaluation. Default: {}
JavaScript
unsafeEval('a + b', { a: 1, b: 2 })  // → 3
unsafeEval('name.toUpperCase()', { name: 'alice' })  // → "ALICE"
unsafeEval('count * 2')  // uses global `count` variable

Security note: This function uses new Function() internally. Never pass user-supplied strings to unsafeEval() in production. It's designed for template expressions defined by the developer, not for end-user input.

createRangeArray(start, end, step)

Creates an array of sequential numbers. Useful for numeric for-loops.

createRangeArray(start: number, end: number, step?: number) : number[]
JavaScript
createRangeArray(1, 5)      // → [1, 2, 3, 4, 5]
createRangeArray(0, 10, 2)  // → [0, 2, 4, 6, 8, 10]
createRangeArray(5, 1, -1)  // → [5, 4, 3, 2, 1]

renderDataLoops()

Manually triggers rendering of all [data-loop] elements. Called automatically by initState() and setState({ dataloops: true }).

renderDataLoops() : void

How data-loop works internally:

  1. Finds all elements with [data-loop] attribute
  2. Gets the array from the global variable named in data-loop
  3. Gets the template from the selector in data-template
  4. For each item, replaces ${valueVar} and ${indexVar} in the template
  5. Runs parseTemplate() on the result for {{expr}} evaluation
  6. Sets the element's innerHTML to the combined output

parseURL(url, global)

Parses a URL and extracts its components. By default, parses the current page URL and stores the result in window.UrlDetails.

parseURL(url?: string, global?: boolean) : URLDetails
Parameter Type Default Description
url string location.href URL to parse
global boolean true If true, stores result in window.UrlDetails

Return value

JavaScript
// For URL: https://example.com/page?name=Alice&count=5#section
parseURL()
// → {
//   protocol: "https:",
//   hostname: "example.com",
//   pathname: "/page",
//   hash: "#section",
//   params: { name: "Alice", count: 5 }  // auto-parsed JSON
// }

Auto-parsed params: Query parameter values are automatically parsed with JSON.parse(). So ?count=5 gives params.count === 5 (number, not string), and ?active=true gives params.active === true (boolean).

loader.show() / loader.hide()

A built-in full-screen loading spinner overlay. Used automatically by setState() when showloader: true (the default).

JavaScript
loader.show();              // show default spinner
loader.show('<p>Loading...</p>');  // show custom HTML
loader.hide();              // remove spinner

Helper Functions

getType(input)

Returns the type of a value. Unlike typeof, correctly identifies arrays and null.

JavaScript
getType([1, 2, 3])   // → "array" (typeof would say "object")
getType(null)        // → "null"  (typeof would say "object")
getType("hello")    // → "string"
getType(42)          // → "number"

passValue(value)

URI-encodes any value for safe URL parameter passing. Objects and arrays are JSON-stringified first.

JavaScript
passValue('hello')           // → "hello"
passValue({ a: 1, b: 2 })  // → "%7B%22a%22%3A1%2C%22b%22%3A2%7D"
passValue([1, 2, 3])        // → "%5B1%2C2%2C3%5D"

<for-loop>

Renders a block of HTML once for each item in an array. Similar to v-for in Vue or .map() in React, but declared directly in HTML.

Attribute Type Default Description
array required string Name of the global array variable to iterate over. Can be an expression like "users.filter(u => u.active)".
loopid required string Unique identifier for this loop. The inner <template> must have a matching loopid attribute.
valueVar optional string "value" Name of the variable representing the current item in each iteration.
indexVar optional string "index" Name of the variable representing the current index (0-based).
start optional number For numeric loops: start value (used with end instead of array)
end optional number For numeric loops: end value (inclusive)
step optional number 1 For numeric loops: step increment

Array loop example

HTML
<for-loop array="users" valueVar="user" indexVar="i" loopid="userList">
  <template loopid="userList">
    <div class="user-card">
      <h3>${i + 1}. ${user.name}</h3>
      <p>Role: ${user.role}</p>
      <button onclick="removeUser(${i})">Delete</button>
    </div>
  </template>
</for-loop>

Numeric range loop

Instead of an array, you can loop over a range of numbers:

HTML
<!-- Renders pages 1 through 10 -->
<for-loop start="1" end="10" valueVar="page" loopid="pagination">
  <template loopid="pagination">
    <button onclick="goToPage(${page})">${page}</button>
  </template>
</for-loop>

Nested for-loops

Each nested loop must have a unique loopid:

HTML
<for-loop array="categories" valueVar="cat" loopid="catLoop">
  <template loopid="catLoop">
    <h2>${cat.name}</h2>
    <for-loop array="cat.items" valueVar="item" loopid="itemLoop_${cat.id}">
      <template loopid="itemLoop_${cat.id}">
        <p>${item.name} - $${item.price}</p>
      </template>
    </for-loop>
  </template>
</for-loop>

Critical pitfall — Tables: The HTML parser strips custom elements from inside <table>, <thead>, <tbody>. You cannot use <for-loop> inside a table. Use data-loop instead (see data-loop section).

Template must use ${}, not {{}}. Inside a for-loop template, loop variables like item and i are only available via ${} syntax. Use {{}} only for global state.

<condition-block>

Conditional rendering — shows or hides content based on JavaScript expressions. Works like if/else-if/else chains.

Structure overview

A condition-block contains <if-condition> and optionally <else-condition> elements:

HTML — Structure
<condition-block ifid="myCondition">
  <if-condition value="expression" elseid="fallback">
    <!-- shown when expression is truthy -->
  </if-condition>
  <else-condition elseid="fallback">
    <!-- shown when expression is falsy -->
  </else-condition>
</condition-block>

condition-block attributes

Attribute Required Description
ifid Yes Unique identifier for targeted re-rendering via setState({ ifid: 'myId' })

if-condition attributes

Attribute Description
value JavaScript expression to evaluate. If no comparison operator is used, treats the result as a boolean.
eq Strict equality: shows content if value === eq
neq Strict inequality: shows content if value !== neq
gt Greater than: shows content if value > gt
lt Less than: shows content if value < lt
gte Greater or equal: shows content if value >= gte
lte Less or equal: shows content if value <= lte
elseid Links this if-condition to a matching <else-condition>

Boolean condition (simplest)

HTML
<condition-block ifid="auth">
  <if-condition value="isLoggedIn" elseid="loginElse">
    <p>Welcome back, {{username}}!</p>
  </if-condition>
  <else-condition elseid="loginElse">
    <p>Please <a href="/login">log in</a>.</p>
  </else-condition>
</condition-block>

Comparison operators

HTML — All operators
<!-- Equality -->
<if-condition value="status" eq="'active'">Active!</if-condition>

<!-- Inequality -->
<if-condition value="role" neq="'admin'">Not an admin</if-condition>

<!-- Numeric comparisons -->
<if-condition value="score" gt="80">Excellent!</if-condition>
<if-condition value="score" gte="50">Passing</if-condition>
<if-condition value="count" lt="0">Negative!</if-condition>
<if-condition value="age" lte="17">Minor</if-condition>

Chained conditions (if/else-if/else)

HTML
<condition-block ifid="grade">
  <if-condition value="score" gte="90" elseid="gradeB">
    <p>Grade: A</p>
  </if-condition>
  <else-condition elseid="gradeB">
    <condition-block ifid="gradeB-inner">
      <if-condition value="score" gte="80" elseid="gradeC">
        <p>Grade: B</p>
      </if-condition>
      <else-condition elseid="gradeC">
        <p>Grade: C or below</p>
      </else-condition>
    </condition-block>
  </else-condition>
</condition-block>

How it renders: The condition-block evaluates each <if-condition>. If its expression is truthy, that block's content is shown and the matching <else-condition> is hidden. If falsy, the if-condition is hidden and the else-condition is displayed. Content inside these elements is run through parseTemplate() for {{}} expression evaluation.

<state-element>

A re-renderable block of HTML tied to a specific state ID. Unlike condition-block (which shows/hides), state-element completely re-renders its template on each update.

Attribute Required Description
stateId Yes Unique identifier. The inner <template> must have a matching stateId. Re-render via setState({ stateId: 'myId' }).
HTML
<state-element stateId="profile">
  <template stateId="profile">
    <div class="profile-card">
      <h2>{{user.name}}</h2>
      <p>Email: {{user.email}}</p>
      <p>Posts: {{user.posts.length}}</p>
    </div>
  </template>
</state-element>

<script>
  watch('user', () => {
    setState({ stateId: 'profile', showloader: false });
  }, { name: 'Alice', email: 'alice@example.com', posts: [] });
</script>

When to use state-element vs condition-block

Use Case Best Choice
Show/hide based on condition <condition-block>
Display dynamic data that updates <state-element>
If/else rendering <condition-block>
Re-render a section with fresh data <state-element>

<include-template>

Loads an external HTML file and injects its content into the page. Acts as a simple component system — like PHP includes but client-side.

Attribute Required Default Description
file Yes Path to the HTML file to load (relative or absolute URL)
scoped No "true" If "true", the included HTML is placed inside a Shadow DOM for style isolation. Set to "false" for no isolation.
HTML — index.html
<!-- Load the navigation bar from an external file -->
<include-template file="components/nav.html" scoped="false"></include-template>

<main>
  <h1>My App</h1>
</main>

<!-- Load footer with style isolation (Shadow DOM) -->
<include-template file="components/footer.html"></include-template>

Scoped vs. unscoped

Requires HTTP server: <include-template> uses fetch() internally. It won't work with file:// protocol. You must serve your files via an HTTP server.

Template expressions in included files: The included HTML is processed through parseTemplate(), so you can use {{expression}} inside the included file and they'll be evaluated against the global state.

data-loop Attribute

Why it exists: The HTML parser strips unknown elements (like <for-loop>) from inside <table>. The data-loop attribute is the solution for rendering table rows from arrays.

Attribute Description
data-loop="arrayName" Name of the global array variable to iterate over
data-template="#selectorId" CSS selector for the <template> element containing the row template
data-value="varName" Name for the current item variable (default: "value")
data-index="varName" Name for the current index variable (default: "index")
HTML — Table with data-loop
<table>
  <thead>
    <tr><th>#</th><th>Name</th><th>Price</th></tr>
  </thead>
  <tbody data-loop="products"
         data-template="#productRow"
         data-value="product"
         data-index="idx">
  </tbody>
</table>

<!-- Template MUST be OUTSIDE the table -->
<template id="productRow">
  <tr>
    <td>${idx + 1}</td>
    <td>${product.name}</td>
    <td>$${product.price.toFixed(2)}</td>
  </tr>
</template>

Template placement: The <template> element must be outside the <table> tags. If placed inside, the HTML parser may strip or corrupt it.

data-innerhtml Attribute

Sets an element's text content to the result of a JavaScript expression. Evaluated on every setState() call where innerhtml: true (the default).

HTML
<!-- Display a computed value -->
<span data-innerhtml="users.length">0</span> users

<!-- Complex expression -->
<p data-innerhtml="products.reduce((sum, p) => sum + p.price, 0).toFixed(2)">0</p>

<!-- Conditional text -->
<span data-innerhtml="items.length > 0 ? 'Has items' : 'Empty'"></span>

Security: Output is HTML-escaped (replaces < and >) so you cannot inject HTML through this attribute. This is intentional for XSS protection. If you need raw HTML, use data-js instead.

data-js Attribute

Executes JavaScript code on each setState() call where datajs: true (the default). The keyword this inside the expression refers to the DOM element.

HTML
<!-- Toggle class based on state -->
<div data-js="this.classList.toggle('active', isMenuOpen)">Menu</div>

<!-- Set inline styles -->
<div data-js="this.style.width = progress + '%'"></div>

<!-- Update attributes -->
<input data-js="this.disabled = isLoading">

<!-- Set raw innerHTML (not escaped) -->
<div data-js="this.innerHTML = markdownToHtml(content)"></div>

Pitfall: data-js runs on every setState() call. If your expression is expensive (e.g., making API calls), use targeted setState() with datajs: false to skip data-js evaluation when it's not needed.

no-parse Attribute

Prevents parseTemplate() from evaluating {{expression}} syntax inside this element. Content is treated as literal text.

HTML
<!-- Without no-parse: HTTL-S tries to evaluate "myVar" → error -->
<code>Use {{myVar}} to display state</code>

<!-- With no-parse: displays literally as "{{myVar}}" -->
<code no-parse>Use {{myVar}} to display state</code>

When to use: Documentation pages, code snippets, or any content that contains literal {{ }} syntax that shouldn't be evaluated.

Example: Counter

The simplest reactive app — demonstrates watch(), setState(), and <state-element>.

counter.html
<state-element stateId="counter">
  <template stateId="counter">
    <h1>{{count}}</h1>
  </template>
</state-element>
<button onclick="count--">-</button>
<button onclick="count = 0">Reset</button>
<button onclick="count++">+</button>

<script src="statejs.js"></script>
<script>
  watch('count', () => {
    setState({ stateId: 'counter', showloader: false });
  }, 0);
  initState();
</script>

Example: Todo List

Demonstrates array reactivity, <for-loop>, <condition-block> for empty state, and event handling.

todo.html
<input type="text" id="todoInput" placeholder="Add a task...">
<button onclick="addTodo()">Add</button>

<!-- Show "no items" message when empty -->
<condition-block ifid="emptyCheck">
  <if-condition value="todos.length" eq="0">
    <p>No tasks yet. Add one above!</p>
  </if-condition>
</condition-block>

<!-- List of todos -->
<for-loop array="todos" valueVar="todo" indexVar="i" loopid="todoList">
  <template loopid="todoList">
    <div style="display:flex; gap:10px; align-items:center">
      <input type="checkbox" onclick="toggleTodo(${i})"
             ${todo.done ? 'checked' : ''}>
      <span style="${todo.done ? 'text-decoration:line-through' : ''}">
        ${todo.text}
      </span>
      <button onclick="removeTodo(${i})">✕</button>
    </div>
  </template>
</for-loop>

<script src="statejs.js"></script>
<script>
  watch('todos', () => {
    setState({ loops: true, conditions: true, showloader: false });
  }, []);

  function addTodo() {
    const input = document.getElementById('todoInput');
    if (input.value.trim()) {
      todos.push({ text: input.value.trim(), done: false });
      input.value = '';
    }
  }

  function toggleTodo(i) { todos[i].done = !todos[i].done; }
  function removeTodo(i) { todos = todos.filter((_, idx) => idx !== i); }

  initState();
</script>

Example: Data Table

Shows how to use data-loop for rendering table rows — the correct approach for tables.

table.html
<table>
  <thead><tr><th>#</th><th>Product</th><th>Price</th><th>Stock</th></tr></thead>
  <tbody data-loop="products" data-template="#rowTpl"
         data-value="p" data-index="i"></tbody>
</table>

<template id="rowTpl">
  <tr>
    <td>${i + 1}</td>
    <td>${p.name}</td>
    <td>$${p.price.toFixed(2)}</td>
    <td>${p.stock}</td>
  </tr>
</template>

<p>Total: <span data-innerhtml="products.length">0</span> products</p>

<script src="statejs.js"></script>
<script>
  watch('products', () => {
    setState({ dataloops: true, innerhtml: true, showloader: false });
  }, [
    { name: 'Laptop', price: 999.99, stock: 15 },
    { name: 'Mouse', price: 29.99, stock: 100 },
  ]);
  initState();
</script>

Example: Nested Conditions

Demonstrates nested if/else chains for multi-state UI.

conditions.html
<button onclick="userRole = 'admin'">Admin</button>
<button onclick="userRole = 'editor'">Editor</button>
<button onclick="userRole = 'viewer'">Viewer</button>

<condition-block ifid="roleCheck">
  <if-condition value="userRole" eq="'admin'" elseid="notAdmin">
    <p style="color:red">⚡ Admin Panel — Full Access</p>
  </if-condition>
  <else-condition elseid="notAdmin">
    <condition-block ifid="editorCheck">
      <if-condition value="userRole" eq="'editor'" elseid="notEditor">
        <p style="color:blue">✏️ Editor Mode — Can edit content</p>
      </if-condition>
      <else-condition elseid="notEditor">
        <p style="color:gray">👁️ View Only</p>
      </else-condition>
    </condition-block>
  </else-condition>
</condition-block>

<script src="statejs.js"></script>
<script>
  watch('userRole', () => {
    setState({ conditions: true, showloader: false });
  }, 'viewer');
  initState();
</script>

Example: Tabs Component

Uses condition-block for tab switching with a state-element for the active indicator.

tabs.html
<div class="tabs">
  <button onclick="activeTab = 'home'"
    data-js="this.classList.toggle('active', activeTab === 'home')">Home</button>
  <button onclick="activeTab = 'about'"
    data-js="this.classList.toggle('active', activeTab === 'about')">About</button>
  <button onclick="activeTab = 'contact'"
    data-js="this.classList.toggle('active', activeTab === 'contact')">Contact</button>
</div>

<condition-block ifid="tabContent">
  <if-condition value="activeTab" eq="'home'" elseid="notHome">
    <h2>Home</h2>
    <p>Welcome to the home page!</p>
  </if-condition>
  <else-condition elseid="notHome">
    <condition-block ifid="tabAbout">
      <if-condition value="activeTab" eq="'about'" elseid="notAbout">
        <h2>About</h2>
        <p>This is the about page.</p>
      </if-condition>
      <else-condition elseid="notAbout">
        <h2>Contact</h2>
        <p>Get in touch with us.</p>
      </else-condition>
    </condition-block>
  </else-condition>
</condition-block>

<script src="statejs.js"></script>
<script>
  watch('activeTab', () => {
    setState({ conditions: true, datajs: true, showloader: false });
  }, 'home');
  initState();
</script>

Common Pitfalls

1. Forgetting initState()

Symptom: Custom elements render as empty. Template expressions like {{count}} appear as literal text.

Fix: Always call initState() at the end of your script, after all watch() declarations.

2. Calling watch() after initState()

Symptom: The variable exists but its initial value isn't shown on first render.

Fix: Move all watch() calls before initState().

3. Using <for-loop> inside tables

Symptom: The for-loop content appears outside the table, or doesn't render at all.

Fix: Use data-loop on the <tbody> element instead. See data-loop section.

4. Using {{}} for loop variables

Symptom: {{item.name}} shows as undefined or throws an error inside a for-loop template.

Fix: Use ${item.name} for loop-scoped variables. {{}} is for global state only.

5. showloader: true causing spinner flash

Symptom: A loading spinner briefly appears on every state change.

Fix: Add showloader: false to your setState() calls for fast updates.

6. Mismatched IDs

Symptom: Loop or state element doesn't update when setState() is called.

Fix: Ensure loopid/stateId/ifid on the custom element matches the loopid/stateId on the inner <template> and matches the ID passed to setState().

7. include-template not loading

Symptom: The included component is blank.

Fix: You must serve your files via an HTTP server. <include-template> uses fetch(), which doesn't work with file:// protocol.

8. Template inside data-loop table placed inside <table> tags

Symptom: The <template> content is malformed or missing.

Fix: The <template> element for data-loop must be placed outside the <table> element.

Error Reference

Console Error Cause Fix
for-loop error Array not found or template missing Ensure array attribute references a valid watched variable, and <template> has matching loopid
Condition evaluation error Invalid expression in value attribute Check that the expression is valid JavaScript
data-js error JS error in data-js attribute Debug the expression in the attribute value
setState error An element's render() method threw Check for template or expression errors in the specified elements
parseURL error Invalid URL passed to parseURL() Ensure the URL string is valid

LLM Reference

HTTL-S for AI Chatbots

Download a single Markdown file containing the entire HTTL-S API reference, optimized for pasting into LLM chatbots like ChatGPT, Claude, or Gemini.

Download .md