1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
|
# State and Reactivity
## Overview
go-tui uses a reactive model: when state changes, the UI re-renders automatically. You don't manually update element text or toggle visibility. Instead, you store values in `State[T]` containers. Your `Render` method reads state with `.Get()`, and any call to `.Set()` or `.Update()` marks the UI as dirty and triggers a new render pass.
## Creating State
State lives as fields on your struct component. Declare each piece of state as a `*tui.State[T]` where `T` is whatever type you need, then initialize it in the constructor with `tui.NewState`:
```gsx
package main
import tui "github.com/grindlemire/go-tui"
type myApp struct {
count *tui.State[int]
name *tui.State[string]
visible *tui.State[bool]
items *tui.State[[]string]
}
func MyApp() *myApp {
return &myApp{
count: tui.NewState(0),
name: tui.NewState("world"),
visible: tui.NewState(true),
items: tui.NewState([]string{"Go", "Rust", "Zig"}),
}
}
```
`NewState` accepts any type through Go generics. The value you pass becomes the initial state. The framework binds each `State` to the running `App` automatically when your component mounts.
## Reading State
Call `.Get()` to read the current value. Use it directly in your render method, inside Go expressions, or in control flow conditions:
```gsx
templ (m *myApp) Render() {
<div class="flex-col gap-1 p-1">
<span class="text-cyan font-bold">{fmt.Sprintf("Count: %d", m.count.Get())}</span>
<span>{"Hello, " + m.name.Get()}</span>
if m.visible.Get() {
<span class="text-green">This is visible</span>
}
for i, item := range m.items.Get() {
<span>{fmt.Sprintf("%d. %s", i+1, item)}</span>
}
</div>
}
```
`.Get()` is thread-safe for reads from any goroutine. In render methods, just call it wherever you need the current value.
## Writing State
There are two ways to change state: `.Set()` for replacing the value outright, and `.Update()` for modifying it based on the current value.
### Set
`.Set()` replaces the current value and triggers a re-render:
```go
func (m *myApp) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('r'), func(ke tui.KeyEvent) { m.count.Set(0) }),
tui.On(tui.Rune('h'), func(ke tui.KeyEvent) { m.visible.Set(!m.visible.Get()) }),
}
}
```
Use `.Set()` when you have the exact value you want. Setting the count to zero, toggling a boolean, replacing a string with new content.
### Update
`.Update()` takes a function that receives the current value and returns the new value. It's shorthand for `s.Set(fn(s.Get()))`:
```go
tui.On(tui.Rune('+'), func(ke tui.KeyEvent) {
m.count.Update(func(v int) int { return v + 1 })
}),
tui.On(tui.Rune('-'), func(ke tui.KeyEvent) {
m.count.Update(func(v int) int { return v - 1 })
}),
```
Use `.Update()` when the new value depends on the old one. Incrementing a counter, appending to a slice, toggling a flag.
Both `.Set()` and `.Update()` must be called from the main event loop (key handlers, mouse handlers, watcher callbacks). For background goroutines, use `app.QueueUpdate()` to schedule the change on the event loop instead.
## Conditional Rendering
`if` and `else` let you show or hide elements based on state. The condition is a Go boolean expression, evaluated fresh on each render:
```gsx
templ (m *myApp) Render() {
<div class="flex-col gap-1 p-1 border-rounded">
<span class="font-bold">Status</span>
<div class="flex gap-1">
<span class="font-dim">Sign:</span>
if m.count.Get() > 0 {
<span class="text-green font-bold">Positive</span>
} else if m.count.Get() < 0 {
<span class="text-red font-bold">Negative</span>
} else {
<span class="text-blue font-bold">Zero</span>
}
</div>
<div class="flex gap-1">
<span class="font-dim">Parity:</span>
if m.count.Get()%2 == 0 {
<span class="text-cyan">Even</span>
} else {
<span class="text-magenta">Odd</span>
}
</div>
</div>
}
```
Chain `else if` for multiple branches. These conditions are re-evaluated on every render, so changes to state are reflected immediately.
## List Rendering
`for` with `range` renders a collection. Combine it with state to build dynamic lists where the data or selection can change:
```gsx
package main
import (
"fmt"
tui "github.com/grindlemire/go-tui"
)
type listApp struct {
items []string
selected *tui.State[int]
}
func ListApp() *listApp {
return &listApp{
items: []string{"Rust", "Go", "TypeScript", "Python", "Zig"},
selected: tui.NewState(0),
}
}
func (l *listApp) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('j'), func(ke tui.KeyEvent) {
l.selected.Update(func(v int) int {
if v >= len(l.items)-1 {
return 0
}
return v + 1
})
}),
tui.On(tui.Rune('k'), func(ke tui.KeyEvent) {
l.selected.Update(func(v int) int {
if v <= 0 {
return len(l.items) - 1
}
return v - 1
})
}),
}
}
templ (l *listApp) Render() {
<div class="flex-col p-1 border-rounded border-cyan">
<span class="font-bold text-gradient-cyan-magenta">Pick a Language</span>
<br />
for i, item := range l.items {
if i == l.selected.Get() {
<span class="text-cyan font-bold">{fmt.Sprintf(" > %s", item)}</span>
} else {
<span class="font-dim">{fmt.Sprintf(" %s", item)}</span>
}
}
<br />
picked := l.items[l.selected.Get()]
<span class="font-dim">{fmt.Sprintf("Selected: %s", picked)}</span>
</div>
}
```
The `selected` state tracks which item has the cursor. Pressing `j` and `k` moves it up and down with wrapping. On each render, the `for` loop checks every item against the selected index and applies highlighting to the match.
For lists stored in state (e.g. `*tui.State[[]string]`), call `.Get()` in the range expression:
```gsx
for i, msg := range m.messages.Get() {
<span>{fmt.Sprintf("[%d] %s", i, msg)}</span>
}
```
## Computed Values
Inside a `templ` body you can write regular Go variable assignments. This is useful when you want to compute a value once and reference it in multiple places:
```gsx
templ (m *myApp) Render() {
label := fmt.Sprintf("Count: %d", m.count.Get())
status := statusText(m.count.Get())
<div class="flex-col gap-1 p-1">
<span class="text-cyan font-bold">{label}</span>
<span class="font-dim">{status}</span>
</div>
}
```
Where `statusText` is a plain Go helper:
```go
func statusText(n int) string {
if n > 20 {
return "high"
}
if n > 0 {
return "medium"
}
if n == 0 {
return "zero"
}
return "negative"
}
```
These are normal Go short variable declarations. They're re-evaluated on each render, so they always reflect the latest state.
### Element bindings (:=) for reusable elements
Element bindings are different from Go variable assignments. They bind an **element** to a name so you can reuse it in multiple places:
```gsx
templ (m *myApp) Render() {
badge := <span class="bg-cyan text-black px-1 font-bold">{fmt.Sprintf("%d", m.count.Get())}</span>
<div class="flex-col gap-1 p-1">
<div class="flex gap-1">
<span>Current count:</span>
{badge}
</div>
<div class="flex gap-1">
<span>Shown again:</span>
{badge}
</div>
</div>
}
```
Use regular Go assignments for computed strings, numbers, and booleans. Use `:=` element bindings when you want to define a reusable element fragment.
## Batching Updates
When you need to change multiple state values at once, `app.Batch()` groups them into a single re-render. Without batching, each `.Set()` call marks the UI dirty independently. With batching, the framework waits until the batch function returns before running any binding callbacks:
```go
func (m *myApp) reset(ke tui.KeyEvent) {
ke.App().Batch(func() {
m.count.Set(0)
m.name.Set("world")
m.visible.Set(true)
})
}
```
Inside the batch function, each `.Set()` records its bindings but doesn't fire them yet. When the function returns, all pending bindings run once with their final values. If the same binding is triggered multiple times within a batch (say, by setting the same state twice), it fires only once with the last value.
Batches can nest. Bindings are deferred until the outermost batch completes:
```go
ke.App().Batch(func() {
m.count.Set(1)
ke.App().Batch(func() {
m.count.Set(2)
})
m.count.Set(3)
})
// Binding fires once with value 3
```
You typically don't need batching for single state changes. It's useful for resetting forms, swapping between modes, or any situation where multiple fields change together and you want a single coherent re-render.
## State Bindings
`.Bind()` registers a callback that fires whenever the state changes. It returns an `Unbind` function you can call to stop receiving updates:
```go
unbind := m.count.Bind(func(v int) {
fmt.Println("count changed to", v)
})
// Later, to stop listening:
unbind()
```
Bindings run in registration order. They fire on every `.Set()` or `.Update()` call (or at the end of a batch). The callback receives the new value as its argument.
Most of the time you won't need manual bindings. The render method already reads state with `.Get()`, and re-renders happen automatically when state changes. Where bindings become useful is when you need something to happen *outside* the render cycle. For example, writing to a log file whenever a value changes, updating a second piece of state that's derived from the first, or sending a message over a channel to notify another goroutine. If you find yourself reaching for `.Bind()` just to update the UI, you probably don't need it. The `.Get()` in your render method already covers that.
## Driving a Modal with State
Modal visibility is controlled by a `*State[bool]`. Set it to `true` to open, `false` to close:
```gsx
package main
import tui "github.com/grindlemire/go-tui"
type confirmApp struct {
showModal *tui.State[bool]
status *tui.State[string]
}
func ConfirmApp() *confirmApp {
return &confirmApp{
showModal: tui.NewState(false),
status: tui.NewState("Press 'o' to open the dialog"),
}
}
func (c *confirmApp) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.OnStop(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.OnStop(tui.Rune('o'), func(ke tui.KeyEvent) { c.showModal.Set(true) }),
}
}
func (c *confirmApp) onConfirm() {
c.showModal.Set(false)
c.status.Set("Confirmed!")
}
func (c *confirmApp) onCancel() {
c.showModal.Set(false)
c.status.Set("Cancelled")
}
templ (c *confirmApp) Render() {
<div class="flex-col p-1 border-rounded border-cyan">
<span class="font-bold text-cyan">Modal Demo</span>
<span class="font-dim">{c.status.Get()}</span>
</div>
<modal open={c.showModal} class="justify-center items-center" backdrop="dim">
<div class="border-rounded p-2 flex-col gap-1 w-40">
<span class="font-bold text-cyan">Confirm Action</span>
<span>Are you sure?</span>
<div class="flex gap-2 justify-center">
<button class="px-2 border-rounded focusable" onActivate={c.onConfirm}>Yes</button>
<button class="px-2 border-rounded focusable" onActivate={c.onCancel}>No</button>
</div>
</div>
</modal>
}
```
The modal opens when `showModal` becomes `true` and closes when either button sets it back to `false`. Escape closes it too (on by default). Tab cycles between the Yes and No buttons, and Enter activates whichever is focused.
If you need to update multiple values when the modal closes, use batching (see [Batching Updates](#batching-updates) above).
## Complete Example
A counter, status display, selectable list, and a reset confirmation modal that uses batching:
```gsx
package main
import (
"fmt"
tui "github.com/grindlemire/go-tui"
)
type demoApp struct {
count *tui.State[int]
selected *tui.State[int]
showReset *tui.State[bool]
items []string
}
func Demo() *demoApp {
return &demoApp{
count: tui.NewState(0),
selected: tui.NewState(0),
showReset: tui.NewState(false),
items: []string{"Rust", "Go", "TypeScript", "Python", "Zig"},
}
}
func (d *demoApp) confirmReset() {
d.showReset.Set(false)
// Batch so count and selected update in a single re-render
d.count.Set(0)
d.selected.Set(0)
}
func (d *demoApp) cancelReset() {
d.showReset.Set(false)
}
func (d *demoApp) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('+'), func(ke tui.KeyEvent) {
d.count.Update(func(v int) int { return v + 1 })
}),
tui.On(tui.Rune('-'), func(ke tui.KeyEvent) {
d.count.Update(func(v int) int { return v - 1 })
}),
tui.On(tui.Rune('r'), func(ke tui.KeyEvent) { d.showReset.Set(true) }),
tui.On(tui.Rune('j'), func(ke tui.KeyEvent) { d.selectNext() }),
tui.On(tui.Rune('k'), func(ke tui.KeyEvent) { d.selectPrev() }),
tui.On(tui.KeyDown, func(ke tui.KeyEvent) { d.selectNext() }),
tui.On(tui.KeyUp, func(ke tui.KeyEvent) { d.selectPrev() }),
}
}
func (d *demoApp) selectNext() {
d.selected.Update(func(v int) int {
if v >= len(d.items)-1 {
return 0
}
return v + 1
})
}
func (d *demoApp) selectPrev() {
d.selected.Update(func(v int) int {
if v <= 0 {
return len(d.items) - 1
}
return v - 1
})
}
func signLabel(n int) string {
if n > 0 {
return "Positive"
}
if n < 0 {
return "Negative"
}
return "Zero"
}
func signClass(n int) string {
if n > 0 {
return "text-green font-bold"
}
if n < 0 {
return "text-red font-bold"
}
return "text-blue font-bold"
}
templ (d *demoApp) Render() {
<div class="flex-col p-1 border-rounded border-cyan">
<span class="text-gradient-cyan-magenta font-bold">State Demo</span>
<div class="flex">
// Counter panel
<div class="flex-col border-rounded p-1 gap-1 items-center justify-center" flexGrow={1.0}>
<span class="font-bold">Counter</span>
<span class="text-cyan font-bold">{fmt.Sprintf("%d", d.count.Get())}</span>
<div class="flex gap-1 justify-center">
<span class="font-dim">+/- r:reset</span>
</div>
</div>
// Status panel
<div class="flex-col border-rounded p-1 gap-1" flexGrow={2.0}>
<span class="font-bold">Status</span>
<div class="flex gap-1">
<span class="font-dim">Sign:</span>
<span class={signClass(d.count.Get())}>{signLabel(d.count.Get())}</span>
</div>
<div class="flex gap-1">
<span class="font-dim">Parity:</span>
if d.count.Get()%2 == 0 {
<span class="text-cyan">Even</span>
} else {
<span class="text-magenta">Odd</span>
}
</div>
</div>
</div>
// Selectable list
<div class="flex-col border-rounded p-1 gap-1">
<span class="font-bold">Languages</span>
for i, item := range d.items {
if i == d.selected.Get() {
<span class="text-cyan font-bold">{fmt.Sprintf(" > %s", item)}</span>
} else {
<span class="font-dim">{fmt.Sprintf(" %s", item)}</span>
}
}
</div>
<div class="flex justify-center">
<span class="font-dim">+/- count | j/k navigate | r reset | esc quit</span>
</div>
</div>
// Reset confirmation modal
<modal open={d.showReset} class="justify-center items-center">
<div class="border-rounded p-2 flex-col gap-1 w-40 border-cyan">
<span class="font-bold text-cyan">Reset?</span>
<span>This sets the counter and selection back to zero.</span>
<div class="flex gap-2 justify-center">
<button class="px-2 border-rounded focusable" onActivate={d.confirmReset}>Yes</button>
<button class="px-2 border-rounded focusable" onActivate={d.cancelReset}>No</button>
</div>
</div>
</modal>
}
```
With `main.go`:
```go
package main
import (
"fmt"
"os"
tui "github.com/grindlemire/go-tui"
)
func main() {
app, err := tui.NewApp(
tui.WithRootComponent(Demo()),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer app.Close()
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
```
Generate and run:
```bash
tui generate ./...
go run .
```
You should see something like this:

## Next Steps
- [Components](components) -- Component patterns, composition, and lifecycle interfaces
- [Event Handling](events) -- Keyboard and mouse input in depth
- [Timers, Watchers, and Channels](watchers) -- Background operations that update state over time
|