| name | tina4-js |
| description | Use whenever working with tina4-js — the lightweight reactive frontend framework. Trigger on any mention of tina4-js, tina4 signals, persistent signals, persist(), Tina4Element, tina4 html tagged templates, tina4 routing, tina4 WebSocket client, tina4 API client, or persistent storage of UI preferences. Also trigger when the user is building a client-rendered frontend for a Tina4 backend, or when they're working with signals, Web Components, reactive templates, islands architecture, or persisted state in a tina4-js project. If the working directory contains tina4-js code (imports from 'tina4js'), use this skill for all frontend tasks.
|
tina4-js — Reactive Frontend Framework (v1.2.5)
tina4-js is a lightweight reactive frontend framework (16.4KB bundled IIFE). Zero dependencies,
no virtual DOM, no build complexity. It uses signals for reactivity, tagged template literals
for DOM, and Web Components for encapsulation.
Distribution: dist/tina4js.min.js is the official IIFE bundle. Usage:
<script src="/js/tina4js.min.js"></script>
This exposes all APIs globally — no imports needed. The IIFE bundle is also shipped inside
tina4-css (dist/tina4js.min.js) so all Tina4 backend frameworks get it automatically.
Building the IIFE bundle:
npm run build
npm run build:types
npx esbuild src/index.ts --bundle --minify --format=iife --global-name=Tina4 --outfile=dist/tina4js.min.js --target=es2021
The IIFE wraps the library in a self-executing function and exposes everything on window.Tina4:
<script src="/js/tina4js.min.js"></script>
<script>
const { signal, computed, html, Tina4Element, api, ws, sse, pwa } = Tina4;
</script>
This skill exists because AI agents consistently get tina4-js patterns wrong. The syntax
looks simple but has specific rules. Getting them wrong produces silent bugs — things render
once but never update, buttons don't disable, inputs don't bind. This reference is the source
of truth, derived from the actual source code.
Backend API Lookups — Use the Live Index
tina4-js talks to a Tina4 backend (Python / PHP / Ruby / Node). When you need a backend route's shape,
an ORM field, or a framework method signature, don't guess from memory — query the running backend's
live API index through its MCP tools (available with tina4 serve + TINA4_DEBUG=true):
api_search("…") to find a class or method, api_class("User") for its full surface, and
api_method("Database", "fetch") for an exact signature. These reflect the actual installed version,
so the frontend wires up against real endpoints instead of invented ones. (For frontend reactivity
itself — signals, html, components — the rules below are the source of truth.)
The Lazy Frontend Ladder
The frontend is where over-building hurts most: a component library for a button, a state
manager for three variables, an npm dependency for what the platform already does. tina4-js is
~1.5KB precisely because it leans on the browser. Before adding code or a dependency, stop at the
FIRST rung that holds.
- Does this need to exist at all? (YAGNI)
- Does the platform already do it?
<input type="date"> over a date-picker lib, <dialog>
over a modal lib, CSS :has() / grid / position: sticky over layout JS, native form
validation attributes over a validation lib, <details> over an accordion component. The
browser is the biggest dependency you already shipped — use it.
- Does tina4-js already do it?
signal + computed for state (no store library), html
bindings + ${() => ...} for reactivity (no virtual DOM), ?attr / .prop bindings,
Tina4Element for components, and the router / api / ws / sse / persist modules.
Do not import React/Vue patterns here.
- Is there an installed dependency that covers it? Use it. Adding a new npm package to a
1.5KB app is a decision, not a reflex.
- Can it be one line? Make it one line.
- Only then: the minimum code that works.
Never lazy about: XSS safety (use ${value} text binding, never ${htmlString}; .innerHTML
only for trusted/sanitised HTML), accessibility (labels, roles, keyboard), and the bindings that
actually make the UI reactive — a frozen ${signal.value} where you needed ${signal} is a bug,
not brevity. Mark a deliberate shortcut with a tina4: comment that names its ceiling.
The Three Rules That Fix 90% of Mistakes
Before writing any tina4-js code, internalize these:
Rule 1: Static vs Reactive
html`<p>${count.value}</p>`
html`<p>${count}</p>`
html`<p>${() => count.value > 0 ? 'Has items' : 'Empty'}</p>`
The pattern:
${signal} — reactive text node (updates when signal changes)
${() => expression} — reactive block (re-evaluates the function, can return html, null, arrays)
${value} — static, evaluated once, never updates
If your UI isn't updating, you probably used a static value where you needed a signal or function.
WARNING about false/null/undefined:
${false}
${null}
${undefined}
${0}
Never use ${condition && html...} — if condition is false, you get the text "false" in your DOM.
Always use the ternary: ${() => condition ? html... : null}
CRITICAL: Never Put Inputs Inside Reactive Blocks
This is the #1 developer mistake. Putting <input>, <textarea>, or <select> inside ${() => ...} causes them to lose focus on every keystroke because the reactive block destroys and recreates the entire subtree.
html`${() => html`<input .value=${name} @input=${(e) => { name.value = e.target.value; }} />`}`
html`
<input .value=${name} @input=${(e) => { name.value = e.target.value; }} />
<p>${() => name.value ? `Hello, ${name.value}!` : 'Type your name'}</p>
`
The rule: Form elements go in the static template. Use .value, @input, ?disabled bindings for reactivity. Only conditional messages, dynamic lists, and computed text go in ${() => ...} blocks.
Rule 2: New References for Objects/Arrays
items.value.push(newItem);
items.value = [...items.value, newItem];
user.value.name = 'Alice';
user.value = { ...user.value, name: 'Alice' };
Signals use Object.is() for equality. Same reference = no update. Always create new references.
Rule 3: Boolean Attributes Use ? Prefix
html`<button disabled=${isDisabled}>Click</button>`
html`<button ?disabled=${isDisabled}>Click</button>`
html`<button ?disabled=${() => !isValid.value}>Submit</button>`
The ? prefix adds the attribute when truthy, removes it when falsy. Without ?, you get
disabled="false" which STILL DISABLES the button (any value = disabled in HTML).
All three forms work reactively (v1.0.11+, boolean bug fixed in v1.0.12):
html`<button ?disabled=${loading}>Save</button>`
html`<div ?hidden=${() => !connected.value}>Offline</div>`
const isEmpty = computed(() => items.value.length === 0);
html`<p ?hidden=${isEmpty}>Items found</p>`
Common pattern — opposing show/hide pair:
const connected = signal(false);
html`
<div ?hidden=${() => connected.value}>Connecting...</div>
<div ?hidden=${() => !connected.value}>
<p>Connected! Send messages below.</p>
</div>
`;
Multi-signal conditions:
html`<button ?disabled=${() => loading.value || !isValid.value}>Submit</button>`
Footguns That Cost Real Debugging Time
These bite even when you know the Three Rules. They came out of real app work — read them.
⚠ THE BIGGEST ONE: one ${...} per attribute — never mix static text with a dynamic part
An attribute value must be a single interpolation. Partial interpolation — static text glued to a ${...} inside one attribute — does not reliably merge the parts; the dynamic piece silently fails to apply.
html`<div class="card ${() => active.value ? 'active' : ''}">`
html`<a href="/user/${id}">`
html`<div class=${() => 'card ' + (active.value ? 'active' : '')}>`
html`<a href=${() => `/user/${id.value}`}>`
html`<a href=${`/user/${userId}`}>`
The rule: if an attribute contains any ${}, the ENTIRE value must be that one ${}. Compose the full string inside the expression — don't concatenate static text with ${} in the template.
Bind form values with .value, never as a reactive child
html`<textarea .value=${() => form.value.note} @input=${e => setNote(e.target.value)}></textarea>`
html`<textarea>${() => form.value.note}</textarea>`
(Same root cause as "never put inputs inside reactive blocks" above — always drive form elements through .value / @input / ?disabled.)
Hash-router links use the BARE path — the router adds the #
html`<a href="/shop">Shop</a>`
html`<a href="#/shop">Shop</a>`
For an in-template control that should toggle state, not navigate, don't use <a href="#"> (it routes). Use a button-role element:
html`<span role="button" @click=${() => open.value = !open.value}>Toggle</span>`
Defer navigation that depends on a signal you just wrote
A computed or route-guard reads the old value if you navigate() synchronously right after writing the signal. Defer the navigation one tick so the write settles first:
setUser(u);
setTimeout(() => navigate('/shop'), 0);
Same fix for any write→navigate cascade (e.g. placeOrder() → clear cart → go to /success) — deferring avoids a 404 race.
Render the primary list as a top-level reactive block
Render the main list directly as its own ${() => ...}:
html`<ul>${() => items.value.map(i => html`<li>${i.name}</li>`)}</ul>`
Deeply nesting a list inside a ternary that itself returns html is fragile — the inner map may not re-render:
html`${() => cond.value ? html`...${() => items.value.map(...)}...` : html`...`}`
If one list "won't update" but a sibling list does, this nesting is the usual cause — pull the list up to its own top-level ${() => ...} block.
Signals — Reactive State
Read references/signals-and-reactivity.md for the full API. Quick reference:
import { signal, computed, effect, batch, isSignal } from 'tina4js';
isSignal(count);
isSignal(42);
const count = signal(0);
const name = signal('');
const items = signal<string[]>([]);
count.value;
count.value = 5;
count.peek();
const doubled = computed(() => count.value * 2);
const isValid = computed(() => name.value.length > 0);
const dispose = effect(() => {
console.log('Count is now:', count.value);
});
dispose();
batch(() => {
count.value = 10;
name.value = 'Alice';
});
HTML Templates — DOM Creation
Read references/html-and-components.md for the full API. Quick reference:
import { html } from 'tina4js';
const fragment = html`<h1>Hello ${name}</h1>`;
html`<button @click=${() => count.value++}>Add</button>`
html`<input @input=${(e) => { name.value = e.target.value; }}>`
html`<form @submit=${(e) => { e.preventDefault(); save(); }}>`
html`<button @click=${() => {
items.value = [...items.value, newItem];
selected.value = null;
loading.value = false;
// ↑ three writes, one DOM update — no mid-event re-renders
}}>Save</button>`
html`<input .value=${name}>`
html`<div .innerHTML=${rawHtml}>`
html`<button ?disabled=${loading}>Save</button>`
html`<div ?hidden=${() => !visible.value}>Content</div>`
html`<input ?checked=${isChecked}>`
html`<div class=${className}>Styled</div>`
html`<img src=${imageUrl} alt=${altText}>`
html`${() => loggedIn.value ? html`<p>Welcome</p>` : html`<a>Login</a>`}`
html`<ul>${() => items.value.map(item => html`<li>${item}</li>`)}</ul>`
html`<ul>${['a', 'b', 'c'].map(i => html`<li>${i}</li>`)}</ul>`
Event Handler Batching (v1.0.9+, auto-batch fix in v1.0.12)
All @event handlers are automatically batched. @click handlers now auto-batch properly
(fixed in v1.0.12). You do NOT need to:
- Wrap signal writes in
batch() inside event handlers
- Use
setTimeout(() => signal.value = x, 0) to defer updates
- Call
e.stopPropagation() to prevent mid-render bubble issues
These were workarounds for a bug that is now fixed at the framework level.
@click=${() => setTimeout(() => { items.value = [...items.value, item]; }, 0)}
@click=${() => { items.value = [...items.value, item]; }}
batch() is still useful outside of event handlers (e.g. in effect(), setTimeout, WebSocket handlers).
Things That Don't Exist — Don't Invent Them
AI agents commonly hallucinate these APIs. None of these exist in tina4-js:
unsafeHTML() — does NOT exist. Use .innerHTML=${rawHtml} property binding
t-model, t-for, t-bind, t-text — these are Vue directives, NOT tina4-js
tina4.createApp() — does NOT exist. There's no app instance
ref() — does NOT exist (that's Vue). Use signal()
useState() — does NOT exist (that's React). Use signal()
observedAttributes / attributeChangedCallback — don't write these manually.
Tina4Element handles them automatically via static props. Use this.prop('name')
If you find yourself writing something that isn't in this skill, stop and check. The API
is small by design — if it's not here, it probably doesn't exist.
Common Patterns
Form with Validation
const email = signal('');
const password = signal('');
const error = signal('');
const loading = signal(false);
const isValid = computed(() => email.value.includes('@') && password.value.length >= 8);
html`
<form @submit=${async (e) => {
e.preventDefault();
loading.value = true;
error.value = '';
try {
await api.post('/login', { email: email.value, password: password.value });
} catch (err) {
error.value = err.data?.message || 'Login failed';
}
loading.value = false;
}}>
<input type="email" .value=${email}
@input=${(e) => { email.value = e.target.value; }}>
<input type="password" .value=${password}
@input=${(e) => { password.value = e.target.value; }}>
${() => error.value ? html`<p class="error">${error}</p>` : null}
<button ?disabled=${() => !isValid.value || loading.value}>
${() => loading.value ? 'Logging in...' : 'Login'}
</button>
</form>`;
File Upload
Use api.upload() for multipart file uploads. Do NOT use api.post() — it sends JSON.
import { signal, html } from 'tina4js';
import { api } from 'tina4js/api';
const status = signal('');
const uploading = signal(false);
const handleUpload = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
uploading.value = true;
status.value = '';
try {
const form = new FormData();
form.append('avatar', file);
form.append('name', 'Alice');
const result = await api.upload('/api/upload', form);
status.value = 'Uploaded!';
} catch (err) {
status.value = 'Upload failed';
}
uploading.value = false;
};
html`
<input type="file" @change=${handleUpload} ?disabled=${uploading} />
<p>${status}</p>
`;
Key points:
api.upload(path, formData) — sends FormData with multipart/form-data
- Do NOT set Content-Type header — the browser sets it with the boundary
- Auth uses Bearer token in header (not formToken in body)
- Backend receives files in
request.files (raw bytes, not base64)
If you don't use the tina4-js api client, use native fetch():
const form = new FormData();
form.append('file', fileInput.files[0]);
const token = localStorage.getItem('tina4_token');
await fetch('/api/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
GraphQL Queries
Use api.graphql() to send GraphQL queries and mutations. It sends a POST with { query, variables }
and returns { data, errors }.
const { data, errors } = await api.graphql('/api/graphql',
'{ products(limit: 10) { id name price } }'
);
const { data } = await api.graphql('/api/graphql',
'query ($term: String!) { search_products(term: $term) { id name slug price } }',
{ term: searchInput.value }
);
const { data } = await api.graphql('/api/graphql',
'mutation ($input: CreateProductInput!) { createProduct(input: $input) { id } }',
{ input: { name: 'Widget', price: 29.99 } }
);
Reactive search example — debounced GraphQL search with live results:
const term = signal('');
const results = signal([]);
let timer;
effect(() => {
const q = term.value;
clearTimeout(timer);
if (q.length < 2) { results.value = []; return; }
timer = setTimeout(async () => {
const { data } = await api.graphql('/api/graphql',
'{ search_products(term: "' + q.replace(/"/g, '\\"') + '") { id name slug price } }'
);
results.value = data?.search_products || [];
}, 300);
});
html`
<input .value=${term} @input=${(e) => { term.value = e.target.value; }} placeholder="Search...">
<ul>${() => results.value.map(p => html`
<li><a href="/products/${p.slug}">${p.name} — $${p.price}</a></li>
`)}</ul>`;
List with Add/Remove
const items = signal<{ id: number; text: string }[]>([]);
const input = signal('');
let nextId = 1;
const addItem = () => {
if (!input.value.trim()) return;
items.value = [...items.value, { id: nextId++, text: input.value }];
input.value = '';
};
const removeItem = (id: number) => {
items.value = items.value.filter(i => i.id !== id);
};
html`
<div>
<input .value=${input} @input=${(e) => { input.value = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter') addItem(); }}>
<button @click=${addItem} ?disabled=${() => !input.value.trim()}>Add</button>
<ul>${() => items.value.map(item => html`
<li>${item.text} <button @click=${() => removeItem(item.id)}>×</button></li>
`)}</ul>
<p>${() => items.value.length} items</p>
</div>`;
API Data Loading
const users = signal([]);
const loading = signal(true);
effect(() => {
api.get('/users').then(data => {
users.value = data;
loading.value = false;
});
});
html`
<div>
${() => loading.value
? html`<p>Loading...</p>`
: html`<ul>${() => users.value.map(u => html`<li>${u.name}</li>`)}</ul>`
}
</div>`;
WebSocket with State
import { ws } from 'tina4js/ws';
const messages = signal<string[]>([]);
const socket = ws.connect('/ws/chat');
socket.pipe(messages, (msg, current) => [...current, msg.text]);
html`
<div>
<span>Status: ${socket.status}</span>
<div ?hidden=${() => !socket.connected.value}>
<ul>${() => messages.value.map(m => html`<li>${m}</li>`)}</ul>
<input @keydown=${(e) => {
if (e.key === 'Enter') {
socket.send({ text: e.target.value });
e.target.value = '';
}
}}>
</div>
</div>`;
Islands Architecture
tina4-js supports an "islands" pattern: use Tina4Element web components as self-contained
interactive widgets within server-rendered pages. Each island auto-registers and hydrates
independently.
<h1>Product Page</h1>
<p>Server-rendered content here...</p>
<product-rating product-id="42"></product-rating>
<add-to-cart product-id="42" price="29.99"></add-to-cart>
<script src="/js/tina4js.min.js"></script>
<script src="/js/islands/product-rating.js"></script>
<script src="/js/islands/add-to-cart.js"></script>
Each island is a standard Tina4Element that calls customElements.define() at the bottom of
its file. The IIFE bundle provides the framework globally; island scripts just use it.
Routing — IMPORTANT: {param} not :param
tina4-js uses curly brace syntax for route parameters — NOT Express-style colons.
import { route, router } from 'tina4js';
route('/', () => html`<h1>Home</h1>`);
route('/users/{id}', ({ id }) => html`<p>User ${id}</p>`);
route('/user/{userId}/post/{postId}', ({ userId, postId }) =>
html`<p>User ${userId}, Post ${postId}</p>`
);
route('*', () => html`<h1>404 — Not Found</h1>`);
route('/admin', {
guard: () => isLoggedIn.value || '/login',
handler: () => html`<admin-panel></admin-panel>`,
});
route('/data', async () => {
const data = await fetch('/api/data').then(r => r.json());
return html`<p>${data.message}</p>`;
});
router.start({ target: '#app', mode: 'hash' });
router.on('change', ({ path, params, pattern, durationMs }) => {
console.log(`Navigated to ${path} in ${durationMs}ms`);
});
router.navigate('/users/42');
Common mistake: Using Express-style :id instead of {id}. The route will never match.
Navigation: Use standard <a href="#/path"> links (hash mode) or <a href="/path"> (history mode). The router intercepts clicks automatically.
Persistent Signal Storage (v1.2.5+)
Wrap a signal so its value survives a page refresh, backed by localStorage or sessionStorage.
Opt-in per signal, zero dependencies, tree-shakeable. Read STORAGE.md before you use it.
import { signal } from 'tina4js';
import { persist, clearPersistedKeys } from 'tina4js/storage';
const theme = persist(signal('light'), { key: 'theme' });
const cart = persist(signal([]), { key: 'cart', syncTabs: true });
theme.value = 'dark';
clearPersistedKeys(['cart', 'lastFilter']);
What persist() returns: the same signal you passed in, with two extras attached —
.clear() removes the key from storage, .dispose() stops the write effect.
Options: key (required), storage: 'local'|'session', custom serializer for Date /
Map / Set, version + migrate for stored-shape changes between deploys, syncTabs for
cross-tab updates, silenceCredentialWarning to silence false positives like tokenColor.
What this must never store
localStorage is XSS-readable. Any script on the origin reads every value. The framework
warns loudly the first time it sees a credential-shaped key or value. Never put any of these
behind persist():
- Auth tokens, JWTs, session IDs, API keys — use
httpOnly + Secure + SameSite cookies.
- Passwords, including "encrypted" or "hashed" client-side ones.
- Personal data (names, emails, phone numbers, addresses, IDs) — POPIA/GDPR exposure.
- Payment data (card numbers, CVV, expiry) — not PCI-DSS compliant.
- Permission flags, roles,
isAdmin booleans — the user can edit them in devtools.
- Encryption keys, OTP seeds, secrets.
- Server-of-record state (orders, balances, ledger entries) — fetch fresh from the database.
- Anything that must not survive a logout — clear it via
clearPersistedKeys() on logout.
What it is for
Theme preference, language, sidebar collapsed state, last-used filter, onboarding flags,
local-only draft text, guest cart contents. Small things the user chose, the user expects
back, and an attacker gains nothing from reading.
Safety guarantees the framework gives you
- SSR-safe. No
window/localStorage means persist() is a silent no-op. No crash.
- Quota-safe.
QuotaExceededError is logged and skipped; the signal still updates.
- Credential warnings. Loud
console.warn once per key for key names matching
token|password|secret|apikey|auth|credential|jwt|bearer|otp|private_key|session_id,
for JWT-shaped string values, and for objects with credential-shape fields.
- No "encrypted" option. Encryption with a key sitting in the same bundle is theatre,
not security. Offering it would mislead.
- Opt-in cross-tab sync.
syncTabs: true per signal. Off by default.
Full details, examples (Date round-trip, version migration, logout wipe), and the complete
dangers table live in STORAGE.md at the repo root.
Cloudflare Workers
tina4-js runs on Cloudflare Workers with Durable Objects for WebSocket state. The IIFE bundle
and all client-side code works as-is; the WebSocket client (ws.connect()) connects to Worker
endpoints backed by Durable Objects for persistent state across connections.
Quick Reference — Commonly Missed APIs
import { isSignal } from 'tina4js';
isSignal(myVar);
import { router } from 'tina4js/router';
router.on('change', ({ path, params, pattern, durationMs }) => { });
import { pwa } from 'tina4js/pwa';
pwa.register({
cacheStrategy: 'cache-first',
});
api.intercept('request', (config) => { });
api.intercept('response', (resp) => { });
const { data, errors } = await api.graphql('/api/graphql', '{ users { id name } }');
const { data } = await api.graphql('/api/graphql', 'query($id: Int!) { user(id: $id) { name } }', { id: 42 });
import { persist, clearPersistedKeys } from 'tina4js/storage';
const theme = persist(signal('light'), { key: 'theme' });
Reference Files
references/signals-and-reactivity.md — Full signal, computed, effect, batch, isSignal,
and persist API with edge cases and gotchas. Read for any reactive state work, including
persistence.
references/html-and-components.md — html template bindings, Tina4Element Web Components,
lifecycle, routing, API client, WebSocket. Read for any UI/component work.