State and refresh
Why widgets hold no values, when the library re-reads your suppliers, and the few things that do live inside built widgets.
On this page
Widgets don't hold your values. They read them through suppliers and write changes back through callbacks, and the library re-reads every supplier whenever the screen refreshes. Once that is clear, every widget behaves predictably.
The mod owns the state#
Toggle.builder("sounds")
.label(Text.translatable("mymod.sounds", "Sounds"))
.value(() -> config.sounds) // read: what the toggle shows
.onChange(v -> config.sounds = v) // write: what a click asks for
.build();
A click doesn't flip the toggle. It calls onChange with the opposite value, and then the screen shows
whatever value returns. Two things follow:
- An
onChangethat ignores the new value (or refuses it) leaves the widget unchanged. - Anything else that changes
config.sounds(another button, arunAsynccallback) shows up on the toggle at the next refresh, with no extra code.
When suppliers are re-read#
The backend re-reads every supplier of the current screen:
- when the screen opens,
- after every entry action (a press, toggle, edit, selection…),
- after every
runAsynccallback, - when you call
host.refresh().
Call refresh() yourself only when state changes outside those, for example inside a runOnMainThread task.
What counts as a supplier: labels and titles, values, enabled, visible, tooltips, SelectList rows,
StringListEditor items, and validators.
Static or dynamic#
Every supplier setter has a constant overload. Use the constant when it never changes:
.label(Text.translatable("mymod.volume", "Volume")) // fixed
.label(() -> Text.translatable("mymod.volume.n", "Volume: %s", config.volume)) // re-read
.enabled(false) // greyed out for good
.enabled(() -> config.sounds) // follows the toggle
Keep suppliers fast and pure#
Suppliers run on the main thread, on every refresh, so:
- No IO, no network, no heavy computation. Load data once (or with
runAsync) and let the supplier read the loaded copy. - No side effects. A supplier may run any number of times.
- Don't throw. A supplier that throws turns your menu off for the rest of the session (Failure isolation).
What lives in the widget#
A few kinds of state belong to the built widget, not to your code:
| State | Where | Survives a refresh | Survives reopening the menu |
|---|---|---|---|
typed text of TextInput, NumberField, MultiLineInput |
the entry | yes | no: starts again from initial |
a Section's expanded state |
the section | yes | no: starts from expanded(…); remember it with onToggle |
a SelectList's selection |
the list | yes, while its row exists | no |
a StatusLine's message |
the status line | yes | no |
Typed text lives in the entry so that invalid input survives refreshes while the player fixes it; onChange
only ever receives valid text.
This is also why you build screens fresh in create and in button actions. A cached screen would carry the
last opening's typed text, sections and selection.
Visibility#
visible(…) decides whether an entry has a row at all. A hidden entry takes no space (the rows below move up),
none of its other suppliers are read, and it can't be used. A section's children are visible only while the
section is visible and expanded.
enabled(…) is different: a disabled widget keeps its row, shows its label, value and tooltip, and is drawn
greyed out.
Next steps#
- Threads: where suppliers and callbacks run.
- Validate what players type: the typed-text exception in practice.