Binding Dataclasses¶
Use this reference to understand how NiceGUI creates binding links, detects changes, propagates values, and applies forward and backward transforms.
The implementation details and signatures below are verified against NiceGUI 3.16.0. Check the target project's pinned version before copying version-sensitive behavior.
Primary Sources¶
- NiceGUI binding documentation: public binding behavior and examples
- NiceGUI
binding.pyatv3.16.0: binding graph, propagation, active links, strict checks, andbindable_dataclass - NiceGUI
ValueElementatv3.16.0:bind_value*signatures and transform direction - Python dataclasses: generated methods, fields, defaults, and mutable-value rules
- PEP 557: dataclass design rationale
What bindable_dataclass Changes¶
@binding.bindable_dataclass first applies Python's @dataclass, then replaces each selected field on the resulting class with a NiceGUI BindableProperty descriptor. The descriptor stores the field value privately and intercepts later assignment.
from nicegui import binding, ui
@binding.bindable_dataclass
class Profile:
name: str = "Ada"
age: int = 37
profile = Profile()
ui.input("Name").bind_value(profile, "name")
ui.number("Age", min=0).bind_value(profile, "age")
ui.label().bind_text_from(
profile,
"name",
backward=lambda name: f"User: {name}",
)
Assigning a different value to profile.name invokes the descriptor immediately. It records the new value, propagates it through the binding graph, and then runs any descriptor change handler. Assigning an equal value returns without propagation.
By default every dataclass field is bindable. Pass bindable_fields to limit descriptor conversion:
@binding.bindable_dataclass(bindable_fields={"query", "page_size"})
class SearchState:
query: str = ""
page_size: int = 25
request_count: int = 0
A bound field omitted from bindable_fields still works, but NiceGUI must treat it as an active link and poll it for changes.
Bindable Dataclasses As Component Handles¶
A reusable NiceGUI component can expose a bindable dataclass as its typed public handle. The component's render function creates the dataclass instance, builds the element subtree, establishes bindings against that instance, and returns it to the page. This keeps the page at the composition level while the component owns its field wiring and internal elements. See the complete reusable component contract.
Choose binding direction according to what the public field represents:
| Component field | Typical element relationship | Binding surface |
|---|---|---|
editable value such as query |
control and component share the value | element.bind_value(handle, "query") |
rendered status such as busy or count |
component state drives text, visibility, or enabled state | bind_*_from with a pure transform when needed |
| browser proposal requiring validation | event handler validates before assignment | explicit callback, then assign the accepted bindable field |
| collection controlling child count or layout | component state is read while rebuilding a bounded subtree | @ui.refreshable_method, not a binding to the child list itself |
| injected service or callback | component implementation dependency | ordinary dataclass field omitted from bindable_fields |
The returned handle should expose intentional component state and actions, not every child element. Bindable fields are effective for stable element properties because assignments propagate immediately. They do not create or delete elements when collection structure changes; a component-owned refreshable method should rebuild that region after the authoritative field is replaced. The target and instance behavior is defined under refreshable component regions.
Create one handle during each page build unless shared state is deliberate. A module-global bindable component model propagates across clients that bind to it, just as a module-level refreshable function can own targets from multiple clients.
Binding Graph And Propagation¶
NiceGUI stores bindings as directed edges from one object attribute to another. A two-way binding is two one-way edges with transforms in opposite directions.
When an edge is registered, NiceGUI propagates its source immediately. For a two-way binding, it registers and runs the backward edge first, then registers the forward edge. The model value therefore wins initial synchronization and seeds the control.
After registration, propagation follows these rules:
- A
BindablePropertyassignment starts propagation immediately whenold_value != new_value. - NiceGUI walks outgoing edges depth first.
- Each object-and-attribute node is visited at most once during that propagation pass, preventing a two-way cycle from running forever.
- Each edge transforms the source value, compares it with the target, and only assigns and continues when the values differ.
Since NiceGUI 2.16.0, this depth-first walk updates each affected node once per pass. Transform functions must not depend on call count or traversal order.
Authoritative Models And Projections¶
A bindable dataclass can own canonical page state while plain dictionaries or component properties act as serializable projections. Use a one-way binding from each model field to its projection when browser rendering requires a different container shape:
from nicegui import binding
projection = {"name": profile.name}
binding.bind_to(
profile,
"name",
projection,
"name",
other_strict=True,
)
Assigning profile.name then propagates immediately to projection["name"]. The projection is transport state, not a second business model; application code should locate and mutate the owning dataclass rather than treating browser-visible dictionaries as authoritative. This distinction is especially useful when one client-side scoped template renders many records and therefore cannot bind to one fixed Python object. The editable-table pattern applies it to one row dataclass and one QTable payload per stable row identity.
Browser-originated values still require Python validation before model assignment. Keep editable fields explicit, normalize into domain types, verify permissions and record existence, and only then assign the bindable field. For the client event path that carries such proposals, see server-authoritative edit proposals.
Persistence And Rollback¶
Treat a dataframe, service, or repository as the persistence boundary around the canonical bindable model:
- validate and normalize the proposed value
- remember the previous model value
- assign the normalized value so bound projections update
- persist the model through the owning adapter, service, or repository
- if persistence fails, restore the previous model value before reporting or re-raising the error
- refresh the affected component from the resulting projection on both acceptance and rejection
For asynchronous persistence, await the transaction and refresh only after it commits or rolls back. Catch expected validation, conflict, and persistence exceptions separately so the interface can report actionable failures without hiding programming errors. Component-specific refresh APIs and identity rules remain the responsibility of the consuming pattern; for QTable, see persistence and row refresh.
Bindable Properties Versus Active Links¶
| Source | Change detection | Update timing |
|---|---|---|
NiceGUI element property or BindableProperty field |
descriptor intercepts assignment | immediate |
| ordinary object attribute or mapping entry | refresh loop compares source and target | next refresh step |
tuple path such as ("address", "city") |
the full path is not a single bindable descriptor key | refresh loop unless the owning leaf object is bound directly |
The active-link refresh interval defaults to 0.1 seconds and is configured with binding_refresh_interval in ui.run(...). Every refresh applies the transform and compares the result, so polling large collections or running expensive transforms can block the event loop. Tune the interval only after measuring; first reduce active links and transform cost.
Transform Direction¶
The names forward and backward are relative to the element on which bind_value* is called:
| API | Source to target | Transform |
|---|---|---|
element.bind_value_to(model, "field") |
element to model | forward |
element.bind_value_from(model, "field") |
model to element | backward |
element.bind_value(model, "field") |
both directions | both; backward runs first initially |
Each transform adapts the source value before NiceGUI assigns it to the target. The examples below convert between control values and native Python types only to make the two directions easy to observe; they do not prescribe a state-modeling approach.
Keep both functions pure, fast, and valid for every value the source can emit. NiceGUI does not turn transform exceptions into validation messages.
Example: Observe Both Directions¶
This example uses datetime.date and int conversions to expose the mechanics. Their different representations make it clear which transform runs as a value crosses each binding edge.
from dataclasses import field
from datetime import date
from nicegui import binding, ui
@binding.bindable_dataclass
class ReportFilters:
start_on: date = field(default_factory=date.today)
page_size: int = 25
filters = ReportFilters()
ui.date().bind_value(
filters,
"start_on",
forward=date.fromisoformat, # control str -> model date
backward=date.isoformat, # model date -> control str
)
ui.select(
options={"10": "10 rows", "25": "25 rows", "50": "50 rows"},
label="Page size",
).bind_value(
filters,
"page_size",
forward=int, # control str -> model int
backward=str, # model int -> control str
)
ui.label().bind_text_from(
filters,
"start_on",
backward=lambda value: f"Starting {value:%d %B %Y}",
)
At binding time, NiceGUI runs backward from the model to each control. Later control changes run forward toward the model. Assigning a new model value runs backward again.
Example: Follow A Constrained Value¶
A select and an Enum provide a second visible representation change. Because the select only emits known values, this example keeps attention on propagation rather than parse failures.
from enum import Enum
from nicegui import binding, ui
class SortOrder(Enum):
NEWEST = "newest"
OLDEST = "oldest"
@binding.bindable_dataclass
class ResultsState:
sort_order: SortOrder = SortOrder.NEWEST
state = ResultsState()
ui.select(
options={"newest": "Newest first", "oldest": "Oldest first"},
label="Sort order",
).bind_value(
state,
"sort_order",
forward=SortOrder, # control str -> model SortOrder
backward=lambda value: value.value, # model SortOrder -> control str
)
The concrete types are incidental. The same graph mechanics apply whenever forward and backward map two representations.
Dataclass Modeling Rules¶
- Use
field(default_factory=...)for mutable defaults and time-dependent defaults. - NiceGUI
3.16.0rejectsfrozen=Trueandslots=Trueinbindable_dataclass; both conflict with its descriptor storage model. - Keep UI-editable fields explicit and typed. Dataclass annotations describe intent but do not enforce runtime types; the control or transform must produce the right type.
- Replace collections instead of mutating them in place.
from dataclasses import field
from nicegui import binding
@binding.bindable_dataclass
class Filters:
query: str = ""
tags: list[str] = field(default_factory=list)
filters = Filters()
filters.tags = [*filters.tags, "python"] # unequal assignment propagates
Calling filters.tags.append("python") bypasses the descriptor. Mutating first and then assigning an equal copy also does not propagate because BindableProperty compares with != and returns when values are equal.
Nested Structures¶
Tuple paths support nested mappings and object attributes:
data = {"user": {"name": "Ada"}}
ui.input("Name").bind_value(data, ("user", "name"))
ui.label().bind_text_from(data, ("user", "name"))
A tuple path is checked as an active link. When a nested object is itself a bindable dataclass, bind its owning object directly to preserve immediate descriptor-driven propagation:
If profile.address is replaced later, rebuild that direct binding or bind through the root tuple path and accept active-link polling.
Strictness And Missing Paths¶
NiceGUI 3.16.0 checks object attributes by default and does not check mapping keys by default. A failed strict check raises AttributeError or KeyError while the binding is being created.
Use strict=False for an intentionally lazy object attribute and strict=True when a mapping key must already exist. On assignment, NiceGUI can create missing intermediate dictionaries, but it cannot create missing intermediate object attributes.
Common Pitfalls¶
- Do not put logging, I/O, model mutation, notifications, or other side effects in transforms. Propagation order and call count are implementation details.
- Do not use a transform as the validation boundary for free-form text. A raised parser exception interrupts propagation.
- Do not mutate a bound collection in place. Construct and assign a different value.
- Do not assume a nested tuple path gets the same immediate behavior as binding directly to a bindable leaf object.
- Scope bindable models to the appropriate page, client, or user. A module-global model shares state across users.
- Remove bindings with NiceGUI's public element lifecycle rather than retaining discarded elements or models indefinitely.
Version Checks¶
bindable_dataclasswas added in NiceGUI2.11.0.- Depth-first binding propagation changed in NiceGUI
2.16.0. - Binding strictness controls were added in NiceGUI
3.0.0. - Tuple paths for nested properties were added in NiceGUI
3.10.0. - NiceGUI
3.16.0supportsbindable_fieldsand rejectsslots=Trueandfrozen=True.
Verify the installed NiceGUI source and documentation when any of these mechanics affect application correctness.