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 reads p.State.
  • A custom component takes a *tgframe.Container and nothing else, so c.State is 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":

GetterReadsMisses as
Get[T]a value stored as a T, and nothing elsezero value, false
GetNumber[T]any number, as a Tzero value, false
GetObjectanything, through a JSON round tripleaves 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.

ComponentKeyStored value
Textboxtextbox_component_<label>string
Textareatextarea_component_<label>string
Numbernumber_component_<label>any number
Checkboxcheckbox_component_<label>bool
Selectselect_component_<label>item index, 1-based; 0 is "nothing selected"
Radioradio_component_<label>item index, 0-based
MultiSelectmultiselect_component_<label>item indices, 0-based, as a list; [] is "nothing selected"
DatePickerdatepicker_component_<label>string, 2006-01-02
TimePickerdatepicker_component_<label>string, 15:04
DateTimePickerdatepicker_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.