State Management Design Document
This document describes how Trellis manages reactive state and enables two-way data binding.
Overview
Trellis provides two key abstractions for state management:
- Stateful — A base class for reactive state with automatic dependency tracking
- mutable() — A function for two-way data binding between state and form inputs
Stateful Base Class
Stateful is a base class for reactive state objects. Define state as a dataclass that inherits from Stateful:
from dataclasses import dataclass
from trellis import Stateful
@dataclass
class FormState(Stateful):
name: str = ""
email: str = ""
subscribed: bool = False
Automatic Dependency Tracking
When a component reads a state property during render, Trellis automatically tracks that dependency. When the property changes, the component re-renders.
@component
def UserDisplay() -> None:
state = UserState.from_context()
# Reading state.name registers this component as a dependency
w.Label(text=f"Hello, {state.name}!")
How it works:
Stateful.__getattribute__intercepts property access during render- It records which component (by element ID) accessed which property
- When
__setattr__detects a property change, it marks dependent components as dirty - On the next render cycle, only dirty components re-render
Property-Level Granularity
Dependency tracking is per-property, not per-object. A component that only reads state.name won't re-render when state.email changes.
@dataclass
class AppState(Stateful):
name: str = ""
count: int = 0
@component
def NameDisplay() -> None:
state = AppState.from_context()
w.Label(text=state.name) # Only depends on name
@component
def Counter() -> None:
state = AppState.from_context()
w.Label(text=str(state.count)) # Only depends on count
Modifying state.count only re-renders Counter, not NameDisplay.
Context Integration
State can be shared across components using context:
@component
def App() -> None:
state = AppState()
with state: # Push to context
Header()
Content()
Footer()
@component
def Header() -> None:
state = AppState.from_context() # Retrieve from context
w.Label(text=state.title)
Two-Way Data Binding with mutable()
For form inputs, Trellis provides mutable() to create two-way bindings between state properties and widgets.
The Problem
Without two-way binding, updating state from form inputs requires explicit callbacks:
# Verbose pattern with widgets
w.TextInput(
value=state.name,
on_change=lambda v: setattr(state, "name", v),
)
# Same pattern with HTML elements
h.Input(
value=state.name,
on_input=lambda e: setattr(state, "name", e["target"]["value"]),
)
The Solution
With mutable(), the binding is automatic:
# Clean two-way binding
w.TextInput(value=mutable(state.name))
How mutable() Works
-
Property Access Recording: When you access
state.name,Stateful.__getattribute__records the access in a context variable:(owner, attr_name, value) -
Reference Capture:
mutable()reads this recorded access and creates aMutable[T]wrapper containing the owner and attribute name -
Serialization: During serialization,
Mutable[T]becomes:{"__mutable__": "callback_id", "value": "current_value"} -
Client Handling: The client extracts the value and auto-generates an
on_changehandler that invokes the callback -
State Update: When the user types, the callback sets the new value on the state, triggering re-renders
Supported Widgets
These widgets support mutable() for their primary value props:
| Widget | Property | Type |
|---|---|---|
| TextInput | value | str |
| NumberInput | value | float |
| Slider | value | float |
| Checkbox | checked | bool |
| Select | value | str |
| Tabs | selected | str |
| Collapsible | expanded | bool |
When to Use mutable() vs callback()
Use mutable() for simple bindings where the widget value maps directly to state:
w.TextInput(value=mutable(state.name))
w.Checkbox(checked=mutable(state.enabled))
Use callback() when you need custom processing:
# Validation and transformation
def set_name(value: str) -> None:
state.name = value.strip().title()
w.TextInput(value=callback(state.name, set_name))
# Clamping values
def set_slider(value: float) -> None:
state.percent = max(0.0, min(100.0, value))
w.Slider(value=callback(state.percent, set_slider))
Both mutable() and callback() create a Mutable[T] wrapper. The difference is that callback() stores a custom handler that's used instead of the auto-generated property setter.
Implementation Details
Mutable Class
class Mutable(Generic[T]):
__slots__ = ("_attr", "_on_change", "_owner")
def __init__(
self,
owner: Stateful,
attr: str,
on_change: Callable[[T], Any] | None = None,
) -> None:
self._owner = owner
self._attr = attr
self._on_change = on_change # Custom callback (None = use auto-setter)
@property
def value(self) -> T:
return object.__getattribute__(self._owner, self._attr)
@value.setter
def value(self, new_value: T) -> None:
setattr(self._owner, self._attr, new_value)
@property
def on_change(self) -> Callable[[T], Any] | None:
return self._on_change
The mutable() function creates a Mutable without a custom callback (uses auto-setter). The callback() function creates a Mutable with a custom callback.
Property Access Recording
# In RenderTree:
class RenderTree:
_last_property_access: tuple[Stateful, str, Any] | None = None
# In Stateful.__getattribute__:
def __getattribute__(self, name: str) -> Any:
value = object.__getattribute__(self, name)
# Skip internal attrs and callables
if name.startswith("_") or callable(value):
return value
# During render, record access on the RenderTree for mutable()
tree = get_active_render_tree()
if tree is not None:
tree._last_property_access = (self, name, value)
return value
Serialization Format
Mutable values serialize to a special format that the client recognizes:
# In serialization.py
if isinstance(value, Mutable):
# Use custom callback if provided, otherwise create a setter
if value.on_change is not None:
handler = value.on_change
else:
def setter(new_val):
value.value = new_val
handler = setter
cb_id = ctx.register_callback(handler, element_id, f"{prop_name}:mutable")
return {
"__mutable__": cb_id,
"value": serialize(value.value),
}
Client-Side Handling
The client wraps mutable refs in a Mutable<T> object that components use explicitly:
// In processProps
if (isMutableRef(value)) {
result[key] = new Mutable(value, onEvent);
}
// In each widget component
const { value, setValue } = unwrapMutable(valueProp);
const handleChange = setValue ?? on_change;
Each widget explicitly handles mutable bindings using unwrapMutable(), which returns the current value and an optional setValue function.