Skip to content

C5 — LiveComponents

Prerequisite: C4 — you know when to reach for an SFC (in-memory UI state) versus a string-helper handler (the database).

Objective: define a component once and render many independent instances of it, each with its own state.

Why it matters: C1–C4 had one component per page. Real screens repeat a stateful widget: a metric tile on a dashboard, a card editor on every kanban card, an expand/collapse on every row. You don't want one shared counter for all of them — you want each instance to own its slice of state. That's a LiveComponent: one definition, component("Name", "<instance-id>") per copy, independent state each.


The idea: an instance is a name + an id

You've already been calling component("Name", "id") to mount a single SFC (the main.fitz wiring since C1). The second argument is the instance id, and it's the whole trick: the framework keeps a separate state store per (component name, instance id) pair. Mount the same component under two different ids and you get two independent instances.

So a dashboard of three counters is one Tile component and three ids.


The component

Nothing new here — it's the counter from C1, as an SFC:

// Course C5 — one Tile definition, many independent instances.
//
// Each `component("Tile", "<id>")` in main.fitz gets its OWN copy of this
// state, keyed by the instance id. Bumping one tile never touches another.

component Tile {
  state { count: Int = 0 }
  event bump() { count = count + 1 }
  event reset() { count = 0 }
  <template>
    <div class="tile-body">
      <span class="tile-count">{count}</span>
      <button data-flv-click="bump">+1</button>
      <button data-flv-click="reset">reset</button>
    </div>
  </template>
}

The parent renders many

The parent owns the layout and config (which tiles, what labels); each Tile owns its state (its count). They compose by the parent calling component("Tile", cfg.id) once per tile — the examples/course/c5-metric-tiles project:

from fitz_liveviews import Html, html, flv, h_join, live_layout, html_response, LiveFrame, diff_html, component, dispatch_component_events, flv_register
from Tile import Tile, Tile_render, Tile_bump, Tile_reset

type TileCfg {
  id: Str
  label: Str
}

fn tiles() -> List<TileCfg> {
  return [
    TileCfg { id: "downloads", label: "Downloads" },
    TileCfg { id: "signups", label: "Signups" },
    TileCfg { id: "errors", label: "Errors" },
  ]
}

fn render_tile(cfg: TileCfg) -> Html {
  let inner = component("Tile", cfg.id)
  return html("""<article class="tile"><h3>{flv(cfg.label)}</h3>{inner.raw}</article>""")
}

fn render() -> Html {
  let cards = h_join(tiles().map(fn(c) => render_tile(c)))
  return html("""<div id="app"><h1>Metrics</h1><div class="grid">{cards}</div></div>""")
}

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

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

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

Run it (cd examples/course/c5-metric-tiles && fitz run) and open http://127.0.0.1:3000/. Press +1 on "Downloads" — only that tile's number moves. "Signups" and "Errors" don't budge. Three counters, one definition.


Reading it

component("Tile", cfg.id) is the instance. Each TileCfg carries a unique id ("downloads", "signups", "errors"). render_tile mounts the Tile component under that id and wraps it in a card with the parent-supplied label. Same component, three instances, three independent counts.

The parent owns config, the component owns state. The label lives in TileCfg (parent data); the count lives in Tile's state (per instance). That split keeps the component reusable — it knows nothing about labels or layout, just how to count.

dispatch_component_events(frame) routes to the right instance. When you click +1 on a tile, the client sends the event with that tile's instance id (the framework reads it from the data-flv-component-name / instance wrapper the composition emits). dispatch_component_events looks up the matching instance and mutates only its state. The @ws loop is the same generic three lines from C1 — it never grows as you add tiles or events.

No per-instance wiring. You didn't write an if frame.event == ... for each tile. One component, one dispatch call, N instances — the handler doesn't care how many.


When to reach for a LiveComponent

  • You have N of the same stateful widget — dashboard tiles, kanban card editors, per-row expand toggles, a list of independently-editable items.
  • Each needs its own slice of UI state — open/closed, editing/not, a local count, a draft value.
  • You want the parent handler to stay small — every event the component handles disappears from the parent's dispatch.

You do not need a LiveComponent for a single stateful widget with no siblings (put the state in the page's @ws handler) or a pure formatting fragment (a plain fn -> Html helper, C3). And remember C4's boundary: component state is in-memory — for shared, durable data, the database + a string-helper handler is still the tool.

This is the pattern behind the two flagship showcases: the dashboard (six independent metric_tile instances) and the kanban (a card_editor per card). Both are this chapter, scaled up.

Per-connection instances (v0.11.0)

The instance id also decides who shares the state: a fixed id is shared by every connection; an id minted per socket (let cid = Uuid.v4().to_str()) gives each connection its own private instance — right for dialogs, selections and filters that must not leak between users. Seed connection-scoped data (like the locale) with component_with(name, cid, State { ... }). Full recipe in Components → Per-connection instances; the Admin ABM's ConfirmDialog.fitzv is the worked example.


Checkpoint

  • [x] Three tiles render from one Tile component.
  • [x] Bumping one tile changes only that tile — per-instance state is isolated.
  • [x] The @ws handler is the same generic loop regardless of tile count.
  • [x] You can say what the instance id (component("Name", "id")) does.

Troubleshooting

All tiles change together

They're sharing one instance id. Each component("Tile", "<id>") needs a distinct id — reuse the same string and you get one shared instance.

Clicking a tile does nothing

The composition wrapper carries the instance context for dispatch_component_events to route on. Make sure you mount via component("Tile", id) (which emits that wrapper), not by pasting the component's raw HTML yourself.


What's next

You can build reusable, independently-stateful components. C6 — Ship it is the last step: fitz build turns any of these projects into a single native binary, and a small Dockerfile puts it in a container — the same path the Admin ABM uses to deploy.