astwerk

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.

Loading…

1 post rendered at build time

  • Rendered at build time
  • How it works

    State

    templ
    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

    templ
    <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
    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
    templ
    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.