astwerk

A keyed list

The two rows were rendered at build time; the rest is a keyed list — adding a row inserts one node, never rebuilds the others. The add and remove handlers are a script, which is the escape hatch: anything a combinator can't say is plain JavaScript reading the named signals.

  • Read the docs
  • Build something
  • 2 remaining

    How it works

    State

    templ
    type XTodo struct {
    	ID   string `json:"id"`
    	Text string `json:"text"`
    }
    
    var XTodos = x.Named("todos", []XTodo{
    	{ID: "1", Text: "Read the docs"},
    	{ID: "2", Text: "Build something"},
    })
    var XDraft = x.Named("draft", "")

    The JSON tags are the field names the browser half of the list reads, so they are part of the contract with todoRow. The two initial rows were rendered at build time; everything after that happens in the browser.

    The list

    templ
    <div data-x-list>
    	@x.List(XTodos, "id", xTodoRow, "todoRow")
    </div>

    x.List takes the signal, the JSON field that keys each item, a Go renderer for rows present at build time, and the name of a JavaScript function that renders rows added in the browser. Rows are keyed by id: adding or removing an item moves or detaches one node, and every other row is untouched.

    The script

    templ
    <script>
    	function todoRow(item) {
    		return "<li>" + item.text + "</li>";
    	}
    	document.querySelector("[data-x-form]").addEventListener("submit", (e) => {
    		e.preventDefault();
    		const text = draft.get();
    		if (!text) return;
    		todos.set([...todos.get(), { id: String(Date.now()), text }]);
    		draft.set("");
    	});
    </script>

    Appending to the array is one of the few things combinators can't say, so it's a script: draft.get() reads the input signal, todos.set(...) replaces the list, and draft.set("") clears the field — which the two-way binding echoes back.

    The count

    templ
    <p class="demo-note"><span data-x-remaining>2 remaining</span></p>
    
    @x.Effect() {
    	<script>
    		document.querySelector("[data-x-remaining]").textContent = todos.get().length + " remaining";
    	</script>
    }

    The remaining count is an effect: it reads todos.get(), so it re-runs whenever the list changes and rewrites the line.

    Full source
    templ
    type XTodo struct {
    	ID   string `json:"id"`
    	Text string `json:"text"`
    }
    
    var XTodos = x.Named("todos", []XTodo{
    	{ID: "1", Text: "Read the docs"},
    	{ID: "2", Text: "Build something"},
    })
    var XDraft = x.Named("draft", "")
    
    templ XTodoDemo() {
    	<div class="demo">
    		<form class="todo-form" data-x-form>
    			@x.Input(XDraft)
    			<button type="submit">Add</button>
    		</form>
    		<div data-x-list>
    			@x.List(XTodos, "id", xTodoRow, "todoRow")
    		</div>
    		<p class="demo-note"><span data-x-remaining>2 remaining</span></p>
    		@xTodoScript()
    		@x.Effect() {
    			<script>
    				document.querySelector("[data-x-remaining]").textContent = todos.get().length + " remaining";
    			</script>
    		}
    	</div>
    }
    
    templ xTodoRow(item XTodo) {
    	<li>{ item.Text } <button data-remove={ item.ID }>remove</button></li>
    }
    
    templ xTodoScript() {
    	<script>
    		function todoRow(item) {
    			return "<li>" + item.text + "</li>";
    		}
    		document.querySelector("[data-x-form]").addEventListener("submit", (e) => {
    			e.preventDefault();
    			const text = draft.get();
    			if (!text) return;
    			todos.set([...todos.get(), { id: String(Date.now()), text }]);
    			draft.set("");
    		});
    	</script>
    }
    
    // On the page: @x.Document(XTodoDemo())
    Why rows are keyed by id

    The key is what makes the list patch instead of re-render: the browser can tell "this row moved" from "this is a new row". An item whose key changes is treated as a new item.