Skip to content

C2 — Events, payloads & forms

Prerequisite: C1 — you can build and run a live component and you know the four parts (state, render, @get, @ws).

Objective: make events carry data. By the end you'll have a live name list: add with a form, remove a specific row, and filter as you type — all with zero JavaScript.

Why it matters: the counter's events were bare ("increment" with no data). Real UIs need three things the counter didn't have: a form to submit, a way for a specific element to say "this event is about me", and live input that reacts as you type. Those are three data-flv-* attributes, and they're all you need for the vast majority of interactions.

▶ Live preview. A client-WASM build of this chapter's name list — the same NoteList component (seeded with Ada / Grace / Margaret), running in the page. Add a name, remove a row, watch the count. The course builds the server-driven version; the add / remove / count behavior is identical. (The live filter is dropped here — a case-insensitive .lower().contains(...) filter isn't in the client-WASM envelope yet, so it stays server-side. Everything else runs offline.)


The three mechanisms

Everything an event carries ends up in frame.payload — a Map<Str, Str> your @ws handler reads. There are three ways to fill it:

1. data-flv-value-* — a click that carries data

Add data-flv-value-<key>="<val>" next to a data-flv-click and the click's payload gets payload[key] = val. This is how one button in a list says which row it belongs to:

<button data-flv-click="remove" data-flv-value-item="{flv(name)}">x</button>

Clicking it sends {"event": "remove", "payload": {"item": "<name>"}}. The handler reads payload["item"] to know which name to remove. You can add as many data-flv-value-* attributes as you need.

2. data-flv-submit — a form, serialized

Put data-flv-submit="<event>" on a <form>. On submit, the client cancels the normal page reload and serializes every named field into the payload, keyed by each input's name:

<form data-flv-submit="add">
  <input name="item" data-flv-clear autocomplete="off" />
  <button type="submit">Add</button>
</form>

Submitting sends {"event": "add", "payload": {"item": "<typed value>"}}. The handler reads payload["item"]. The extra data-flv-clear tells the client to empty that input after a successful submit — so the box is ready for the next name without you touching it.

(Checkbox groups sharing a name arrive comma-joined; radios send the checked value. You get the whole selection as one string — more on that when we build richer forms.)

3. data-flv-change — live input

Put data-flv-change="<event>" on an input or select and it fires on every change (each keystroke for a text input), with the current value in payload["value"]:

<input name="q" data-flv-change="filter" value="{flv(filter)}" />

Typing sends {"event": "filter", "payload": {"value": "<current text>"}} on each change. The handler updates its filter and re-renders — instant, no submit button, no fetch.

Same thing on the client-WASM target: @input / @change

The SFC form below uses the same data-flv-change, and it also works verbatim if you compile the component to WebAssembly. On the WASM target you can also write the decorator form — <input @input="filter"> (per keystroke) or <select @change="pick"> (on selection) — which the emitter wires to a real DOM listener that reads the control's value into payload["value"]. Same payload["value"], same handler; one is the SSR attribute, the other the client-side decorator. See Client-WASM → What works (v0.29.7).


The whole program — string-helper form

Here's the live name list using all three mechanisms, in the string-helper style from C1. It's a complete, runnable program — the examples/course/c2-name-list project:

// Course C2 — events, payloads & forms: a live name list.
//
// Add a name with the form (data-flv-submit), remove one with a per-row
// button that carries its value (data-flv-value-*), and filter the list live
// as you type (data-flv-change). No JavaScript.
//
// Run:  fitz run   → http://127.0.0.1:3000/

from fitz_liveviews import Html, html, flv, h_join, live_layout, html_response, LiveFrame, diff_html

fn seed() -> List<Str> {
  return ["Ada", "Grace", "Margaret"]
}

fn render(items: List<Str>, filter: Str) -> Html {
  let shown = items.filter(fn(it) => filter == "" or it.lower().contains(filter.lower()))
  let rows = shown.map(fn(it) => html("""<li>{flv(it)}
    <button data-flv-click="remove" data-flv-value-item="{flv(it)}">x</button></li>"""))
  let list_html = h_join(rows)
  return html("""<div id="app">
  <form data-flv-submit="add">
    <input name="item" placeholder="Add a name" data-flv-clear autocomplete="off" />
    <button type="submit">Add</button>
  </form>
  <input name="q" placeholder="Filter" data-flv-change="filter" value="{flv(filter)}" />
  <ul>{list_html}</ul>
  <p>{shown.len()} shown / {items.len()} total</p>
</div>""")
}

@get("/")
fn page() -> Response {
  return html_response(live_layout("/live", "app", render(seed(), "")))
}

@ws("/live")
async fn socket(ws: WsConn<LiveFrame>) {
  let items = seed()
  let filter = ""
  let last = render(items, filter).raw
  loop {
    let frame = ws.recv()?
    let ev = frame.event
    if (ev == "add") {
      let name = get_or(frame.payload, "item", "")
      if (name != "") {
        items.push(name)
      }
    }
    if (ev == "remove") {
      let target = get_or(frame.payload, "item", "")
      items = items.filter(fn(it) => it != target)
    }
    if (ev == "filter") {
      filter = get_or(frame.payload, "value", "")
    }
    let new_html = render(items, filter).raw
    ws.send(LiveFrame { html: new_html, patches: diff_html(last, new_html) })?
    last = new_html
  }
}

fn get_or(m: Map<Str, Str>, key: Str, dflt: Str) -> Str {
  return match m.get(key) {
    Ok(v) => v,
    Err(_) => dflt,
  }
}

@server(3000)
fn main() => 0

Run it (cd examples/course/c2-name-list && fitz run, or copy it into your own project) and open http://127.0.0.1:3000/. Add names, filter, remove a row.

The same program — SFC form

As C1's "when to use each" table noted, anything stateful reads better as a single-file component. Here's the exact same name list as an SFC — the examples/course/c2-name-list-sfc project. The component:

// Course C2 (SFC form) — the name list as a single-file component.
//
// Same behavior as examples/course/c2-name-list (the string-helper form),
// written as a .fitzv: state fields, event handlers with `payload`, and a
// <template> with {#for}, auto-escaped {expr}, and @/data-flv-* bindings.
// No flv() / .raw / h_join / triple-quoted strings.

component NoteList {
  state {
    names: List<Str> = ["Ada", "Grace", "Margaret"]
    filter: Str = ""
  }

  event add() {
    if (payload.has("item")) {
      let n = payload["item"]
      if (n != "") { names.push(n) }
    }
  }

  event remove() {
    if (payload.has("item")) {
      let target = payload["item"]
      names = names.filter(fn(it) => it != target)
    }
  }

  event set_filter() {
    if (payload.has("value")) { filter = payload["value"] }
  }

  <template>
    <div id="app">
      <form data-flv-submit="add">
        <input name="item" placeholder="Add a name" data-flv-clear autocomplete="off" />
        <button type="submit">Add</button>
      </form>
      <input name="q" placeholder="Filter" data-flv-change="set_filter" value="{filter}" />
      <ul>
        {#for it in names.filter(fn(x) => filter == "" or x.lower().contains(filter.lower()))}
          <li>{it} <button data-flv-click="remove" data-flv-value-item="{it}">x</button></li>
        {/for}
      </ul>
      <p>{names.len()} total</p>
    </div>
  </template>
}

Look at what disappeared: no html("""..."""), no flv(...), no h_join, no .raw. The three event mechanisms are identicaldata-flv-submit="add", data-flv-value-item="{it}", data-flv-change="set_filter" all work the same — but the markup is real <template> with {#for} and auto-escaped {it}, and each event handler reads payload directly instead of matching frame.event.

The component still needs a small main.fitz to mount it:

from fitz_liveviews import Html, html_response, live_layout, LiveFrame, diff_html, component, dispatch_component_events, flv_register
from NoteList import NoteList, NoteList_render, NoteList_add, NoteList_remove, NoteList_set_filter

@get("/")
fn page() -> Response {
  return html_response(live_layout("/live", "app", component("NoteList", "main")))
}

@ws("/live")
async fn socket(ws: WsConn<LiveFrame>) {
  let last = component("NoteList", "main").raw
  loop {
    let frame = ws.recv()?
    let _ = dispatch_component_events(frame)
    let new_html = component("NoteList", "main").raw
    ws.send(LiveFrame { html: new_html, patches: diff_html(last, new_html) })?
    last = new_html
  }
}

@server(3000)
fn main() => 0

That @ws loop is generic — dispatch_component_events(frame) routes add, remove, and set_filter without you writing a single if. The one rough edge is the from NoteList import ... line, which lists each event handler by its generated name.

Comparison operators in .fitzv (v0.28.2)

The remove handler uses it != target and the filter uses filter == "". Comparison operators (==, !=, <=, >=) inside .fitzv event bodies and templates need Fitz ≥ v0.28.2 — earlier builds only round-tripped == sometimes and rejected != outright. If you hit a parse error on a comparison, update fitz.


Reading it (string-helper form)

A few things worth calling out:

flv(...) on everything user-typed. The names come from user input, so every time one is rendered — in the <li>, in the remove button's data-flv-value-item, in the filter's value — it goes through flv(...), which HTML-escapes it. This is your XSS defense: a name like <script> renders as text, not markup. Interpolating a plain Int (like the counts) is safe without flv; anything a user can type is not.

The handler is a reducer. Same shape as C1: items and filter are locals (one copy per connection), the loop waits on ws.recv(), and each if block applies one event:

  • "add"items.push(name) when the field isn't empty.
  • "remove"items.filter(fn(it) => it != target) — keep everything except the clicked name.
  • "filter" → store the new filter string; render does the actual filtering.

render is still pure. It takes items and filter, computes shown (the filtered list), builds the rows with .map, joins them with h_join (which takes a List<Html> and returns one Str), and returns the page. No event logic leaks into it.

get_or — reading the payload safely. payload.get(key) returns a Result (the key might be missing), so the little get_or helper matches it to a default. That's the idiomatic way to pull a value out of a Map.

First paint matches the socket. Both @get and @ws seed from the same seed() function, so the page renders the three starting names immediately — before the socket even connects. Keep your initial @get render and your @ws starting state in sync, or the list will "pop" when the socket takes over.


Checkpoint

You should be able to:

  • [x] Add a name with the form and see it appear (and the input clear itself).
  • [x] Remove a specific row — the right one, because the button carried its value.
  • [x] Type in the filter and watch the list narrow live, with the counts updating.
  • [x] Explain where each piece of frame.payload came from (data-flv-submit / data-flv-value-* / data-flv-change).

Troubleshooting

The form reloads the page instead of adding

The <form> is missing data-flv-submit, or its value doesn't match the event name in the handler. The attribute is what cancels the browser's default submit.

Remove deletes the wrong row (or nothing)

The button needs data-flv-value-item="{flv(name)}" and the handler must read the same key (payload["item"]). Key names are your contract — a typo on either side breaks it silently.

flv / h_join unknown variable

Add them to the import: from fitz_liveviews import ..., flv, h_join, .... h_join takes exactly one argument, a List<Html> — not a List<Str> and not a separator. Build your rows as Html (don't call .raw on them) and pass the list straight in.

The list is empty until I interact

Your @get render and @ws starting state disagree. Seed both from the same function (here, seed()).


What's next

You can now wire any interaction: clicks with data, forms, live input. C3 — Composing UI is about scale: pulling repeated markup into render helpers, reaching for the component catalog instead of hand-writing every control, and adding scoped styles. The component gallery is the reference — every control there is one of these three mechanisms with nicer CSS.