astwerk

A keyed list

Add and remove todos. Entries are keyed, so existing nodes are moved rather than rebuilt. The compiled-markup version of this is Keyed list.

    0 remaining

    How it works

    State

    go
    type Item struct {
    	ID   string
    	Text string
    }
    
    items := reactive.NewSignal([]Item{
    	{ID: "1", Text: "Read the docs"},
    	{ID: "2", Text: "Build something"},
    })
    draft := reactive.NewSignal("")

    The model is a slice of items; the DOM is a projection of it. draft holds the input while it's being typed.

    The keyed list

    go
    reactive.BindList(list, items.Get,
    	func(it Item) string { return it.ID },
    	func(it Item) wasmwrap.Element {
    		row := wasmwrap.Create("li")
    		remove := wasmwrap.Create("button").SetText("remove")
    		reactive.On(remove, "click", func(wasmwrap.Event) {
    			items.Update(func(all []Item) []Item {
    				return slices.DeleteFunc(all, func(o Item) bool {
    					return o.ID == it.ID
    				})
    			})
    		})
    		return row.Append(wasmwrap.Create("span").SetText(it.Text), remove)
    	})

    BindList takes the parent, a getter, a key function and a render function. The key is what makes it patch instead of re-render: add a row and only a new <li> is created; remove one and only that node is detached. The render function runs once per key, so wiring the remove handler inside it is safe — it closes over its own row.

    Adding a todo

    go
    reactive.On(form, "submit", func(e wasmwrap.Event) {
    	e.PreventDefault()
    	text := strings.TrimSpace(draft.Get())
    	if text == "" {
    		return
    	}
    	items.Update(func(all []Item) []Item {
    		return append(all, Item{ID: fmt.Sprintf("%d", len(all)+1), Text: text})
    	})
    	draft.Set("")
    })

    The field is two-way bound to draft. Submit — Enter works — trims, ignores empty text, appends a new item and clears the field. items.Update replaces the slice, and BindList diffs the keys and patches the DOM to match.

    The count

    go
    reactive.BindText(remaining, func() string {
    	return strconv.Itoa(len(items.Get()))
    })

    The remaining count is a text binding over the same slice — reading items.Get() re-runs it on every change.

    Full source
    go
    type Item struct {
    	ID   string
    	Text string
    }
    
    items := reactive.NewSignal([]Item{
    	{ID: "1", Text: "Read the docs"},
    	{ID: "2", Text: "Build something"},
    })
    draft := reactive.NewSignal("")
    
    reactive.Hydrate("#todo", func(root wasmwrap.Element) {
    	field := root.Find("[data-new]")
    	reactive.BindValue(field, draft)
    
    	reactive.On(root.Find("[data-form]"), "submit", func(e wasmwrap.Event) {
    		e.PreventDefault()
    		text := strings.TrimSpace(draft.Get())
    		if text == "" {
    			return
    		}
    		items.Update(func(all []Item) []Item {
    			return append(all, Item{ID: fmt.Sprintf("%d", len(all)+1), Text: text})
    		})
    		draft.Set("")
    	})
    
    	reactive.BindText(root.Find("[data-remaining]"), func() string {
    		return strconv.Itoa(len(items.Get()))
    	})
    
    	reactive.BindList(root.Find("[data-list]"), items.Get,
    		func(it Item) string { return it.ID },
    		func(it Item) wasmwrap.Element {
    			row := wasmwrap.Create("li")
    			remove := wasmwrap.Create("button").SetText("remove")
    			reactive.On(remove, "click", func(wasmwrap.Event) {
    				items.Update(func(all []Item) []Item {
    					return slices.DeleteFunc(all, func(o Item) bool {
    						return o.ID == it.ID
    					})
    				})
    			})
    			return row.Append(wasmwrap.Create("span").SetText(it.Text), remove)
    		})
    })
    How this demo runs

    scripts/demo.go is compiled to demo.wasm by CompileFrom and loaded by WasmScript — see Scripts & WebAssembly. Without WebAssembly the page still renders; only the rows stay put.