Real stories:How a football-school coach shipped his apps in one week soon

Interaction & state

Step 2 of the UI track. The screen from the previous step comes alive: tapping a habit marks it done, the stats count themselves, chips filter the list, and the button at the bottom adds a new card. Along the way — the whole LeCodes UI state model: handlers on buttons, signals, reactive bindings and style classes.

As before, every example is a complete program: the phone next to you runs exactly the code you're reading.

The first tap

Exactly three things in LeCodes are tappable: UIButton, UIScreen and UIWidget. Want a clickable card, a list row, an icon — wrap it in a UIButton. The handler chains on — .onClick(...) — and updating the screen is a plain mutation: elements are live objects, UIText has a .text property, and writing it repaints the element immediately. No virtual DOM, no "re-render".

To keep a handle on a nested element, assign it to a variable right in the children list — the assignment both creates the element and remembers it:

TypeScript
let label: UIText
let taps = 0

const screen = UIScreen(
  label = UIText("Taps: 0").style({ color: "white", fontSize: 32, fontWeight: 800 }),

  UIButton(
    UIText("Tap me").style({ color: "white", fontSize: 16, fontWeight: 700 }),
  )
    .style({ bgColor: "#FF4032", borderRadius: 16, p: 16, $pressed: { opacity: 0.7 } })
    .onClick(() => { label.text = `Taps: ${++taps}` }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)", gap: 16 })

screen.open()

Two details you'll use everywhere:

  • A button draws nothing of its own — no background, no border, no press reaction. Background and radius are ordinary styles, and $pressed: { opacity: 0.7 } is a style block the system switches on while a finger is down on the button. Without it a press is invisible.
  • UIButton defaults to a row with children centered on both axes, so the label sits centered with no alignItems.

The imperative way is honest and fast, but it has a ceiling: as soon as the same number needs to show in three places, you're syncing three .text writes by hand. That's what signals are for.

Signals

signal(value) is a container for one value. Read .value, write .value. The magic is in the read: when .value is read inside a binding function (UIText(() => …), a style function, a computed), the element subscribes to the signal and repaints itself when the value changes. A write is a precise property update on the already-built tree — nothing is recreated.

TypeScript
const count = signal(0)

const screen = UIScreen(
  UIText(() => `Taps: ${count.value}`).style({ color: "white", fontSize: 32, fontWeight: 800 }),
  UIText(() => (count.value === 0 ? "None yet" : count.value < 5 ? "Warming up" : "Great!"))
    .style({ color: "#8a8a93", fontSize: 15 }),

  UIButton(UIText("Tap me").style({ color: "white", fontSize: 16, fontWeight: 700 }))
    .style({
      borderRadius: 16, p: 16, $pressed: { opacity: 0.7 },
      bgColor: () => (count.value < 5 ? "#FF4032" : "#34c759"),
    })
    .onClick(() => count.value++),

  UIButton(UIText("Reset").style({ color: "#8a8a93", fontSize: 15 }))
    .style({ p: 8, $pressed: { opacity: 0.6 } })
    .onClick(() => { count.value = 0 }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)", gap: 12 })

screen.open()

One signal, three subscribers: the heading, the caption and the button color. Not a single .text = … in the code — the handler changes only the state, and the view follows.

  • A binding is () => value wherever an element expects a string or a primitive style: UIText text, any style like bgColor, opacity, transform. The function runs once at creation (the element gets a concrete value) and again after every write to a signal it read.
  • Writes are batched: several .value = … in one tick produce one repaint. Writing an equal value is a no-op.
  • Derived values are computed(() => …): a lazy cache, recomputed only when something it read changes. We'll need it for the stats in a moment.
  • Side effects (save, send to the network, log) go in effect(() => …); it returns a dispose function. UI bindings need no disposal — they live and die with their element.
Note

The LeCodes compiler can wrap bindings for you: UIText(`Taps: ${count.value}`) in project code becomes UIText(() => …). The guide writes the arrow explicitly so you can see exactly where the reactivity lives. Inside your own helper functions (UIText(fmt())) the automatic form doesn't kick in — the arrow is required there.

A list from state

The habit list is a signal too: an array of objects. A container accepts a function instead of childrenUIColumn(() => habits.value.map(HabitRow)) — and rebuilds itself when the array changes. The rebuild is minimal: the runtime compares items by reference — cards whose objects stayed in the array are left alone, new ones are inserted, departed ones removed.

Hence the update rule: don't mutate the array in place, write a new one. Marking a habit done means replacing one object in a new array; exactly that card re-renders:

TypeScript
const CARD = "#1a1a20"
const MUTED = "#8a8a93"

type Habit = { id: number; title: string; time: string; color: string; done: boolean }

const habits = signal<Habit[]>([
  { id: 1, title: "Morning run", time: "07:30", color: "#34c759", done: true },
  { id: 2, title: "Read 20 pages", time: "13:00", color: "#ff9f0a", done: true },
  { id: 3, title: "Practice piano", time: "18:00", color: "#5e9eff", done: false },
  { id: 4, title: "Stretching", time: "20:30", color: "#af6bff", done: false },
])
const doneCount = computed(() => habits.value.filter((h) => h.done).length)

const toggle = (h: Habit) => {
  habits.value = habits.value.map((x) => (x === h ? { ...x, done: !x.done } : x))
}

const HabitRow = (h: Habit) =>
  UIButton(
    UIColumn(
      UIText(h.done ? "✓" : "").style({ color: "white", fontSize: 15, fontWeight: 700 }),
    ).style({
      width: 28, height: 28, borderRadius: 14,
      bgColor: h.done ? h.color : "transparent",
      border: h.done ? "0" : `2px solid ${h.color}`,
      alignItems: "center", justifyContent: "center",
    }),
    UIColumn(
      UIText(h.title).style({ color: "white", fontSize: 16, fontWeight: 600 }),
      UIText(h.time).style({ color: MUTED, fontSize: 13 }),
    ).style({ gap: 2, flexGrow: 1 }),
  )
    .style({ bgColor: CARD, borderRadius: 16, p: 14, gap: 12, opacity: h.done ? 0.75 : 1, $pressed: { opacity: 0.6 } })
    .onClick(() => toggle(h))

const screen = UIScreen(
  UIText(() => `Done ${doneCount.value} of ${habits.value.length}`)
    .style({ color: "white", fontSize: 22, fontWeight: 800 }),

  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)" })

screen.open()

Notice what HabitRow doesn't have: a single binding. The card reads h.done once at creation — which is fine, because when the state changes the object is replaced and the card is built anew. The reactivity lives one level up — in the column's children function and in the computed counter.

  • habits.value.push(x) won't work: same array reference, subscribers never hear about it. Write habits.value = [...habits.value, x], .filter(…), .map(…).
  • The identity key is the object reference. Don't recreate every object on each write (items.map(i => ({ ...i }))) — that's a full list re-render.
  • A container with function children belongs to the binding — don't call .append() / .remove() on it by hand.
  • For hundreds of rows, function children are the wrong tool; that's UIVirtualizedList, covered in the Lists & data step.
Tip

There is a second way: keep done not in the object but as a signal inside it (done: signal(false)) and bind the card's styles to it. Then the card is recolored rather than recreated — worth it when the row carries an animation or focus you'd hate to lose. We'll come back to this in the Polish step.

Style classes

The filter chips "All / Left / Done" are a typical composite element with a named state: the active chip changes both its background and its label color. You could hang two bindings on it — or declare a style class: a $active: { … } block inside .style(), switched through el.class. A class has two advantages over a scattering of bindings: it takes a duration (the switch animates), and it cascades — a class set on the button lights up the same-name $active blocks on all its descendants. One flag on the container, and the label inside reacts too:

TypeScript
const ACCENT = "#FF4032"
const CARD = "#1a1a20"
const MUTED = "#8a8a93"

type Habit = { id: number; title: string; time: string; color: string; done: boolean }
type Filter = "all" | "todo" | "done"

const habits = signal<Habit[]>([
  { id: 1, title: "Morning run", time: "07:30", color: "#34c759", done: true },
  { id: 2, title: "Read 20 pages", time: "13:00", color: "#ff9f0a", done: true },
  { id: 3, title: "Practice piano", time: "18:00", color: "#5e9eff", done: false },
  { id: 4, title: "Stretching", time: "20:30", color: "#af6bff", done: false },
])
const filter = signal<Filter>("all")
const visible = computed(() =>
  habits.value.filter((h) => filter.value === "all" || (filter.value === "done") === h.done),
)

const Chip = (label: string, value: Filter) =>
  UIButton(
    UIText(label).style({ color: MUTED, fontSize: 13, fontWeight: 600, $active: { color: "white" } }),
  )
    .style({ height: 32, px: 14, borderRadius: 16, bgColor: CARD, $active: { bgColor: ACCENT, duration: 150 } })
    .class({ active: () => filter.value === value })
    .onClick(() => { filter.value = value })

const HabitRow = (h: Habit) =>
  UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color, opacity: h.done ? 1 : 0.35 }),
    UIText(h.title).style({ color: "white", fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIText(h.done ? "done" : h.time).style({ color: MUTED, fontSize: 13 }),
  ).style({ bgColor: CARD, borderRadius: 16, p: 14, gap: 12, alignItems: "center" })

const screen = UIScreen(
  UIRow(Chip("All", "all"), Chip("Left", "todo"), Chip("Done", "done")).style({ gap: 8 }),
  UIColumn(() => visible.value.map(HabitRow)).style({ gap: 10, mt: 16 }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)" })

screen.open()

.class({ active: () => filter.value === value }) is the reactive form: the class tracks the signal like any binding. The manual form — chip.class.active = true — is the same mechanism; read your own state with chip.class.active. Two names are reserved and toggled by the system: $pressed (a finger on the element) and $focused (an input has focus) — they cascade too, so an icon inside a button can react to the button's press in its own way.

Precedence when states compete for one property: base style → $classes$pressed/$focusedonPressed/onFocused. The full contract is in the Style classes reference.

The whole screen

All together — the screen from the previous step, but alive: stats computed via computed, chips filtering, a tap on a card marking the habit, the button at the bottom adding a new one (for now from a fixed list of ideas; a real form comes in the Forms step):

TypeScript
const ACCENT = "#FF4032"
const BG = "#0e0e12"
const CARD = "#1a1a20"
const MUTED = "#8a8a93"

type Habit = { id: number; title: string; time: string; color: string; done: boolean }
type Filter = "all" | "todo" | "done"

const habits = signal<Habit[]>([
  { id: 1, title: "Morning run", time: "07:30", color: "#34c759", done: true },
  { id: 2, title: "Read 20 pages", time: "13:00", color: "#ff9f0a", done: true },
  { id: 3, title: "Practice piano", time: "18:00", color: "#5e9eff", done: false },
  { id: 4, title: "Stretching", time: "20:30", color: "#af6bff", done: false },
])
const filter = signal<Filter>("all")

const doneCount = computed(() => habits.value.filter((h) => h.done).length)
const visible = computed(() =>
  habits.value.filter((h) => filter.value === "all" || (filter.value === "done") === h.done),
)

const toggle = (h: Habit) => {
  habits.value = habits.value.map((x) => (x === h ? { ...x, done: !x.done } : x))
}

const IDEAS = ["Glass of water", "10 minutes of meditation", "A walk", "No phone before bed"]
const addHabit = () => {
  const n = habits.value.length
  habits.value = [...habits.value, { id: n + 1, title: IDEAS[n % IDEAS.length], time: "21:00", color: ACCENT, done: false }]
}

const Stat = (value: () => string, label: string) =>
  UIColumn(
    UIText(value).style({ color: "white", fontSize: 22, fontWeight: 800 }),
    UIText(label).style({ color: MUTED, fontSize: 13 }),
  ).style({ bgColor: CARD, borderRadius: 14, p: 12, gap: 2, flex: 1, alignItems: "center" })

const Chip = (label: string, value: Filter) =>
  UIButton(UIText(label).style({ color: MUTED, fontSize: 13, fontWeight: 600, $active: { color: "white" } }))
    .style({ height: 32, px: 14, borderRadius: 16, bgColor: CARD, $active: { bgColor: ACCENT, duration: 150 } })
    .class({ active: () => filter.value === value })
    .onClick(() => { filter.value = value })

const HabitRow = (h: Habit) =>
  UIButton(
    UIColumn(UIText(h.done ? "✓" : "").style({ color: "white", fontSize: 15, fontWeight: 700 })).style({
      width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
      bgColor: h.done ? h.color : "transparent", border: h.done ? "0" : `2px solid ${h.color}`,
    }),
    UIColumn(
      UIText(h.title).style({ color: "white", fontSize: 16, fontWeight: 600 }),
      UIText(h.time).style({ color: MUTED, fontSize: 13 }),
    ).style({ gap: 2, flexGrow: 1 }),
  )
    .style({ bgColor: CARD, borderRadius: 16, p: 14, gap: 12, opacity: h.done ? 0.75 : 1, $pressed: { opacity: 0.6 } })
    .onClick(() => toggle(h))

const screen = UIScreen(
  UIText("Today").style({ color: "white", fontSize: 32, fontWeight: 800 }),
  UIText(date().format("dddd, DD MMMM")).style({ color: MUTED, fontSize: 15 }),

  UIRow(
    Stat(() => `${doneCount.value}`, "done"),
    Stat(() => `${habits.value.length - doneCount.value}`, "left"),
    Stat(() => `${Math.round((doneCount.value / habits.value.length) * 100)}%`, "today"),
  ).style({ gap: 10, mt: 8 }),

  UIRow(Chip("All", "all"), Chip("Left", "todo"), Chip("Done", "done")).style({ gap: 8, mt: 12 }),
  UIColumn(() => visible.value.map(HabitRow)).style({ gap: 10, mt: 12 }),

  UISpacer(),

  UIButton(UIText("+ New habit").style({ color: "white", fontSize: 16, fontWeight: 700 }))
    .style({ bgColor: ACCENT, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } })
    .onClick(addHabit),
).style({ bgColor: BG, p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)", gap: 4 })

screen.open()

Notice how the roles split: state is three lines at the top (habits, filter, two computeds), actions are two functions that write only to signals, and the rest of the file is pure layout that merely reads the state. Every following step of the track is built the same way — however many screens get added.

What you learned

  • Only UIButton, UIScreen and UIWidget are tappable; a button draws nothing itself — background, radius and $pressed are yours to set.
  • A reference to a nested element is an assignment right in the children list; content changes through properties (.text, .value, .src), not styles.
  • signal / computed / effect hold app state; () => value in a text or style is a binding that updates itself.
  • A list from state is a function instead of a container's children; update arrays with a new reference, row identity is the object.
  • $name: { … } in a style + el.class — named states with an animated switch and a cascade to descendants.

Reference: Signals · Buttons & inputs · Style classes · Pointer events.

Next: theme & tokens — the colors move out of constants into theme(), we add a dark/light switch and an app-wide font.