State Storage
ToolGUI stores data at three levels, longest lived first:
- App Cache: for the whole process, shared by every user. Not provided by ToolGUI — the developer implements it.
- Session Cache: for one user's connection to the app.
- State Cache: for the page currently shown, through
p.State.
Why cache at all:
-
Faster access: Frequently used data can be retrieved from the cache much faster than recalculating it or fetching it from an external source every time. This improves the application's overall performance.
-
Reduced resource usage: By avoiding redundant calculations and external data fetching, the app can conserve resources like CPU and network bandwidth.
Two paths to the same state: p.State and c.State
A page function gets the state as p.State; a container carries the same
*State as c.State. They are the same object — App.Run hands one state to
the page function and to every container of that run — so neither sees
anything the other does not, and both are kept.
Which one to use follows from what is in hand:
- Page code takes a
*tgframe.Params, so it readsp.State. - A custom component takes a
*tgframe.Containerand nothing else, soc.Stateis how it reaches the state at all. That is the reason the field exists: a component has to be callable anywhere a container is — inside a layout, inside a slot — without the page function handing it anything.
A component that reaches for p.State has taken a dependency on the page it
was first written in, and stops working in the next one.
A container built directly with NewContainer carries whatever state its
caller passed, which may be nil. A component that has to work there checks
for it, the way Dialog does.
Reading a value out of the state
p.State holds any, so every getter has to answer what happens when the
value is not the type asked for. None of them panic. There are three, and
they differ in what they mean by "the right type":
| Getter | Reads | Misses as |
|---|---|---|
Get[T] | a value stored as a T, and nothing else | zero value, false |
GetNumber[T] | any number, as a T | zero value, false |
GetObject | anything, through a JSON round trip | leaves out alone |
Get[T] is a plain type assertion, and the one to reach for by default:
name, ok := p.State.Get[string]("name")
State.Default[T] is the same idea for a value the page mutates. It stores
the default the first time round and hands back a pointer, so what the page
writes through it is what the next run reads:
todoList := p.State.Default("todoList", TODOList{})
todoList.Add("buy milk")
Numbers are the one place a type is not taken literally, and GetNumber[T] is
why. The frontend sends every number as JSON, so an event lands a float64
whatever the component's own type is, while a default written from Go code
carries whichever integer type was at hand. GetNumber reads either, so
Set(key, 30), Set(key, int64(30)) and Set(key, 30.0) are the same value:
age, ok := p.State.GetNumber[int]("number_component_Age")
An integer is read exactly, so an id past 2^53 comes back as it went in. A
float read as an integral T truncates, and a number T cannot hold is
absent rather than whatever the conversion produced. A page's own numeric
type — a type Count int — counts as a number on both sides. A string does
not: Set(key, "30") reads back as false.
GetObject is the third one, and it is not a getter for a type so much as a
decoder. It marshals what the key holds and unmarshals it into out, so a
value the client sent as a JSON array reads back into the []int or the
struct it stands for — which Get[[]int] would miss, because what the state
is holding is a []any:
var idxes []int
err := p.State.GetObject("multiselect_component_Fruit", &idxes)
So: Get[T] for a value the page itself wrote, GetNumber[T] for a number
from either side, and GetObject for a composite value the client sent. A
missing key is not an error for GetObject: out is left as it was.
Setting an input's initial value
An input reads its value from the state under its own id, so writing that key before the component runs is what the page reads back from it:
func Main(p *tgframe.Params) error {
if _, ok := p.State.GetNumber[float64]("number_component_Age"); !ok {
p.State.Set("number_component_Age", 30)
}
age, _ := tgcomp.Number[int64](p.Main, "Age") // 30, before anyone types
...
}
The guard matters: Set on every run overwrites what the user just typed. Use
the getter that matches the stored value — GetNumber for a numeric key,
since Get[float64] would miss a default the page itself wrote as an int.
What Set does not do is fill in the field on screen. The state lives on the
server and is never sent to the client; the widget starts from the component's
own Conf.Default, and the browser only learns a value once someone enters
one. So a page that only writes the key reads 30 while showing an empty box.
To have both, write the key and set the conf:
p.State.Set("number_component_Age", 30)
age, _ := tgcomp.Number(p.Main, "Age", &tgcomp.NumberConf[int64]{Default: 30})
Every input that has a value to start on takes a Default in its conf, so
writing the key directly is a thing to reach for only when the value is not
known where the component is written. FileUpload is the exception, for the
reason its page gives.
The key is <component name>_<label>, unless the component was given an
explicit ID in its conf, in which case the key is that id verbatim.
| Component | Key | Stored value |
|---|---|---|
Textbox | textbox_component_<label> | string |
Textarea | textarea_component_<label> | string |
Number | number_component_<label> | any number |
Checkbox | checkbox_component_<label> | bool |
Select | select_component_<label> | item index, 1-based; 0 is "nothing selected" |
Radio | radio_component_<label> | item index, 0-based |
MultiSelect | multiselect_component_<label> | item indices, 0-based, as a list; [] is "nothing selected" |
DatePicker | datepicker_component_<label> | string, 2006-01-02 |
TimePicker | datepicker_component_<label> | string, 15:04 |
DateTimePicker | datepicker_component_<label> | string, 2006-01-02T15:04 |
Select and Radio disagree on the base, which is the one asymmetry to watch
for. The frontend's select has a placeholder as its first option, so Go
numbers the real items from 1 and keeps 0 for "nothing selected"; radio has no
placeholder and numbers from 0, using an unset key for "nothing selected".
Neither shows through the API — both return a 0-based index, take a 0-based
Conf.Default, and give nil when nothing is selected — so it only bites
when writing the key directly. To preselect the second item:
p.State.Set("select_component_Fruit", 2) // 1-based
p.State.Set("radio_component_Fruit", 1) // 0-based
Both drop an index that points outside items — a selection left over from a
run of a longer list reads as nothing selected, rather than as an index the
page would go on to use. Which is what Conf.Default is for, and it is
0-based for both:
tgcomp.Select(p.Main, "Fruit", fruits, (&tgcomp.SelectConf{}).SetDefault(1))
tgcomp.Radio(p.Main, "Fruit", fruits, (&tgcomp.RadioConf{}).SetDefault(1))
MultiSelect numbers from 0 like Radio, and holds a list rather than one
index, so an empty list is a selection of nothing and an absent key is what
falls back to the conf's Default:
p.State.Set("multiselect_component_Fruit", []int{0, 2})
The pickers parse the string they read, and a value in the wrong format fails
the run rather than being ignored, so write the format in the table exactly.
An empty string is the one they do read: it is the app user having cleared the
picker, and reads back as nil rather than as a default to fall back to.