Skip to content

Counter — the "hello world" (Phase 2)

A real-time counter driven by WebSocket events. Click a button, the number updates without a page reload. No JavaScript build step, no npm install, no bundler.

Source: examples/counter/

Run

cd examples/counter
fitz run

Then open http://127.0.0.1:3000/ — ideally in two browser tabs at once so you can see each tab has its own independent counter (per-connection state; shared state lands in Phase 4).

What each file does

  • fitz.toml — package manifest. Declares this project as a [bin] and pulls in the fitz_liveviews library via a path = "../.." dependency.
  • src/main.fitz — the whole counter:
    1. type CounterState — the state (just an Int)
    2. fn render(state) — turns state into Html
    3. @get("/") — initial HTML render + client-side script bundle
    4. @ws("/live/counter") — receives events, mutates state, computes the diff against the last snapshot, and sends compact patches
    5. @server(3000) — binds the port

The whole source

// Fitz LiveViews — real-time counter (Phase 11.6.e migration).
//
// Run:   fitz run
// Then open http://127.0.0.1:3000/ in two browser tabs — both stay in
// sync as you click the buttons. **Behaviour change from the original
// per-connection counter**: `component("Counter", "root")` shares one
// server-side state instance across every tab, matching the Phase 4
// dashboard/kanban model. If you want per-tab isolation, use a unique
// `instance_id` per connection (e.g. a UUID passed via query string).

from fitz_liveviews import Html, html, live_layout, html_response, LiveFrame, diff_html, component, dispatch_component_events, flv_register

// Bring the component's type + render/event fns into scope. The
// `.fitzv` loader (Phase 11.6.d) transforms `Counter.fitzv` into
// classic Fitz source that emits `@live_component("Counter")` on the
// type + `@render_for` + `@on` fns.
from Counter import Counter, Counter_render, Counter_increment, Counter_decrement, Counter_reset


// -----------------------------------------------------------------------------
// Boot registration is IMPLICIT (Fitz core v0.21.0+)
// -----------------------------------------------------------------------------
//
// The compiler auto-generates the `flv_register("Counter", Counter { },
// Counter_render, {"increment": Counter_increment, ...})` call from
// the metadata that `@live_component` + `@render_for` + `@on` leave in
// the imported `Counter.fitzv` module. Cross-module auto-inject shipped
// in Fitz core v0.21.0 (Phase 11.6.e §9.bb) — for it to work, `main.fitz`
// must import the state type + render fn + every event handler by their
// canonical names (`from Counter import Counter, Counter_render,
// Counter_<event>...`), which is what the `from Counter import ...`
// line above already does.


// -----------------------------------------------------------------------------
// HTTP GET — initial render + client runtime
// -----------------------------------------------------------------------------

@get("/")
fn counter_page() -> Response {
  let initial = component("Counter", "root")
  return html_response(live_layout("/live/counter", "counter-app", initial))
}


// -----------------------------------------------------------------------------
// WebSocket — event/patch cycle
// -----------------------------------------------------------------------------

@ws("/live/counter")
async fn counter_socket(ws: WsConn<LiveFrame>) {
  let last_html = component("Counter", "root").raw
  loop {
    let frame = ws.recv()?
    let _handled = dispatch_component_events(frame)
    // Re-render the component (its state was mutated by the dispatch)
    // and ship the incremental patch. Client falls back to `html` if
    // the patches don't apply cleanly.
    let new_html = component("Counter", "root").raw
    let patches = diff_html(last_html, new_html)
    ws.send(LiveFrame { html: new_html, patches: patches })?
    last_html = new_html
  }
}


// -----------------------------------------------------------------------------
// Server config
// -----------------------------------------------------------------------------

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

Poking under the hood

Check the server logs and the network tab of your browser's dev tools. You'll see:

  1. GET / returns the initial HTML.
  2. The browser upgrades a connection at ws://127.0.0.1:3000/live/counter.
  3. Each click sends a small JSON frame like {"event":"increment","payload":{},"html":"","patches":[]}.
  4. The server responds with a JSON frame carrying:
    • patches — compact list like [{"op":"text","path":[0,3,0],"content":"Count: 6"}]
    • html — the full new HTML as a fallback (only used if patches fail)
  5. The client walks the DOM by path index and applies patches directly. Only the <p> element mutates — no re-mount of the surrounding tree.

What's missing (comes later)

  • Shared state. Two tabs = two counters. Phase 4 adds per-LiveView shared state.
  • Forms. No data-flv-submit needed here — but chat uses it in Phase 3a.
  • @live decorator. The current @get + @ws pattern is a Phase 2 MVP shape. @live("/counter") as a single decorator is a Phase 6+ language enhancement.

See the Roadmap for the full plan.