# 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](https://www.nicegui.io/documentation/section_binding_properties): public binding behavior and examples - [NiceGUI `binding.py` at `v3.16.0`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/binding.py): binding graph, propagation, active links, strict checks, and `bindable_dataclass` - [NiceGUI `ValueElement` at `v3.16.0`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/mixins/value_element.py): `bind_value*` signatures and transform direction - [Python dataclasses](https://docs.python.org/3/library/dataclasses.html): generated methods, fields, defaults, and mutable-value rules - [PEP 557](https://peps.python.org/pep-0557/): 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. ```python 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: ```python @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. ## 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: 1. A `BindableProperty` assignment starts propagation immediately when `old_value != new_value`. 2. NiceGUI walks outgoing edges depth first. 3. Each object-and-attribute node is visited at most once during that propagation pass, preventing a two-way cycle from running forever. 4. 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: ```python 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](./tables.md) 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](./component-mechanics.md#server-authoritative-edit-proposals). ### Persistence And Rollback Treat a dataframe, service, or repository as the persistence boundary around the canonical bindable model: 1. validate and normalize the proposed value 2. remember the previous model value 3. assign the normalized value so bound projections update 4. persist the model through the owning adapter, service, or repository 5. if persistence fails, restore the previous model value before reporting or re-raising the error 6. 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](./tables.md#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`](https://docs.python.org/3/library/datetime.html#date-objects) and `int` conversions to expose the mechanics. Their different representations make it clear which transform runs as a value crosses each binding edge. ```python 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`](https://docs.python.org/3/library/enum.html) provide a second visible representation change. Because the select only emits known values, this example keeps attention on propagation rather than parse failures. ```python 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.0` rejects `frozen=True` and `slots=True` in `bindable_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. ```python 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: ```python 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: ```python ui.input("City").bind_value(profile.address, "city") ``` 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. ```python from nicegui import app, ui ui.input().bind_value(app.storage.user, "display_name", strict=True) ``` 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_dataclass` was added in NiceGUI `2.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.0` supports `bindable_fields` and rejects `slots=True` and `frozen=True`. Verify the installed NiceGUI source and documentation when any of these mechanics affect application correctness.