gitstack

grindlemire/go-tui code browser

10.0 KB markdown 358 lines 2026-04-18 · 28dfef2 raw

State Reference

Overview

State[T] and Events[T] are the two generic types that handle reactivity in go-tui. State[T] holds a value and triggers re-renders when it changes. Events[T] broadcasts messages between components without shared mutable state.

Both types follow the same lifecycle pattern: create them unbound, and the framework binds them to an App during component mounting. Once bound, any mutation marks the UI dirty so the next frame picks up the change.

// In a struct component
type counter struct {
    count *tui.State[int]
}

func Counter() *counter {
    return &counter{
        count: tui.NewState(0),
    }
}

State[T]

A reactive container for a single value of type T. When the value changes via Set or Update, the framework marks the app dirty (triggering a re-render) and notifies any registered bindings.

NewState

func NewState[T any](initial T) *State[T]

Creates a new state with the given initial value. The state starts unbound and the framework calls BindApp automatically when the owning component mounts.

Works with any type: primitives, slices, structs, maps.

count := tui.NewState(0)               // State[int]
name := tui.NewState("hello")          // State[string]
items := tui.NewState([]string{})      // State[[]string]
visible := tui.NewState(true)          // State[bool]

NewStateForApp

func NewStateForApp[T any](app *App, initial T) *State[T]

Creates a state already bound to the given app. Use this when creating state outside of a component (for example, in main.go before passing it to a component constructor). Panics if app is nil.

app, _ := tui.NewApp(...)
data := tui.NewStateForApp(app, []string{"a", "b", "c"})

BindApp

func (s *State[T]) BindApp(app *App)

Binds the state to an app for dirty-marking and batching. The framework calls this during component mounting; you rarely need to call it yourself. Panics if app is nil. Idempotent if called with the same app; overwrites if called with a different one.

Get

func (s *State[T]) Get() T

Returns the current value. Safe to call from any goroutine.

current := count.Get()

In .gsx render methods, call Get() inside expressions:

<span>{fmt.Sprintf("Count: %d", s.count.Get())}</span>

Set

func (s *State[T]) Set(v T)

Replaces the value, marks the app dirty, and notifies all bindings. If called within a Batch, binding execution is deferred until the batch completes.

Must be called from the main event loop. For updates from background goroutines, use app.QueueUpdate or channel watchers.

count.Set(42)

Circular dependencies: If a binding callback on state A triggers a Set on state A (directly, or through a chain like A→B→A), the value is updated and dirty is marked, but bindings are not fired again. Without this guard, the recursive calls would overflow the stack. Render() reads the correct final values on the next frame.

Non-circular dependencies are unaffected. If A's binding sets B and C's binding also sets B, B's bindings fire once for each call. The guard only activates when Set is called on a state that is already in the middle of executing its own bindings.

Update

func (s *State[T]) Update(fn func(T) T)

Reads the current value, applies fn, and sets the result. A convenience for read-modify-write operations.

count.Update(func(v int) int { return v + 1 })

items.Update(func(list []string) []string {
    return append(list, "new item")
})

Bind

func (s *State[T]) Bind(fn func(T)) Unbind

Registers a callback that fires whenever the value changes via Set or Update. The callback receives the new value. Bindings execute in registration order.

Returns an Unbind function. Call it to remove the binding and prevent future invocations.

unbind := count.Bind(func(v int) {
    fmt.Println("count changed to", v)
})

// Later, to stop receiving updates:
unbind()

Unbind

type Unbind func()

A handle returned by Bind. Calling it deactivates the associated binding. Safe to call multiple times.

Events[T]

An event bus for broadcasting messages between components. Unlike State[T], Events[T] does not store a value. It delivers each emitted event to all current subscribers and marks the app dirty.

NewEvents

func NewEvents[T any](topic string) *Events[T]

Creates a new topic-based event bus. topic is required and is used to route events across components.

notifications := tui.NewEvents[string]("app.notifications")

NewEventsForApp

func NewEventsForApp[T any](app *App, topic string) *Events[T]

Creates an event bus already bound to the given app. Use this when creating a bus outside of a component. Panics if app is nil.

app, _ := tui.NewApp(...)
bus := tui.NewEventsForApp[string](app, "app.notifications")

BindApp

func (e *Events[T]) BindApp(app *App)

Binds the event bus to an app for dirty-marking. The framework calls this during component mounting. Panics if app is nil. Idempotent for the same app; overwrites if called with a different one.

UnbindApp

func (e *Events[T]) UnbindApp()

Detaches the event bus from app topic routing. Called automatically when components unmount or when the root is replaced.

Emit

func (e *Events[T]) Emit(event T)

Sends an event to all subscribers and marks the app dirty. Subscribers are called synchronously in registration order.

Panics if the event bus has no bound app.

notifications.Emit("task completed")

Subscribe

func (e *Events[T]) Subscribe(fn func(T)) func()

Registers a handler that will be called for every emitted event. Handlers run on the UI thread, so it is safe to update state from within a handler.

unsub := notifications.Subscribe(func(msg string) {
    log.Set(append(log.Get(), msg))
})
defer unsub()

Batching

App.Batch

func (a *App) Batch(fn func())

Executes fn with all state binding callbacks deferred. Within a batch, each Set or Update call still marks the app dirty immediately, but binding callbacks accumulate and run once when the outermost Batch returns. If the same binding is triggered multiple times during a batch, only the last value is delivered.

Batches can nest. Deferred callbacks only fire when the outermost batch completes.

app.Batch(func() {
    firstName.Set("Alice")
    lastName.Set("Smith")
    age.Set(30)
}) // All binding callbacks fire here, once

Use Batch when you need to update several state values at once and want to avoid intermediate binding work.

Thread Safety

State[T] and Events[T] follow the same threading rules as the rest of go-tui:

  • Get() is safe from any goroutine. Read the value whenever you need it.
  • Set(), Update(), and Emit() must run on the main event loop. The event loop is the single thread that processes input and renders frames.
  • Watcher callbacks run on the event loop. Handlers passed to tui.OnTimer and tui.Watch are safe to call Set from.
  • For background goroutines, use app.QueueUpdate. This enqueues a function on the event loop where Set is safe.
// Background goroutine updating state safely
go func() {
    result := fetchData()
    app.QueueUpdate(func() {
        data.Set(result) // runs on the event loop
    })
}()

Getting *tui.App into a component

For samplers, pollers, or any goroutine spawned from a component method, the component needs an *tui.App reference to call QueueUpdate. Declare a field of that type on the struct; the generator's BindApp assigns it automatically along with delegating to every State and Events field:

type MyComponent struct {
    app   *tui.App            // auto-assigned on mount
    count *tui.State[int]
}

templ (c *MyComponent) Render() { <span>{count}</span> }

The generator also emits an unexported bindAppFields(app *tui.App) method on the receiver. If you override BindApp (for custom setup), call bindAppFields from inside your override so the State and Events delegations don't have to be maintained by hand:

func (c *MyComponent) BindApp(app *tui.App) {
    c.bindAppFields(app)
    // custom logic here
}

A user-defined BindApp that skips bindAppFields will leave State fields unbound, causing Set to panic or silently drop updates.

Practical Patterns

Derived display values

Use Go expressions directly in .gsx to compute display strings from state:

templ (c *counter) Render() {
    <span class="font-bold">{fmt.Sprintf("Count: %d", c.count.Get())}</span>
}

Reusable element fragments with :=

The := binding assigns an element to a name so you can reuse it in multiple places. It requires an element (starting with <), not a Go expression.

templ (s *stateApp) Render() {
    countBadge := <span class="text-cyan font-bold">{fmt.Sprintf("%d", s.count.Get())}</span>
    <div class="flex-col gap-1">
        {countBadge}
    </div>
}

Conditional rendering from state

templ (a *myApp) Render() {
    if a.loading.Get() {
        <span class="text-yellow">Loading...</span>
    } else {
        <span class="text-green">Ready</span>
    }
}

Shared state between components

Create state in a parent and pass it to child constructors. Both components react to changes from either side.

type app struct {
    selected *tui.State[int]
    sidebar  *sidebar
    content  *contentPanel
}

func App() *app {
    sel := tui.NewState(0)
    return &app{
        selected: sel,
        sidebar:  Sidebar(sel),
        content:  ContentPanel(sel),
    }
}

Cross-component events

Use Events[T] when components need to communicate without sharing mutable state:

type app struct {
    header *header
    body   *body
}

func App() *app {
    return &app{
        header: Header(),
        body:   Body(),
    }
}

Both components can construct tui.NewEvents[string]("app.alerts") internally and communicate through that shared topic without passing bus pointers.