Skip to content
Subha Sundar Das edited this page Aug 4, 2026 · 1 revision

Forms in retui

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.


1. Quick start

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.


2. Rules of UseForm (same as every other hook)

  1. Call only from the render function/goroutine.
  2. Call unconditionally, in a fixed order — never inside an if or loop whose condition can differ between renders. If it does, hook slots get assigned to the wrong component.
  3. Don't call UseForm[T] with a different T at the same call site across renders. (If this happens by accident — e.g. a hook-order bug — the slot is silently reset with a fresh Form[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.)
  4. No separate reset call is needed — Form piggybacks on the same BeginRender() cursor reset every other hook uses.

3. Form[T] API reference

Values

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.

Full example

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")
}

}

Centralizing the sync

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 }


6. Tab navigation and modals

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.


7. Gotchas

  • Values() returns a copy. Mutating the returned struct doesn't change the form — always go through SetValues/SetField.
  • SetField requires T to 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.
  • SetValues doesn't update per-field Touched state, since it doesn't know which fields actually changed. Use SetField (or MarkTouched) 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 — SetHidden only stops focus routing, not rendering.
  • Batch multiple field updates with retui.Batch(func() { ... }) when one event should cause several SetField calls — this coalesces them into a single re-render instead of one per call.

8. Testing

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).

Clone this wiki locally