Async data
The WASM async demo through the script escape hatch: a fake fetch that takes half a second, a loading signal, and a keyed list — with no WASM and no framework. The WASM version is Async data.
1 post rendered at build time
How it works
State
var XPosts = x.Named("posts", []XPost{
{ID: "0", Title: "Rendered at build time"},
})
var XLoading = x.Named("loading", false)Two named signals: the posts, and a loading flag the script toggles. Named, because the script below reads and writes them directly.
The list
<div data-x-list>
@x.List(XPosts, "id", xPostRow, "postRow")
</div>x.List renders the posts statically at build time — the one row that exists before any script runs — and keeps the list in sync as the script replaces the signal.
The script
templ xAsyncScript() {
<script>
function postRow(p) {
return "<li>" + p.title + "</li>";
}
const status = document.querySelector("[data-x-status]");
function load() {
loading.set(true);
status.textContent = "Loading…";
setTimeout(() => {
posts.set([
{ id: "1", title: "Signals track their own dependencies" },
{ id: "2", title: "Keyed lists move nodes instead of rebuilding them" },
{ id: "3", title: "Scripts are the escape hatch — this is one" },
]);
loading.set(false);
status.textContent = posts.get().length + " posts loaded";
}, 500);
}
document.querySelector("[data-x-reload]").addEventListener("click", load);
load();
</script>
}Combinators stop where a real program begins: timing, fetching, side effects. That is the escape hatch — a plain script reading the named signals. It flips loading, waits, replaces posts, and updates the status line directly.
Full source
type XPost struct {
ID string `json:"id"`
Title string `json:"title"`
}
var XPosts = x.Named("posts", []XPost{
{ID: "0", Title: "Rendered at build time"},
})
var XLoading = x.Named("loading", false)
templ XAsyncDemo() {
<div class="demo">
<div class="counter-row">
<button type="button" data-x-reload>Reload</button>
@x.El("span", x.Show(XLoading)) {
Loading…
}
</div>
<p class="demo-note" data-x-status>1 post rendered at build time</p>
<div data-x-list>
@x.List(XPosts, "id", xPostRow, "postRow")
</div>
@xAsyncScript()
</div>
}
templ xAsyncScript() {
<script>
function postRow(p) {
return "<li>" + p.title + "</li>";
}
const status = document.querySelector("[data-x-status]");
function load() {
loading.set(true);
status.textContent = "Loading…";
setTimeout(() => {
posts.set([
{ id: "1", title: "Signals track their own dependencies" },
{ id: "2", title: "Keyed lists move nodes instead of rebuilding them" },
{ id: "3", title: "Scripts are the escape hatch — this is one" },
]);
loading.set(false);
status.textContent = posts.get().length + " posts loaded";
}, 500);
}
document.querySelector("[data-x-reload]").addEventListener("click", load);
load();
</script>
}
// On the page: @x.Document(XAsyncDemo())Why this is a script, not a marker
The marker set covers state, bindings and structure. Async work has no combinator — it is a program, so it is a script. That boundary is deliberate: the script escape hatch is what keeps the markers from having to grow into a language.