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:
- The screen opens and
Counterruns once. It returns a tree: a column with a heading and a button. - The game makes the kit widgets for it, in the player’s style (Vanilla or Fancy).
- A click calls
set_n(1). That marksCounteras changed, and on the next frame it runs again and returns a new tree. - 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 anif. - 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.