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
- Reactive global variables — change a variable, the UI updates automatically
- For-loops in HTML — no JavaScript DOM manipulation needed
- Conditional rendering — if/else chains right in your markup
- Template includes — split HTML into reusable components
- Data binding — expressions like
{{count}}and${item.name} - No build step — works with any static server, CDN, or even
file://
Installation
HTTL-S is a single JavaScript file. You have two options:
Option 1: CDN (recommended for getting started)
<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:
<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:
<!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:
watch('count', callback, 0)— creates a global variablecountwith initial value0. A deep proxy watches for any changes.- When you click the button,
count++triggers the proxy's setter, which schedules the callback. - The callback calls
setState({ stateId: 'counter' }), which finds the<state-element stateId="counter">and re-renders its template. - The template contains
{{count}}, whichparseTemplate()evaluates to the current value ofcount. 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:
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:
npx -y http-server . -p 8080 --cors -c-1
VS Code Setup
For the best developer experience with HTTL-S:
- Install the Live Server extension for auto-reload
- Copy the
.vscode/htmls.code-snippetsfrom the HTTL-S repo for auto-complete snippets - Add
/// <reference path="statejs.d.ts" />in your JS files for TypeScript IntelliSense
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:
<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:
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:
- You can access
countanywhere — in templates, event handlers, the console - You can set
count = 5from any script — the proxy intercepts the assignment and fires the callback - You cannot have two variables with the same name — calling
watch('count', ...)twice will warn and skip the second one
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, []):
- HTTL-S stores the initial value
[] - If the value is an object or array, it wraps it in a deep proxy using
createDeepProxy() - It defines a getter/setter on
window.todos - Any access to
todosreturns the proxied version
Step 2: Mutation triggers the callback
The deep proxy intercepts any mutation at any depth:
// 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:
// 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:
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.
<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:
- Register custom elements —
for-loop,condition-block,if-condition,else-condition,state-element,include-template - Call
renderDataLoops()— finds all[data-loop]elements and renders them - 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:
- Specific elements (if
loopid,ifid, orstateIdis provided) data-jsattributesdata-innerhtmlattributes- All
<for-loop>elements - All
[data-loop]elements - All
<condition-block>elements - 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.
| 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:
myArray.push(item)— triggers callback (proxy intercepts thepushmutation)myObj.nested.deep.value = 5— triggers callback (nested property access returns sub-proxies)delete myObj.key— triggers callback (proxy'sdeletePropertytrap)- Full reassignment
myVar = newValue— triggers callback (goes through the global setter)
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.
// 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.
| 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
// ✅ 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.
What it does internally:
- Registers 6 custom elements:
for-loop,include-template,if-condition,else-condition,condition-block,state-element - Calls
renderDataLoops()to render all[data-loop]elements - Calls
setState()with defaults to trigger the first full render
Order matters! Always follow this pattern:
// 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.
| Parameter | Type | Description |
|---|---|---|
template required |
string |
String containing {{expression}} placeholders to evaluate |
How expressions are evaluated
Inside {{ }}, you can use any valid JavaScript expression:
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:
<!-- 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.
| Parameter | Type | Description |
|---|---|---|
expression required |
string |
JavaScript expression to evaluate |
context optional |
object |
Key-value pairs made available as local variables during evaluation. Default:
{}
|
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(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 }).
How data-loop works internally:
- Finds all elements with
[data-loop]attribute - Gets the array from the global variable named in
data-loop - Gets the template from the selector in
data-template - For each item, replaces
${valueVar}and${indexVar}in the template - Runs
parseTemplate()on the result for{{expr}}evaluation - Sets the element's
innerHTMLto 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.
| Parameter | Type | Default | Description |
|---|---|---|---|
url |
string |
location.href |
URL to parse |
global |
boolean |
true |
If true, stores result in window.UrlDetails |
Return value
// 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).
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.
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.
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
<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:
<!-- 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:
<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:
<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)
<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
<!-- 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)
<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' }). |
<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. |
<!-- 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
scoped="true"(default) — content is placed in a Shadow DOM. Styles from the main page don't affect the included content, and vice versa.scoped="false"— content is injected directly into the page. Shares all styles. Use this for navbars, headers, and other components that need access to page styles.
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") |
<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).
<!-- 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.
<!-- 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.
<!-- 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>.
<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.
<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>
<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.
<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.
<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 |