
State, Events, and Reactivity
Source:vignettes/state-events-reactivity.Rmd
state-events-reactivity.RmdThis guide explains how rtui apps handle user interaction: the event system that responds to clicks, key presses, and value changes; the mutable state object that persists data across events; and the reactive binding system that automatically updates widgets when state changes.
Terminal only: All examples must be saved as
.Rfiles and run from a real terminal (Rscript my_app.R). rtui apps do not work in RStudio, R GUI, Jupyter, or any embedded R console.
The event loop
When an rtui app is running, Textual captures user input and dispatches events. Each event is routed to an R callback function that you provide. The callback inspects the event, optionally modifies state, updates widgets, and returns state to continue.
User action -> Textual event -> Python handler -> R callback -> state
Handler functions
Every handler has the same signature:
function(event, state) {
# ... do things ...
state
}-
event: A list describing what happened. Always has$typeand$widget_id. Many events also have$value. -
state: A mutableRtuiStateobject. Your persistent store across all events. -
Return value: Must return
state(orquit()to exit).
Available event types
Pass handlers to tui_app() or
quick_app():
quick_app(
layout = ...,
on_mount = function(event, state) { ... }, # App started
on_click = function(event, state) { ... }, # Button press / item click
on_change = function(event, state) { ... }, # Widget value changed
on_submit = function(event, state) { ... }, # Enter pressed in Input
on_key = function(event, state) { ... }, # Any key pressed
on_timer = function(event, state) { ... }, # Timer fired
on_action = function(event, state) { ... }, # Key binding action
on_screen_result = function(event, state) { ... } # Screen dismissed
)on_mount
Fires once when the app starts, after the UI is rendered. Use it for initialisation – loading data, setting initial state, starting timers:
on_mount = function(event, state) {
state$set("count", 0L)
state$set("data", load_my_data())
set_interval(state$app, 1, "clock_tick")
state
}on_click
Fires when a button is pressed or an item is clicked.
event$widget_id tells you which widget;
event$value carries the click data.
on_click = function(event, state) {
if (event$widget_id == "save_btn") {
save_data(state$get("data"))
notify(state$app, "Saved!", severity = "info")
}
state
}on_change
Fires when a widget’s value changes (input text, checkbox toggle, select choice, slider move, etc.):
on_change = function(event, state) {
if (event$widget_id == "search") {
results <- filter_data(state$get("data"), event$value)
state$set("filtered", results)
}
state
}on_submit
Fires when the user presses Enter in an input()
widget:
on_submit = list(
search_box = function(event, state) {
run_search(event$value)
state
}
)on_action
Fires when a key binding action is triggered.
event$value is the action name:
on_action = function(event, state) {
if (event$value == "quit_app") return(quit())
if (event$value == "toggle_dark") dark_toggle(state$app)
if (event$value == "save") save_data(state)
state
}on_screen_result
Fires when a pushed screen is dismissed with a result value:
on_screen_result = function(event, state) {
if (isTRUE(event$value)) {
# User confirmed
perform_action(state)
}
state
}Per-widget-id routing
For on_click, on_change, and
on_submit, you can pass a named list
instead of a single function. Each name is a widget id, and its value is
the handler for that specific widget:
on_click = list(
save_btn = function(event, state) {
save_data(state)
notify(state$app, "Saved!")
state
},
delete_btn = function(event, state) {
confirm(state$app, "Delete this item?")
state
},
.default = function(event, state) {
# Catch-all for any other widget
message("Clicked: ", event$widget_id)
state
}
)The special .default key acts as a catch-all for widgets
not explicitly listed.
This pattern is cleaner than a long if/else chain and
makes it easy to see which widgets have handlers at a glance.
State management
The RtuiState object
State is an R6 object with a simple key-value API:
# Set a value
state$set("count", 42L)
# Get a value (with optional default)
state$get("count") # 42
state$get("missing") # NULL
state$get("missing", 0L) # 0 (default)
# Access the running app
state$app # RtuiApp object
# Get all state as a list
state$as_list()State persists across all events within a single app run. After the
app exits, quick_app() returns the final state
invisibly.
Patterns for state
Counter pattern:
on_click = list(
inc = function(event, state) {
n <- state$get("n", 0L) + 1L
state$set("n", n)
update(state$app, "display", value = as.character(n))
state
}
)List/collection pattern:
on_click = list(
add_item = function(event, state) {
items <- state$get("items", list())
new_item <- list(
id = length(items) + 1L,
text = state$get("input_text", ""),
done = FALSE
)
items <- c(items, list(new_item))
state$set("items", items)
refresh_display(state)
state
}
)Toggle pattern:
Reactive bindings
Reactive bindings eliminate manual update() calls.
Instead of this:
# Manual approach -- every handler must call update()
on_click = list(
inc = function(event, state) {
n <- state$get("count", 0L) + 1L
state$set("count", n)
update(state$app, "display", value = as.character(n)) # tedious!
state
}
)You declare the binding once:
quick_app(
layout = vstack(
digits("0", id = "display"),
button("+1", id = "inc"),
id = "root"
),
reactive = reactive(count = "display"),
on_click = list(
inc = function(event, state) {
state$set("count", state$get("count", 0L) + 1L)
state # display auto-updates!
}
)
)Whenever state$set("count", value) is called, the
"display" widget’s value is automatically updated. No
manual update() needed.
Binding types
The reactive() function accepts three binding forms:
1. Widget id (string) – auto-update the widget’s value:
reactive(count = "counter_display")
# state$set("count", 42) -> update(app, "counter_display", value = "42")2. Formula – transform the value:
In formulas, .x is the new value, .state is
the state object, and .app is the running app.
3. Function – full control:
Updating widgets
The update() function modifies a widget by id. Different
widgets support different properties:
# Text content (text, static, markdown)
update(state$app, "msg", content = "New message")
# Value (input, digits, switch, text_area)
update(state$app, "counter", value = "42")
# Label (button, checkbox)
update(state$app, "btn", label = "New Label")
# Enable/disable any widget
update(state$app, "btn", disabled = TRUE)
# Show/hide any widget
update(state$app, "panel", display = FALSE)
# OptionList items
update(state$app, "my_list", items = c("New", "Items"))
# DataTable rows
update(state$app, "table", add_rows = as.list(new_df))
update(state$app, "table", clear_data = TRUE)
# Progress bar
update(state$app, "pb", progress = 75)
update(state$app, "pb", total = 200)
# Collapsible section
update(state$app, "section", collapsed = FALSE)Quitting the app
Return quit() from any handler to exit the app:
You can pass a result value that becomes the return value of
app$run():
Example: Interactive filter
Here’s a complete app that demonstrates state, events, and reactive bindings working together:
library(rtui)
# Data to filter
all_items <- c("Apple", "Apricot", "Banana", "Blueberry", "Cherry",
"Date", "Fig", "Grape", "Kiwi", "Lemon", "Mango")
quick_app(
title = "Fruit Filter",
layout = vstack(
header(),
input(placeholder = "Type to filter...", id = "search"),
rule(),
option_list(items = all_items, id = "results"),
static(paste(length(all_items), "items"), id = "count"),
footer()
),
on_mount = function(event, state) {
state$set("all_items", all_items)
state
},
on_change = list(
search = function(event, state) {
term <- tolower(event$value)
items <- state$get("all_items")
if (nzchar(term)) {
filtered <- items[grepl(term, tolower(items))]
} else {
filtered <- items
}
update(state$app, "results", items = filtered)
update(state$app, "count",
content = paste(length(filtered), "items"))
state
}
),
on_change_list = list(
results = function(event, state) {
notify(state$app, paste("Selected:", event$value))
state
}
),
bindings = list(
binding("q", "quit_app", "Quit", priority = TRUE),
binding("escape", "quit_app", "Quit", priority = TRUE)
),
on_action = function(event, state) {
if (event$value == "quit_app") return(quit())
state
},
css = paste0(
tui_theme("nord"),
"
#search { margin: 0 1; }
#results { height: 1fr; }
#count { height: 1; text-align: center; color: $text-muted; }
"
)
)Example: Multi-handler routing
This example shows per-widget-id routing for both click and change events:
library(rtui)
quick_app(
title = "Settings",
layout = vstack(
header(),
static("[bold]Preferences[/bold]"),
rule(),
checkbox("Enable notifications", value = TRUE, id = "notif"),
checkbox("Dark mode", value = TRUE, id = "dark"),
select(c("English", "Spanish", "French"), id = "lang"),
rule(),
hstack(
button("Save", id = "save"),
button("Reset", id = "reset")
),
static("", id = "status"),
footer()
),
on_change = list(
notif = function(event, state) {
state$set("notifications", event$value)
update(state$app, "status",
content = paste("Notifications:", event$value))
state
},
dark = function(event, state) {
dark_toggle(state$app, event$value)
state
},
lang = function(event, state) {
state$set("language", event$value)
update(state$app, "status",
content = paste("Language:", event$value))
state
}
),
on_click = list(
save = function(event, state) {
notify(state$app, "Settings saved!", severity = "info")
state
},
reset = function(event, state) {
update(state$app, "notif", value = TRUE)
update(state$app, "lang", value = "English")
notify(state$app, "Settings reset", severity = "warning")
state
}
),
bindings = list(
binding("q", "quit_app", "Quit", priority = TRUE)
),
on_action = function(event, state) {
if (event$value == "quit_app") return(quit())
state
}
)