State
Controllers are persistent per screen, so screen-lifetime state belongs in controller ivars. Application state classes hold app-lifetime state: values that must survive navigation away from the screen.
ApplicationState
State classes inherit from Charming::ApplicationState, which includes ActiveModel::Model and ActiveModel::Attributes:
module MyApp
class HomeState < ApplicationState
attribute :title, :string, default: "Home"
attribute :count, :integer, default: 0
attribute :status, :string, default: "Ready"
end
end
Common attribute types include:
:string:integer:float:boolean:date:datetime:time
Session-Backed State
Use Controller#state to lazily create and cache state objects in the application session:
def home
state(:home, HomeState)
end
Subsequent calls with the same name return the same state object.
def increment
home.count += 1
show
end
State objects belong to the loop thread. Task blocks receive data in via run_task’s with: and return data out as the block value; they never touch state objects directly. The on_task handler on the loop thread is the only place task results become state.
Initial Attributes
Pass initial attributes through state:
def counter
state(:counter, CounterState, count: 10)
end
Initial attributes are only used when the state object is first created.
Validations
Use normal ActiveModel validations:
class CounterState < Charming::ApplicationState
attribute :count, :integer, default: 0
validate :count_gte_zero
def count_gte_zero
errors.add(:count, "must be >= 0") if count < 0
end
end
Controller actions can call valid? and inspect errors:
def save
if form.valid?
navigate :root
else
render :edit, form: form
end
end
Component State
Interactive widgets need state that outlives one event — a cursor position, a scroll offset, an expanded-node set. Persistent controllers keep components for the screen’s lifetime, so the idiom is a memoized ivar:
def query
@query ||= Charming::Components::TextInput.new
end
The component object itself holds its state (value, cursor) across key presses. Do not store live component objects in the session — save_session drops anything that can’t survive a JSON round-trip. (The exception is runtime engine handles like session[:audio] ||= Charming::Audio::Player.new, which wrap a live process and are intentionally dropped by save_session.)
Do not memoize data-bound components. A memoized List of database rows serves stale items after the data changes. Rebuild those each dispatch and keep their interaction state, like the selected index, in a state object.
component_state (a session-backed widget-state hash) still works but is deprecated — use ivars for screen-lifetime component state.
Form State
Use Controller#form for terminal forms. Charming stores the form’s primitive state (values, field cursors, errors, focus) on the controller instance, so the draft survives events on the screen and is discarded on navigation.
def signup_form
form(:signup) do |f|
f.input :name, required: true
f.textarea :bio, height: 5
f.select :plan, options: ["Free", "Pro"]
f.confirm :terms, required: true
end
end
On submit, the focused form returns [:submitted, values] and dispatches to the action declared for the focus slot:
focus_ring :signup_form
on_submit :signup_form, :save_signup
def save_signup(values)
profile.assign_attributes(values)
profile.valid? ? navigate(:root) : show
end
The declarations are on_submit, on_select, and on_cancel. Submit and select actions receive the value; cancel actions receive no arguments.
When one form serves both “new” and “edit” modes, clear the stale draft when the mode (or the record) changes so the builder’s defaults re-seed:
before_action :prepare_form_state
def prepare_form_state
mode = editing_entry ? "edit-#{editing_entry.id}" : "new"
return if @compose_mode == mode
@compose_mode = mode
reset_form(:entry)
end
Session Persistence
Sessions are in-memory by default and vanish on quit. Opt into persistence per app:
class MyApp::Application < Charming::Application
persist_session to: "tmp/session.json"
end
The runtime saves on exit and the application reloads on boot. Only JSON-safe values survive: nil, booleans, numbers, strings, symbols, and arrays/hashes of those. Hash keys come back as symbols; symbol values come back as strings. The framework always excludes its internal keys (focus_state, command_palette). A corrupt or missing file falls back to an empty session.
State objects persist explicitly. Mark the attributes to keep with persist:
class HomeState < Charming::ApplicationState
attribute :count, :integer, default: 0
attribute :draft, :string, default: ""
persist :count
end
On save, each state object serializes as its class name plus its persisted attributes. On boot, the application re-instantiates the class with those attributes. Unmarked attributes — draft above — reinitialize to their defaults. A session file that references a renamed class or attribute logs a warning and starts that state fresh; it never crashes boot.
Drops are loud, not silent. A state class with no persist declarations, or a raw session value that is not JSON-safe, warns once per key with a save_session dropped ... message naming the fix. At 1.0 the warning goes away: undeclared means not persisted.
Good candidates: the chosen theme (stored automatically by use_theme) and scroll positions that must survive restarts. Form drafts live on the controller instance and are not restart-persisted.