-
Notifications
You must be signed in to change notification settings - Fork 4
Forms
retui.Form[T] is a hook-based form state container, built on top of the
same positional hook system as UseState/UseRef (see hooks.go). It
tracks values, dirty/touched state, and validation errors, and integrates
with FocusManager (focus.go) to drive Disabled/ReadOnly/Hidden behavior
for individual fields.
type CompanyForm struct {
Name string
TaxID string
Country string
State string
}
func (c *FormComponent) Render() Element {
form := retui.UseForm(CompanyForm{}) // created once, reused every render
v := form.Values()
components.TextInput().
ID("company-name").
Focused(retui.IsFocused("company-name")).
Value(v.Name).
OnChange(func(id, val string) {
form.SetField("Name", val)
}).
Render()
// ...more fields
}
UseForm(initial) returns the same *Form[T] instance across renders,
the same way UseState/UseRef do. form.SetField("FieldName", value) is
the standard way to wire an input's OnChange without manually copying the
whole struct out and back in.
- Call only from the render function/goroutine.
- Call unconditionally, in a fixed order — never inside an
ifor loop whose condition can differ between renders. If it does, hook slots get assigned to the wrong component. - Don't call
UseForm[T]with a differentTat the same call site across renders. (If this happens by accident — e.g. a hook-order bug — the slot is silently reset with a freshForm[T]rather than panicking, but this discards whatever was in that slot before. Treat it as a bug to fix, not a feature to rely on.) - No separate reset call is needed —
Formpiggybacks on the sameBeginRender()cursor reset every other hook uses.
| Method | Description |
|---|---|
| Values() T | Returns a copy of current values. Safe to mutate the copy without affecting form state. |
| SetValues(v T) | Replaces all values at once. No-ops (no dirty flag, no re-render) if v deeply equals current values. |
| SetField(name string, value any) error | Sets a single struct field by name via reflection. Marks the form dirty and that field touched. Returns an error (not a panic) for unknown fields, unexported fields, or type mismatches. |
| Field(name string) (any, bool) | Reads a single struct field by name. false if T isn't a struct or the field doesn't exist. |
func (c *FormComponent) Render() Element {
form := retui.UseForm(CompanyForm{})
v := form.Values()
// 1. Derive flags from current values — pure business rules.
stateHidden := v.Country != "US"
taxIDReadOnly := c.submitting
// 2. Push into FocusManager BEFORE reading Focused()/IsHidden() below.
retui.SetReadOnly("tax-id", taxIDReadOnly)
retui.SetHidden("state", stateHidden)
retui.SetFocusOrder([]string{"company-name", "tax-id", "country", "state"})
components.TextInput().
ID("company-name").
Focused(retui.IsFocused("company-name")).
Value(v.Name).
OnChange(func(id, val string) { form.SetField("Name", val) }).
Render()
components.TextInput().
ID("tax-id").
Focused(retui.IsFocused("tax-id")).
Value(v.TaxID).
OnChange(func(id, val string) {
if retui.IsReadOnly("tax-id") {
return // ReadOnly doesn't block focus, so guard mutation here
}
form.SetField("TaxID", val)
}).
Render()
if !retui.IsHidden("state") { // Hidden fields: don't render them at all
components.TextInput().
ID("state").
Focused(retui.IsFocused("state")).
Value(v.State).
OnChange(func(id, val string) { form.SetField("State", val) }).
Render()
}
if retui.CurrentFocus() == "" { // first render: nothing focused yet
retui.SetFocus("company-name")
}
}
If a form has many conditional fields, pull the flag-derivation into one function called once per render, right after reading values:
func (c *FormComponent) syncFocusState(v CompanyForm) {
retui.SetReadOnly("tax-id", c.submitting)
retui.SetHidden("state", v.Country != "US")
retui.SetFocusOrder([]string{"company-name", "tax-id", "country", "state"})
}
func (c *FormComponent) Render() Element {
form := retui.UseForm(CompanyForm{})
v := form.Values()
c.syncFocusState(v)
// ...render fields
}
Prefer retui.FocusNext()/retui.FocusPrev() over hand-rolled index math —
they already skip Disabled/Hidden fields and wrap around correctly:
c.win.OnKeyPress(func(key retui.Key) bool {
if retui.CapturedFocus() != "" {
return false // dropdown/autocomplete owns input
}
switch key.Code {
case retui.KeyDown, retui.KeyTab:
retui.FocusNext()
return true
case retui.KeyUp, retui.KeyShiftTab:
retui.FocusPrev()
return true
case retui.KeyEscape:
c.win.Close()
return true
}
return false
})
For modals, use retui.PushFocus(id) / retui.PopFocus() instead of
manually saving/restoring focus — this also releases any active keyboard
capture automatically when a modal opens.
-
Values()returns a copy. Mutating the returned struct doesn't change the form — always go throughSetValues/SetField. -
SetFieldrequiresTto be a struct (not a pointer, not a map). Errors are returned, not panicked, for unknown fields / type mismatches — check the return value if you're not certain the field name is correct. -
SetValuesdoesn't update per-fieldTouchedstate, since it doesn't know which fields actually changed. UseSetField(orMarkTouched) if you need per-field touched tracking. -
ReadOnly does not block focus or typing by itself. You must check
retui.IsReadOnly(id)inside the field's own change handler. -
Hidden fields still exist in the FocusManager's tab order unless you
also skip rendering them —
SetHiddenonly stops focus routing, not rendering. -
Batch multiple field updates with
retui.Batch(func() { ... })when one event should cause severalSetFieldcalls — this coalesces them into a single re-render instead of one per call.
Form[T] shares global hook state with UseState/UseRef/etc. In tests,
simulate render passes with retui.BeginRender() and reset all hook state
between tests with retui.ResetComponentState():
func TestMyForm(t *testing.T) {
retui.ResetComponentState()
t.Cleanup(retui.ResetComponentState)
var form *retui.Form[CompanyForm]
retui.BeginRender()
form = retui.UseForm(CompanyForm{Name: "Acme"})
form.SetField("Name", "Acme Inc")
if form.Values().Name != "Acme Inc" {
t.Fatalf("expected updated name")
}
}
See form_test.go for the full suite (creation/reuse, validation, submit,
Batch coalescing, and a -race-safe concurrency test).