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(...)returnsHtml;.rawis its underlying string, dropped into the outer template. -
Join a list of fragments →
h_join, which takes aList<Html>and returns a singleStr:Note:let rows = members.map(fn(m) => member_row(m)) // List<Html> html("""<ul>{h_join(rows)}</ul>""") // h_join(...) is a Strh_joinalready returns aStr, so you interpolate it directly — no.raw. Calling.rawon aStris a runtime error. (Rule of thumb:.rawturns anHtmlinto aStr; don't apply it to something that's already aStr.)
Structured state with a type¶
C2's state was a List<Str>. Real data has fields, so use a type:
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 \}:
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:
.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:
- Create your own — a single-file component (
.fitzv) withstate/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". - Compose it — drop
<Card />into another component's template and pass props by attribute (this chapter). - 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 Cardthen<Card />composes a component declared in a siblingCard.fitzv. Each child keeps its own state. - From a dependency (client-WASM target, core v0.29.6): declare
fitz_liveviewsas a dependency in your app'sfitz.toml, then
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>withh_join(and know not to.rawthe result). - [x] Reuse one helper with different props (the two stat cards).
- [x] Back a screen with a
typeand 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.