Skip to content

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.

Application 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] so ctx.state is 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.

Grid state
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())
  1. Assigning to count schedules 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.

grid_render from application state
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):

Show a panel from state
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.

group=
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:

ctx.focus("body")   # or ctx.runtime.focus("body")
ctx.blur()

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.

class_name and keywords
# 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.

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

API

Field · Context · Terminal · State · BaseGrid