Skip to content

C3 — Composing UI

Prerequisite: C2 — you can wire clicks with data, forms, and live input.

Objective: stop hand-writing every element. Build a small screen by composing reusable render helpers, back it with a structured type, and give it scoped styling.

Why it matters: C1 and C2 rendered everything inline in one big template. That doesn't scale — real screens repeat the same card, the same row, the same badge dozens of times. In Fitz, a "component" is just a function that returns Html. Compose those and a screen becomes readable, and each piece is reused and tested on its own. This is the same idea the component catalog is built on.

▶ Live preview. A client-WASM build of a small composed UI (tabbed panels) — the same "compose small pieces into a screen" idea, running in the page. The course builds the server-driven version; the behavior is the same.


An Html fragment is just a function

There's no special component syntax for this. A render helper is a normal Fitz function with an Html return type:

fn badge(active: Bool) -> Html {
  let cls = match active { true => "on", false => "off" }
  let txt = match active { true => "active", false => "inactive" }
  return html("""<span class="badge {cls}">{txt}</span>""")
}

Call it wherever you need a badge. Because it's typed, the compiler checks that you pass a Bool and that whatever consumes it gets an Html.

Composing fragments: .raw vs h_join

Two rules cover every case of putting fragments together:

  • Embed one fragment inside another template → interpolate its .raw:

    html("""<li>{flv(m.name)} {badge(m.active).raw}</li>""")
    
    html(...) returns Html; .raw is its underlying string, dropped into the outer template.

  • Join a list of fragmentsh_join, which takes a List<Html> and returns a single Str:

    let rows = members.map(fn(m) => member_row(m))   // List<Html>
    html("""<ul>{h_join(rows)}</ul>""")              // h_join(...) is a Str
    
    Note: h_join already returns a Str, so you interpolate it directly — no .raw. Calling .raw on a Str is a runtime error. (Rule of thumb: .raw turns an Html into a Str; don't apply it to something that's already a Str.)


Structured state with a type

C2's state was a List<Str>. Real data has fields, so use a type:

type Member {
  name: Str
  active: Bool
}

Now the compiler checks every m.name and m.active, and your helpers take a Member instead of loose strings.


The whole screen — string-helper form

Here's a team panel: two stat cards (the stat_card helper reused with different props), a member list built from a member_row helper, a badge helper, and a scoped <style> — the complete examples/course/c3-team-panel project:

// Course C3 — composing UI: a team panel from reusable render helpers.
//
// A `type Member`, a `stat_card` helper reused twice, a `badge` and a
// `member_row` helper, and a scoped <style> block — assembled into one screen.
// Click "toggle" on a row to flip that member's active status.
//
// 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

type Member {
  name: Str
  active: Bool
}

fn seed() -> List<Member> {
  return [
    Member { name: "Ada", active: true },
    Member { name: "Grace", active: false },
    Member { name: "Margaret", active: true },
  ]
}

// A reusable stat card — used twice below with different props.
fn stat_card(label: Str, value: Int) -> Html {
  return html("""<div class="card">
    <div class="stat-value">{value}</div>
    <div class="stat-label">{flv(label)}</div>
  </div>""")
}

fn badge(active: Bool) -> Html {
  let cls = match active { true => "on", false => "off" }
  let txt = match active { true => "active", false => "inactive" }
  return html("""<span class="badge {cls}">{txt}</span>""")
}

fn member_row(m: Member) -> Html {
  return html("""<li class="row">
    <span>{flv(m.name)}</span>
    {badge(m.active).raw}
    <button data-flv-click="toggle" data-flv-value-name="{flv(m.name)}">toggle</button>
  </li>""")
}

fn count_active(members: List<Member>) -> Int {
  return members.filter(fn(m) => m.active).len()
}

fn render(members: List<Member>) -> Html {
  let rows = members.map(fn(m) => member_row(m))
  let cards = h_join([stat_card("Members", members.len()), stat_card("Active", count_active(members))])
  return html("""<div id="app">
  <style>
    #app \{ font-family: system-ui; max-width: 32rem; \}
    .cards \{ display: flex; gap: 1rem; \}
    .card \{ border: 1px solid #ddd; border-radius: .5rem; padding: 1rem; flex: 1; \}
    .stat-value \{ font-size: 2rem; font-weight: 700; \}
    .stat-label \{ color: #666; \}
    .row \{ display: flex; align-items: center; gap: .5rem; \}
    .badge.on \{ color: green; \} .badge.off \{ color: #999; \}
  </style>
  <h1>Team</h1>
  <div class="cards">{cards}</div>
  <ul>{h_join(rows)}</ul>
</div>""")
}

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

@ws("/live")
async fn socket(ws: WsConn<LiveFrame>) {
  let members = seed()
  let last = render(members).raw
  loop {
    let frame = ws.recv()?
    if (frame.event == "toggle") {
      let target = get_or(frame.payload, "name", "")
      members = members.map(fn(m) => flip_if(m, target))
    }
    let new_html = render(members).raw
    ws.send(LiveFrame { html: new_html, patches: diff_html(last, new_html) })?
    last = new_html
  }
}

fn flip_if(m: Member, target: Str) -> Member {
  return match m.name == target {
    true => Member { name: m.name, active: not m.active },
    false => m,
  }
}

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

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

Run it (cd examples/course/c3-team-panel && fitz run) and click toggle on any row — the badge flips and the "Active" stat card updates, because both are computed from the same members list on every render.

The same screen — SFC + helpers

Composition is the case where the two styles work together best. A .fitzv file holds only component and from ... import — no top-level fn — so pure helpers stay in a sibling .fitz and get imported. That's not a limitation to route around; it's the clean split: the SFC owns state + events + the shell, and your reusable fragments stay plain fn -> Html.

The helpers live on their own — the examples/course/c3-team-panel-sfc project's helpers.fitz:

from fitz_liveviews import Html, html, flv

type Member {
  name: Str
  active: Bool
}

fn badge(active: Bool) -> Html {
  let cls = match active { true => "on", false => "off" }
  let txt = match active { true => "active", false => "inactive" }
  return html("""<span class="badge {cls}">{txt}</span>""")
}

fn stat_card(label: Str, value: Int) -> Html {
  return html("""<div class="card"><div class="stat-value">{value}</div><div class="stat-label">{flv(label)}</div></div>""")
}

fn count_active(members: List<Member>) -> Int {
  return members.filter(fn(m) => m.active).len()
}

fn flip_if(m: Member, target: Str) -> Member {
  return match m.name == target {
    true => Member { name: m.name, active: not m.active },
    false => m,
  }
}

And the component imports and uses them — {stat_card(...).raw}, {badge(m.active).raw} — inside a real <template> with {#for}:

// Course C3 (SFC form) — the team panel as a single-file component.
//
// The component owns state + the toggle event + the main template. The pure
// formatting helpers (badge, stat_card) and the domain logic (Member, flip_if,
// count_active) live in a sibling helpers.fitz and are imported here — the
// blessed split: SFC for the stateful shell, plain `fn -> Html` for reusable
// fragments (a .fitzv file itself only holds `component` + `from ... import`).

from helpers import Member, badge, stat_card, count_active, flip_if

component TeamPanel {
  state {
    members: List<Member> = [Member { name: "Ada", active: true }, Member { name: "Grace", active: false }, Member { name: "Margaret", active: true }]
  }

  event toggle() {
    if (payload.has("name")) {
      let target = payload["name"]
      members = members.map(fn(m) => flip_if(m, target))
    }
  }

  <template>
    <div id="app">
      <h1>Team</h1>
      <div class="cards">{stat_card("Members", members.len()).raw} {stat_card("Active", count_active(members)).raw}</div>
      <ul>
        {#for m in members}
          <li class="row">{m.name} {badge(m.active).raw}
            <button data-flv-click="toggle" data-flv-value-name="{m.name}">toggle</button></li>
        {/for}
      </ul>
    </div>
  </template>
}

Same helpers, same reuse (stat_card twice), same toggle — but the shell is a component with a {#for} loop instead of a .map + h_join, and the event is event toggle() reading payload instead of an if frame.event ladder. The markup lost its html("""...""") wrapper; the helpers kept theirs, because that's exactly what they're for.

Why helpers live in .fitz, not in the .fitzv

A helper written in classic .fitz is portable: the SSR emitter calls it as-is, and it stays a normal Fitz function you can test on its own. Keeping it out of the component file is the framework's deliberate design (the .fitzv parser only accepts component and imports), and it keeps the three files — helpers.fitz, the .fitzv, and main.fitz — each doing one job.


Reading it

Reuse is just calling the function twice. The two stat cards are one helper:

let cards = h_join([
  stat_card("Members", members.len()),
  stat_card("Active", count_active(members)),
])

Same function, different props, composed with h_join. Add a third card and it's one more line — no copy-paste.

Derived values live in helpers, not state. count_active(members) is computed from the list every render; it isn't stored. The list is the single source of truth, and everything on screen is a function of it. That's the whole reactive model — change the list, re-render, diff, done.

Scoped styling. The <style> block lives inside the #app root and its rules are prefixed (#app, .card, .badge.on). One caveat: because Fitz's """...""" strings use { } for interpolation, CSS braces must be escaped as \{ and \}:

.card \{ border: 1px solid #ddd; border-radius: .5rem; \}

Miss that and you'll get an "invalid format spec" error at compile time. (This is only for CSS/JS braces inside a template; your {value} interpolations stay as-is. True per-component scoped styles — where the framework prefixes selectors for you — come with LiveComponents in C5.)

The event carries the row's identity. The toggle button uses data-flv-value-name="{flv(m.name)}" (the C2 pattern), and flip_if rebuilds the list, flipping only the matching member:

members = members.map(fn(m) => flip_if(m, target))

.map returns a new list; the handler reassigns members. No mutation of the element in place — build the new state and re-render.


Where components come from next

You just built badge / card / row "components" as functions. The component catalog is a whole cookbook of these — DataGrid, tabs, modal, toasts, tree, cascade selects — each a render helper (or a small set) you can lift into your screen. The component gallery runs them all in one page. When you need one, copy the pattern; it's the same fn ... -> Html you've been writing.

Creating, composing, and importing components

There are three moves, and you've now seen the first:

  1. Create your own — a single-file component (.fitzv) with state / event / <template> / <style scoped>. You built one in C1 → "the clean way: a single-file component"; the full walkthrough for a stateful LiveComponent is in LiveComponents → "A minimal component from scratch", and the client-WASM shape is in Client-WASM → "Authoring a client component".
  2. Compose it — drop <Card /> into another component's template and pass props by attribute (this chapter).
  3. Import it — reuse a component you (or a library) already created, instead of copying it:

Copying the pattern is fine for a helper. For a whole SFC component you'd rather import it — and you can, across files and even across packages:

  • Cross-file: from Card import Card then <Card /> composes a component declared in a sibling Card.fitzv. Each child keeps its own state.
  • From a dependency (client-WASM target, core v0.29.6): declare fitz_liveviews as a dependency in your app's fitz.toml, then
from fitz_liveviews.ui.Badge import badge as Badge

pulls the companion Badge straight out of the library and inlines it into your standalone WASM crate — no copy. (The companion component is badge lowercase; alias it to a Capitalized Badge so <Badge /> reads as a component.) This is how an external WASM app consumes the companion UI as a library. See Client-WASM → Sharing source with the SSR kit.


Checkpoint

You should be able to:

  • [x] Write a render helper (fn ... -> Html) and call it from another template with .raw.
  • [x] Join a List<Html> with h_join (and know not to .raw the result).
  • [x] Reuse one helper with different props (the two stat cards).
  • [x] Back a screen with a type and derive on-screen values with helpers.
  • [x] Add a scoped <style> with escaped CSS braces.

Troubleshooting

invalid format spec on a <style> or <script>

Raw { / } in CSS or JS collide with Fitz interpolation. Escape them as \{ and \} inside the template string.

field access .raw on a value of type Str

You called .raw on something that's already a Str — usually an h_join result. h_join returns Str; interpolate it directly. Use .raw only to turn an Html into a string.

The whole page re-renders / flickers on toggle

It shouldn't — the diff only moves changed nodes. If it does, your @get and @ws initial renders probably disagree (see C2's checkpoint) so the first diff is huge. Seed both from the same function.


What's next

You can compose a screen from typed helpers and style it. So far state has been in memory and per-connection. C4 — Persistent & multi-user state backs the list with Postgres and uses ws.broadcast(...) so two browsers stay in sync — the step from "a nice demo" to "a real app". The chat example and the Admin ABM are where this goes.