Guide Index
- ๐๏ธ Project Layout
- ๐ช Entry Point
- ๐งฉ Component Pattern
- ๐ง Component Display
- โฑ๏ธ Async Callback Wrapper
- โณ tag.promise
- ๐งน onDestroy Cleanup
- ๐งต Component Arguments
- ๐ Inputs and Updates
- ๐ช Functions for Output
- ๐ผ๏ธ Display
- ๐ฆ Element Imports
- ๐ท๏ธ attributes``
- โจ Dynamic Content _=>
- ๐ Arrays - Map Looping
- ๐ฑ๏ธ Event Handlers
- ๐งพ outerHTML for HTML Strings
- ๐ Static HTML / SEO Output
- ๐ Reactive Updates
- โ๏ธ React vs TaggedJS
- ๐ก Subscriptions & Observables
- ๐งพ Output Subscriptions
- ๐งฉ Map Values to Output
- ๐ซ No _=> Wrapper
- ๐ Combine Observables
- Default value with subscribeWith
- ๐จ Subscriptions in Attributes
- โป๏ธ Subscription Lifecycle
- ๐งน Manual Unsubscribe
- Dynamic Child Content
- Rules of Thumb
- Broken: Frozen Child Data
- Fixed: Updating Props
- Function Children
- Stable Map Keys
๐๏ธ Project Layout
The gh-pages branch is a full app. Source code lives in src/, the runtime entry is in src/index.ts, and the main view assembly lives in src/app.tag.ts plus the menu in src/menu.tag.ts.
Use the documentation/ folder for doc pages, styles, and future guides. Keep documentation separate from app runtime code so it remains focused and easy to host.
๐ช Entry Point
To start a TaggedJS app, place a custom element in your HTML and mount the component with tagElement. The mount call connects your component to that element and triggers the first render.
This is the minimal setup: an HTML document, a root element, and a module script that defines a tag component and mounts it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>TaggedJS App</title>
</head>
<body>
<app id="app-root"></app>
<script type="module">
import { tag, tagElement, div, h1 } from "taggedjs"
const App = tag(() =>
div(
h1("Hello TaggedJS")
)
)
tagElement(App, document.getElementById("app-root"))
</script>
</body>
</html>
๐งฉ Component Pattern
Components are created by calling tag and returning mock element functions like div, button, and p. Below are some simple examples. You may see syntax used in ways you have not seen before BUT all code is native vanilla JavaScript that is supported everywhere.
Basic Counter Component
import { tag, p, button } from 'taggedjs'
export const basicCounter = tag((counter = 0) => [
p('Counter: ', _=> counter}),
button.onClick(() => counter++)('Increment Counter')
])
โ๏ธ ABOVE Explanation: The function "basicCounter" becomes a tag component when wrapped in a tag() call. The tag requires no inputs/props/arguments. The new tag/component is designed to create a local variable counter that is set to 0 and increments when a button is clicked.
Basic show/hide Component
import { tag, div, button } from 'taggedjs'
export const basicShowHide = tag((showDiv = true) =>
div(
button.onClick(() => showDiv = !showDiv)(
_=> `Toggle Div (${showDiv ? 'Hide' : 'Show'})`
),
_=> showDiv && div('Now you see me')
)
)
โ๏ธ ABOVE Explanation: The function "basicShowHide" becomes a tag component when wrapped in a tag() call. The tag requires no inputs/props/arguments. The new tag/component is designed to create a local variable "showDiv" that is toggled true/false when a button is clicked.
The tag((counter = 0) => div(_=> counter)) form is shorthand for declaring local variables and returning markup. It is the same as tag(() => { let counter = 0; return div(_=> counter) }), just more compact.
Returning an array lets you emit multiple root elements without a wrapper. () => [div('hello'), div('world')] is the no-wrapper alternative to () => div('hello world') when you want separate siblings.
๐ง Component Display
When you pass arguments to a tag component, render it inside a _=> block so TaggedJS treats argument changes as updates.
This keeps the tag mounted and lets .updates(...) receive new arguments without recreating the component.
Using _=> also helps it stand out as dynamic display, while something like div.onClick(() => {}) reads more like an event handler. It's optional, but recommended for clearer intent.
return div(
_=> boltTag(counter)
)๐งต Component Arguments
Component arguments are TaggedJS inputs. They are values the parent passes down to a child component, similar to Angular @Input() properties.
A tag component is a stable instance after it is mounted. Its main function sets up local state and returns the view once; later parent changes update the dynamic output blocks instead of recreating the whole child.
That is why child tags that receive changing arguments should be rendered inside _=>. The dynamic wrapper gives TaggedJS a place to send the latest parent values into the already-mounted child.
export const parent = tag(() => {
let counter = 0
return div(
button.onClick(() => counter++)("Increment"),
_=> boltTag(counter)
)
})
const boltTag = tag((parentCounter: number) => {
boltTag.updates(args => [parentCounter] = args)
return div(_=> `parent counter: ${parentCounter}`)
})
src/basic.tag.tsUse .updates(...) when the child only needs the later values. It receives the current argument array in call order and copies those values into the local variables used by the child view.
const boltTag = tag((parentCounter: number) => {
boltTag.updates(args => [parentCounter] = args)
return div(
div(_=> `parent counter: ${parentCounter}`)
)
})
src/basic.tag.ts๐ Inputs and Updates
The important distinction is lifecycle timing, not two competing ways to declare inputs. The function parameters define the component inputs. .updates(...) runs on later parent argument changes. .inputs(...) runs during initial input setup and again on later argument changes.
Reach for .inputs(...) when incoming arguments need normalization every time they enter the component lifecycle. In this repo that usually means turning a function input into an output callback with output(...), because the callback must be bound on the first render and whenever the parent passes a new function.
This keeps the Angular-shaped model clear: inputs are values flowing down from the parent, updates are the moment those values are synchronized into an existing component, and dynamic _=> output blocks are what re-render from those synchronized values.
import { tag, output, footer, span, button } from "taggedjs"
const Footer = tag((
todosCount: number,
removeCompleted: () => any,
route: string,
activeTodoCount: number,
) => {
Footer.inputs(args => {
[todosCount, removeCompleted, route, activeTodoCount] = args
removeCompleted = output(removeCompleted)
})
return footer(
span(_=> activeTodoCount),
_=> (todosCount - activeTodoCount) > 0 &&
button.onClick(() => removeCompleted())("Clear completed")
)
})
src/todo/components/footer.ts๐ช Functions for Output
Function arguments are the TaggedJS output pattern. The parent passes a function down, the child calls it when something happens, and the parent updates its own state.
That mirrors Angular @Output() event flow: inputs move data down into the child, outputs notify the parent that something happened.
Calling output binds the callback to the parent render context. When the child invokes that wrapped callback, TaggedJS can re-run the parent's affected _=> output blocks after the parent state changes.
import { output, tag, button } from "taggedjs"
export const child = tag((onSave: () => void) => {
onSave = output(onSave)
return button.onClick(() => onSave())("Save")
})
export const parent = tag(() => {
let saved = 0
return div(
_=> child(() => saved++),
div(_=> `saved: ${saved}`)
)
})
When a function input may change, wrap it again inside .updates(...) or use .inputs(...) for the initial-and-update form. The repository repeats this pattern in innerCounters, funInPropsChild, propsDebug, and Footer.
import { tag, output, button } from "taggedjs"
const child = tag((onSave: () => void) => {
onSave = output(onSave)
child.updates(args => {
[onSave] = args
onSave = output(onSave)
})
return button.onClick(onSave)("Save")
})
โฑ๏ธ Async Callback Wrapper
TaggedJS exports callback to wrap async handlers (events, timers, promises) so the current tag can re-evaluate dependent _=> outputs when the async work completes.
Call callback inline and assign it to a new variable so it can be registered and cleaned up with the async API.
import { callback } from "taggedjs"
const getHash = () => window.location.hash.substring(1) || '/'
const HashRouter = () => {
const memory = {route: getHash()}
const onHashChange = callback(() => memory.route = getHash())
window.addEventListener('hashchange', onHashChange)
return {memory, onHashChange}
}
todo/src/HashRouter.function.tsโณ tag.promise
Set tag.promise to a Promise to tell TaggedJS that a new render cycle should run when that async work completes.
Do not use await tag.promise or await the promise inline; the promise is only a signal for re-render, not a value to block on.
const promiseTag = tag(() => {
let x = 0
tag.promise = new Promise((resolve) => {
setTimeout(() => {
++x
resolve(x)
}, 250)
})
return div.id`tag-promise-test`(_=> `count ${x}`)
})
src/async.tag.ts๐งพ outerHTML for HTML Strings
Call .outerHTML on the result of a tag component to render it as a plain HTML string. This is useful for server-side rendering, static HTML generation, tests, and snapshots.
outerHTML is a getter, not a function. Use MyTag().outerHTML, not MyTag.outerHTML or MyTag().outerHTML().
import { tag, div, span } from "taggedjs"
const Greeting = tag((name: string) =>
div("hello world", () => name)
)
const html = Greeting(" love").outerHTML
console.log(html)
// <div>hello world love</div>
const Nested = tag(() =>
div.class`wrapper`(
span("hello world")
)
)
Nested().outerHTML
// <div class="wrapper"><span>hello world</span></div>
The getter renders the component with its stored arguments, then serializes the returned TaggedJS element using the same behavior as elementVarToHtmlString.
import { elementVarToHtmlString } from "taggedjs"
const html = elementVarToHtmlString(Greeting(" love"))
Event handlers are intentionally omitted from the returned HTML string, matching direct elementVarToHtmlString output.
๐ Static HTML / SEO Output
The same string output is what people usually mean by server-side rendering, static rendering, pre-rendering, or SEO output. TaggedJS can create the HTML before the browser runs the app, so crawlers and users receive real document content instead of an empty mount element.
This documentation page already uses that pattern. scripts/renderDocsHtml.ts mounts GuideApp in a Happy DOM document, reads mount.innerHTML, and writes that string between the SSR markers in documentation/index.html.
import { Window as HappyWindow } from "happy-dom"
import fs from "fs"
import { tagElement } from "taggedjs"
import { GuideApp } from "../src/documentations/guide.js"
const window = new HappyWindow()
globalThis.window = window as any
globalThis.document = window.document as any
const mount = document.createElement("app")
mount.id = "docs-app"
document.body.appendChild(mount)
tagElement(GuideApp, mount)
const html = mount.innerHTML
fs.writeFileSync("documentation/index.html", html)
The saved page now contains the rendered header, table of contents, and guide sections as normal HTML inside #docs-app. That is the SEO-friendly output: the content exists in the file before client JavaScript downloads.
<app id="docs-app">
<!-- taggedjs:ssr-start -->
<header>...</header>
<main>...</main>
<!-- taggedjs:ssr-end -->
</app>
<script type="module">
import { runDocs } from "../assets/dist/bundle.js"
runDocs()
</script>
On load, the browser parses that string into real DOM immediately. Then the module script calls runDocs(). This repo currently clears #docs-app and mounts GuideApp again so the page becomes the normal interactive TaggedJS app.
export function runDocs() {
const mount = document.getElementById("docs-app")
if(!mount) return
mount.innerHTML = ""
tagElement(GuideApp, mount)
}
That flow is close to what frameworks like Next.js do for React in goal: send useful HTML first, then attach the client runtime. The current TaggedJS docs implementation is a static pre-render plus client remount; it does not preserve and hydrate the existing DOM nodes.
๐งน onDestroy Cleanup
Use onDestroy to run cleanup logic when a tag component is removed. For host elements, host.onDestroy lets you attach cleanup at the element level.
import { tag, div, button, onDestroy, host, signal } from "taggedjs"
const contentTag = tag(() => {
onDestroy(() => {
// closing logic here
})
return div("this tag will be destroyed")
})
const destroys = tag((showContent: boolean) =>
div(
"Content:", _=> showContent && contentTag(),
button
.onClick(() => { showContent = !showContent } )
(_=> showContent ? "destroy" : "restore")
))
src/destroys.tag.tsCommon uses include removing event listeners, stopping intervals, or disposing subscriptions tied to the component lifecycle.
๐ผ๏ธ Display
These sections cover the rendering building blocks: importing elements, applying attributes``, mapping lists, and wiring event handlers.
๐ฆ Element Imports
TaggedJS exposes HTML elements as functions you can import directly. This keeps your render output explicit, avoids string-based templates, and makes composition feel like regular JavaScript.
Benefits include clear dependency lists, easy refactors, and better editor autocomplete because each element is a real import instead of a string tag.
import { div, span, button } from "taggedjs"
export const example = tag(() =>
div(
span("Hello"),
button.onClick(() => alert("Hi"))("Click")
)
)
๐ท๏ธ attributes``
TaggedJS supports a shorthand attribute syntax using tagged template calls. You can set attributes with concise chains like div.id`identifier`.style`border:1px solid black;` to keep the markup compact.
For dynamic values, switch to a function call. The .style(_=> border) form keeps the same chain, but marks the style as reactive so it updates when the value changes.
div
.id`identifier`
.style`border:1px solid black;`
("attributes shorthand")
const border = "border:2px solid blue;"
div
.id`identifier`
.style(_=> border)
("dynamic style")
โจ Dynamic Content _=>
The _=> prefix is a visual cue that the content is dynamic and will re-evaluate when values change. It is meant to stand out from () =>, which you will usually see in event handlers like onClick.
const counter = tag(() => [
p(_=> count ),
button.onClick(() => count++), 'increment')
])
๐ Arrays - Map Looping
TaggedJS uses normal JavaScript array mapping for list rendering. Put the array.map inside a _=> block so the list is reactive.
Return a tag for each item and add .key(...) when the list can be reordered or removed so TaggedJS can keep elements stable. Alternatively, if the first rendered element has an id like div.id`x-${x.id}`, that id can act as the array value identifier.
import { div, tag } from "taggedjs"
const arrayTag = tag(() => {
const items = ['a', 'b', 'c']
return div(
items.map((item, index) =>
div(`hello world item ${index}`).key(item)
)
)
})
const users = [
{ id: 101, name: 'Ada' },
{ id: 102, name: 'Grace' },
{ id: 103, name: 'Linus' }
]
_=> users.map(user =>
div.id(_=> `user-${user.id}`)(
span('name: ', _=> user.name)
)
)
๐ฑ๏ธ Event Handlers
Event handlers use method chaining like button.onClick(...). The code in src/basic.tag.ts shows the standard pattern.
export const clicker = tag(() => {
let counter = 0
return button.onClick(() => counter++)(_=> `Increment Counter: ${counter}`)
})
src/basic.tag.ts๐ Reactive Updates
Reactive updates are driven by closures and tracked by the TaggedJS runtime. When a function uses values in an arrow callback (for example, _=>), the runtime re-evaluates that part of the view when the values change.
In the example above, the p tags and the final conditional _=> showDiv && boltTag(counter) are reactive segments.
โ๏ธ React vs TaggedJS
React typically re-runs the component function to produce the next render output, then reconciles the result. TaggedJS keeps the main tag function stable and re-evaluates only the dynamic segments marked with _=>, which helps focus updates on the specific parts that changed.
๐ก Subscriptions & Observables
TaggedJS treats observable streams as first-class render inputs. Use subscribe (and subscribeWith) inside output blocks to turn emissions into DOM updates.
A "LikeObservable" is any object with subscribe(callback) that returns a subscription object (or function) with an unsubscribe() method. TaggedJS subscribes during render and automatically cleans up when that output is removed.
๐งพ Output Subscriptions
Start by using subscribe(observable) to display the latest value from an observable.
๐งฉ Map Values to Output
If you pass a callback, TaggedJS uses it to map emissions to output. This is useful for formatting or wrapping values.
import { tag, ValueSubject, subscribe, span, button } from "taggedjs"
const count$ = new ValueSubject(0)
export const counter = tag(() => [
button.onClick(() => count$.next(count$.value + 1))("Increment"),
/* output just the emitted value */
span('count:', subscribe(count$)),
/* output the emitted value plus a label */
span(subscribe(count$, value => `count: ${value}`))
])
src/subscriptions.tag.ts๐ซ No _=> Wrapper
Do not wrap subscribe(...) with _=> โ subscribe(...) already returns a tagged value that the runtime recognizes and manages.
๐ Combine Observables
To combine multiple observables, use subscribe.all([a$, b$], ([a, b]) => ...) (which uses a combined subject under the hood) or pipe([a$, b$], values => ...) if you already have a list of observables.
Default value with subscribeWith
Use subscribeWith when you want an initial default value before the first emission. It emits the default (or current .value if available) and then switches to live updates.
import { tag, ValueSubject, subscribeWith, span } from "taggedjs"
const status$ = new ValueSubject("idle") // anything with a subscribe method will do
export const status = tag(() =>
span(subscribeWith(status$, "idle", value => `status: ${value}`))
)
๐จ Subscriptions in Attributes
Subscriptions also work in attributes. The runtime wires the attribute once and updates the value on each emission.
import { tag, ValueSubject, subscribeWith, div } from "taggedjs"
const color$ = new ValueSubject("tomato")
export const swatch = tag((
div
.style( subscribeWith(color$, "tomato", color => ({ backgroundColor: color })) )
("color swatch")
))
src/subscribeAttributes.tag.tsโป๏ธ Subscription Lifecycle
When a subscribe output is rendered, TaggedJS creates an internal subscription context that stores the latest values and the list of active subscriptions.
- Subscribes to each observable on first render and stores the returned subscriptions in
contextItem.subContext.subscriptions. - When the output is removed (conditional turns false, array diff removes it, or component is destroyed),
deleteAndUnsubscribecallsunsubscribe()on each stored subscription and clears the sub-context. - If a value changes from a subscription to something else, TaggedJS destroys the old subscription and then updates the DOM with the new value.
For debugging, Subject.globalSubCount$ tracks active subscriptions and is incremented on subscribe and decremented on unsubscribe.
๐งน Manual Unsubscribe
If you subscribe manually via Subject.subscribe (outside of subscribe(...)), you are responsible for calling subscription.unsubscribe(). onDestroy is the usual place to do that.
import { tag, Subject, onDestroy } from "taggedjs"
const updates$ = new Subject(0)
export const listener = tag(() => {
const subscription = updates$.subscribe(value => {
// side effects here
})
onDestroy(() => subscription.unsubscribe())
return "listening"
})
src/providers.tag.tsDynamic Child Content and Updating Props
If you are coming from React, Vue, or Svelte, the main trap is assuming that a child tag's function re-runs whenever the parent has new values. A TaggedJS tag sets up a stable component instance. After that, dynamic function children, subscriptions, and input handlers are the places where changing values are read again.
Static strings, static arrays, and objects computed during a tag render are snapshots. If the parent later changes state, those snapshots do not magically recompute inside an already-mounted child.
Rules of Thumb
subscribe(observable)creates reactive output for one observable.subscribe.all([a$, b$], callback)combines multiple observable values in one render callback and avoids nested subscription confusion.- Function children like
() => valueor(_: unknown) => valueare not just event callbacks. In output and attributes, they mark values that must be re-evaluated on updates. .inputs(...)is the TaggedJS equivalent of handling updated component props or arguments. Use it when local derived variables depend on changing inputs.- Arrays returned from
.map(...)should use.key(stableValue)when items can be added, removed, or reordered.
Broken: Derived Child Data Freezes
This version combines two stores correctly with subscribe.all, but then passes a precomputed parsed object into a child that renders a static string. The child view is set up once, so later card changes can leave the details panel showing old JSON.
import { tag, subscribe, section, div, h2, pre } from "taggedjs"
const Dashboard = tag(() =>
section(
subscribe.all([hardware$, rotation$], ([hardware, rotation]) => {
const payload = { hardware, rotation }
const card = visibleCard(payload, rotation.currentCardId)
const parsed = parsedDataForCard(card)
return div(
h2(card.title),
DetailsPanel(card, parsed)
)
})
)
)
const DetailsPanel = tag((card: Card, parsed: unknown) =>
pre(JSON.stringify(parsed, null, 2))
)
Fixed: Update Inputs and Render Dynamic Values
Keep the parent subscription focused on combining stores and selecting the current card. Put child-specific derived data inside the child, then refresh it from .inputs(...) when the parent passes a new card.
The pre(() => JSON.stringify(parsed, null, 2)) child is dynamic. TaggedJS can call it again after parsed is reassigned in the input handler.
import { tag, subscribe, section, div, h2, pre } from "taggedjs"
const Dashboard = tag(() =>
section(
subscribe.all([hardware$, rotation$], ([hardware, rotation]) => {
const payload = { hardware, rotation }
const card = visibleCard(payload, rotation.currentCardId)
return div(
h2(() => card.title),
_ => DetailsPanel(card)
)
})
)
)
const DetailsPanel = tag((card: Card) => {
let parsed = parsedDataForCard(card)
DetailsPanel.inputs(([nextCard]) => {
card = nextCard
parsed = parsedDataForCard(card)
})
return pre(() => JSON.stringify(parsed, null, 2))
})
Function Children Are Dynamic Rendering
Use function children anywhere a value must be read again: text nodes, conditional children, mapped children, and attributes. Event handlers also use functions, but the meaning is different: onClick(() => save()) handles an event, while span(() => label) renders a dynamic value.
const StatusBadge = tag((status: Status) => {
StatusBadge.inputs(([nextStatus]) => status = nextStatus)
return span
.class(() => `status status-${status.kind}`)
(() => status.label)
})
Stable Keys for Mapped Tags
When rendering a mapped array of tags, give each rendered item a stable primitive key from the data, such as an id, slug, or persistent name. Do not use fresh inline objects or arrays as keys because their identity changes every render and prevents stable matching.
const CardList = tag((cards: Card[]) => {
CardList.inputs(([nextCards]) => cards = nextCards)
return ul(
() => cards.map(card =>
CardRow(card).key(card.id)
)
)
})
// Avoid keys that are recreated each render:
// CardRow(card).key({ id: card.id })
// CardRow(card).key([card.id])