Skip to content

Components: GUIs that update themselves

Lesson 14.10

In the last lesson you built a screen widget by widget, and every change meant finding the widget and calling a setter on it. Components turn that around: you write a function that says what the screen looks like for the current state, and the game works out what changed. If you know React, this is the same idea.

A first component

local ui = cl.ui

local Counter = ui.component("Counter", function(props)
  local n, set_n = ui.use_state(0)
  return ui.column{ gap = 4,
    ui.heading{ text = props.title },
    ui.button{ label = "Clicked " .. n .. " times", on_press = function() set_n(n + 1) end },
  }
end)

ui.open{ title = "Counter", root = Counter, props = { title = "My first component" }, x = 40, y = 40, width = 200 }

What happens:

  1. The screen opens and Counter runs once. It returns a tree: a column with a heading and a button.
  2. The game makes the kit widgets for it, in the player’s style (Vanilla or Fancy).
  3. A click calls set_n(1). That marks Counter as changed, and on the next frame it runs again and returns a new tree.
  4. The game compares the new tree with the old one. Only the button’s label differs, so only the label is changed. Nothing is rebuilt.

While nobody clicks, nothing runs at all.

Props and children

props is the table a component was called with. Use components inside each other like widgets:

local Stat = ui.component("Stat", function(p)
  return ui.row{ gap = 4, height = 12,
    ui.label{ text = p.name, grow = 1 },
    ui.label{ text = tostring(p.value), width = 40 },
  }
end)

local Stats = ui.component("Stats", function()
  return ui.panel{ padding = 8, gap = 2,
    Stat{ name = "Kills", value = 12 },
    Stat{ name = "Deaths", value = 3 },
  }
end)

A component whose props didn’t change is not run again when its parent runs. That is why breaking a screen into small components keeps it cheap.

Lists need keys

When you show a list, give every entry a key:

ui.map(players, function(name) return ui.label{ key = name, text = name } end)

With keys, the game knows which entry is which: when one player leaves, one label goes, and the others stay. Without keys it matches by position.

Effects

ui.use_effect(fn, deps) runs fn after the screen updated, when something in deps changed. Use it for things that are not drawing: saving, logging, starting a timer. If fn returns a function, that one cleans up (before the next run, and when the component goes away).

local Clock = ui.component("Clock", function()
  local secs, set_secs = ui.use_state(0)
  ui.use_effect(function()
    local task = cl.sched.run_client(20, function() set_secs(secs + 1) end)  -- in one second
    return function() task:cancel() end   -- the next second came, or the screen closed: stop the old timer
  end, { secs })                          -- again after every change of secs
  return ui.label{ text = "Open for " .. secs .. " s" }
end)

A setter also takes a function, set_secs(function(s) return s + 1 end), which gets the newest value.

Rules to remember

  • Call hooks (use_state, use_effect, …) at the top of the component, in the same order every time; never inside an if.
  • Change tables by making new ones (set_list({ table.unpack(list) }) plus your change), not by editing them in place.
  • Handlers get Lua values: a toggle gives true/false, a slider a number, a dropdown the index and the text.

Exercise

Make a “Shopping list” screen: a text field and an Add button, and below it the items, each with a remove button. The Add button should be disabled while the text field is empty.

Show a solution
local ui = cl.ui

local Item = ui.component("Item", function(p)
  return ui.row{ gap = 4, height = 20,
    ui.label{ text = p.name, grow = 1 },
    ui.icon_button{ icon = "trash", tooltip = "Remove", on_press = p.on_remove },
  }
end)

local List = ui.component("List", function()
  local items, set_items = ui.use_state({})
  local text, set_text = ui.use_state("")
  return ui.panel{ padding = 8, gap = 4,
    ui.heading{ text = "Shopping list" },
    ui.row{ gap = 4, height = 20,
      ui.text_field{ hint = "Item", text = text, on_change = set_text },
      ui.button{ label = "Add", width = 50, disabled = text == "", on_press = function()
        local next = { table.unpack(items) }
        next[#next + 1] = text
        set_items(next)
        set_text("")
      end },
    },
    ui.map(items, function(name, i)
      return Item{ key = i .. name, name = name, on_remove = function()
        local next = { table.unpack(items) }
        table.remove(next, i)
        set_items(next)
      end }
    end),
  }
end)

ui.open{ title = "Shopping list", root = List, x = 40, y = 30, width = 240 }

Recap: cl.ui.component(name, fn) makes a component, cl.ui.open{ root = ... } shows it, cl.ui.use_state holds state, cl.ui.use_effect runs work after updates, and keys keep list entries apart. The full reference is Components.