State¶
xnano splits data that paints from data that does not, and gives you two places to keep the latter: on a single grid, or shared across the whole app.
That is what this page is about — not the optional
xnano.state.State helper class (a thin
bag for dynamic attributes). Most apps use a normal dataclass (or any object)
for application state.
Two kinds of state¶
| Kind | Where it lives | Paints? | Shared? |
|---|---|---|---|
| Application state | Terminal(state=...) / Web(state=...) |
No | Every grid and hook via ctx.state |
| Grid state | Field(state=True) on one grid |
No | Only that grid instance |
Painted fields (str, components, nested grids) are the UI. State fields and
application state are the model. Hooks and grid_render move data between
them.
Application state¶
Hand any object to the host once. Every hook on every grid sees the same
instance as ctx.state.
import dataclasses
from xnano import BaseGrid, Context, Field, Terminal, on_keyboard
@dataclasses.dataclass
class AppState:
count: int = 0
title: str = "Counter"
class Counter(BaseGrid):
label: str = Field(default="0", height=1)
@on_keyboard("up")
def inc(self, ctx: Context[AppState]) -> None:
ctx.state.count += 1
self.label = str(ctx.state.count)
Terminal(state=AppState()).run(Counter())
Use application state when more than one grid needs the same data (a note list and an editor, a selection index and a detail panel).
Web(state=...)works the same way for browser sessions.- Type hooks as
Context[AppState]soctx.stateis checked. ctx.get_state()returns the same object and raises if nothing was attached.
A full walkthrough lives in Getting Started.
Optional State helper¶
If you do not want a fixed schema:
from xnano.state import State # not exported from the package root
state = State(name="John", count=0)
state.count += 1
Terminal(state=state).run(App())
Prefer a dataclass or Pydantic model when the shape is known.
Grid state (Field(state=True))¶
A state field is typed data on one grid. It never claims a layout slot.
from xnano import BaseGrid, Field, Terminal, on_keyboard
class Counter(BaseGrid, direction="vertical"):
label: str = Field(default="Count: 0", height=1)
count: int = Field(default=0, state=True) # (1)!
@on_keyboard("up")
def inc(self) -> None:
self.count += 1
self.label = f"Count: {self.count}"
Terminal().run(Counter())
- Assigning to
countschedules a repaint of this grid. The value itself is never drawn unless you copy it into a painted field (here,label).
Use grid state for local UI state: open/closed flags, local counters, temporary buffers that no sibling grid needs.
Plain attributes¶
class Card(BaseGrid):
heading: str = Field(default="Hello")
tags: list[str] = ["a", "b"] # never painted, no Field metadata
A plain attribute is normal Python. Use Field(state=True) when you want
Field defaults / validation metadata and repaint-on-assign behavior.
Keeping the UI in sync¶
State does not paint itself. Refresh painted fields from state each frame with
grid_render, or update them in hooks.
from xnano import BaseGrid, Context, Field
class Dashboard(BaseGrid):
stats: str = Field(default="")
def grid_render(self, ctx: Context[AppState]) -> None:
self.stats = f"{ctx.state.count} items"
Drive layout metadata from state with grid_set_field (visibility, title,
size, and so on):
def grid_render(self, ctx: Context[AppState]) -> None:
self.grid_set_field("editor", visible=ctx.state.editing)
See Fields and Grids for grid_render /
grid_set_field details.
Focus groups¶
group= is not state storage — it names a field so focus and some events
can find it across the tree.
from xnano.components.input import Input
class Editor(BaseGrid):
title: Input = Field(
default_factory=lambda: Input(placeholder="Title"),
group="title",
height=1,
)
body: Input = Field(
default_factory=lambda: Input(placeholder="Body", multiline=True),
group="body",
)
From a hook:
Fields that share a group string on different grids are addressed together
by ctx.focus(group), @on_focus(group=...), and @on_click(group=...).
autofocus=True prefers that field when nothing else is focused yet.
Style and class_name¶
Painted fields can take style keywords or a Tailwind-like class_name. That is
presentation of the slot, not the state store — but it is often driven from
state in grid_render or hooks.
# Static chrome
status: str = Field(
default="ok",
class_name="text-emerald-400 p-1 rounded-lg",
)
# Or keywords (same Style system — see Styling)
status: str = Field(default="ok", foreground="emerald-400", padding=1)
Change chrome at runtime when state changes:
def grid_render(self, ctx: Context[AppState]) -> None:
foreground = "emerald-400" if ctx.state.ok else "rose-400"
self.grid_set_field("status", foreground=foreground)
# or: self.status = "ok" / "error" and keep style fixed
Full style vocabulary: Styling.
Related field knobs¶
These often travel with state-driven UIs:
| Keyword | Role |
|---|---|
state=True |
Data only; no paint slot |
visible= |
Show or hide a painted field (often from app state) |
group= |
Focus / click / scroll target name |
autofocus= |
Default focus candidate |
overlay= / z= |
Floating panels driven by open/close state |
class_name= / style keywords |
How a painted slot looks |
scroll= |
Window overflow; drive with ctx.scroll(group) |
Choosing where data lives¶
| Situation | Prefer |
|---|---|
| Shared by several grids or hosts | Application state= |
| Local to one grid (counter, flag) | Field(state=True) |
| Shown on screen | Painted Field (maybe filled from state in grid_render) |
| One-off private Python value | Plain attribute |
Next¶
- Fields — slots and
Field(...) - Context —
ctx.stateinside hooks - Events & Hooks — mutate state from input
- Getting Started — notes app using both kinds