Skip to content

Ref system: type-safe reactive references with auto-generated keys #159

@jlowin

Description

@jlowin

Summary

Introduce a Ref system that provides type-safe reactive references, eliminates namespace collisions via auto-generated component keys, and lets developers build expressions using Python operators and method chains that compile to the {{ }} expression DSL.

Supersedes #150 (namespace scoping) and #156 (type-safe refs) — the Ref system dissolves both problems with a single design.

The problems this solves

Namespace collisions (#150): Today, name="email" is both a human label and a global state key. Two components with name="email" clobber each other. Scoping (dot-path prefixes from ancestor containers) was one fix, but it creates fragile references that break when the component tree is refactored — {{ signup.email }} stops working if you move the form into a different parent.

Stringly-typed bindings (#156): {{ }} template strings have no IDE autocomplete, no typo detection, and no way to trace references through code.

Design

Core concept

Every component that holds a value gets an auto-generated unique key (e.g. input-a7f3). The developer never picks a name for binding purposes — they hold a Python reference to the component and access .ref to get a Ref object that serializes to the correct {{ }} expression.

slider = Slider(min=0, max=100)
slider.ref          # Ref object for this slider's value
str(slider.ref)     # "{{ slider-a7f3 }}"

Usage rules

Two rules, no exceptions:

  1. Bare ref when the entire value is reactive:

    Metric(value=slider.ref)
    Metric(value=slider.ref.currency())
    DataTable(rows=filtered)  # filtered is a Ref from Computed
  2. f-string when mixing refs with literal text:

    Text(f"Revenue: {(revenue.ref / 1_000_000).round(2)}M")
    Text(f"Showing {items.length()} {items.length().pluralize('result')}")

Operators → expression AST

Python operators on Ref/ExprNode build expression trees that serialize to the DSL:

slider.ref * 2              # {{ slider-a7f3 * 2 }}
slider.ref * qty.ref        # {{ slider-a7f3 * slider-b2e1 }}
price.ref > 100             # {{ price-c3d4 > 100 }}

Supported operators: +, -, *, /, ==, !=, >, >=, <, <=

Boolean logic (Python doesn't allow overloading and/or/not):

ref_a.and_(ref_b)           # {{ key-a && key-b }}
ref_a.or_(ref_b)            # {{ key-a || key-b }}
ref_a.not_()                # {{ !key-a }}

Ternary:

(status.ref == "active").then("Online", "Offline")
# {{ status-x1y2 == 'active' ? 'Online' : 'Offline' }}

Pipes → method chains

Every registered pipe in the expression engine maps to a method on Ref/ExprNode:

revenue.ref.currency()          # {{ key | currency }}
revenue.ref.currency("EUR")    # {{ key | currency:'EUR' }}
revenue.ref.round(2)           # {{ key | round:2 }}
name.ref.upper()               # {{ key | upper }}
items.ref.length()             # {{ key | length }}
items.ref.first()              # {{ key | first }}
items.ref.selectattr("done")   # {{ key | selectattr:'done' }}

Chains compose:

(revenue.ref * 1000).round(2).currency()
# {{ key * 1000 | round:2 | currency }}

Dot-path access

user = Ref("user")
user.name                    # {{ user.name }}
user.address.city            # {{ user.address.city }}
user.created_at.date("long") # {{ user.created_at | date:long }}

Uncalled attribute access = dot-path. Called = pipe invocation. Ambiguous cases (like .length) need a clear resolution rule — probably: treat as dot-path on attribute access, use .length() (called) for the pipe.

Auto-generated keys

Components auto-generate unique keys using a prefix + short random suffix:

  • Slider(...) → key slider-a7f3
  • Input(...) → key input-b2e1
  • Select(...) → key select-c3d4

Opt-in human-readable name via name= parameter still works for:

  • Form submission (server expects known field names)
  • Interop with external state
  • Manual {{ }} templates (ForEach $item, etc.)

When name= is provided, it's used as the key. When omitted, auto-generated.

Computed returns a Ref

filtered = Computed(
    tool="filter_sales",
    args={"category": category.ref, "min_rev": min_rev.ref},
)
# filtered is a Ref — use it directly
DataTable(rows=filtered)
Metric(value=filtered.length())

Runtime variables use explicit Ref()

Loop variables and event values don't exist at Python authoring time:

with ForEach(each=items, item_name="item"):
    item = Ref("$item")
    Text(f"{item.name}: {item.price.currency()}")

Ref("$item"), Ref("$event"), Ref("$index"), Ref("$error") — the escape hatch for renderer-injected variables.

Implementation scope

Python side

  • Ref class: __str__, __format__, __getattr__, arithmetic/comparison operators
  • ExprNode class: intermediate expression tree with pipe methods, serializes to DSL string
  • Component.ref property: returns Ref bound to the component's key
  • Auto-key generation in Component.__init__ (when name is not provided)
  • Pydantic BeforeValidator on str fields to accept Ref/ExprNode (calls str())

Renderer side

  • No changes to expression evaluation — it already handles {{ }} with dot-paths, operators, and pipes
  • Component state read/write uses whatever key is in the JSON (auto-generated or explicit)

JSON wire format

  • No changes — the JSON carries the resolved key strings as it does today

Full example

from prefab_ui.app import PrefabApp
from prefab_ui.components import *
from prefab_ui.components.charts import BarChart, ChartSeries
from prefab_ui.actions import Computed

with Column(gap=6) as view:
    H2("Sales Dashboard")

    with Row(gap=4):
        category_select = Select(placeholder="All Categories")
        with category_select:
            for cat in ["Electronics", "Clothing", "Food"]:
                SelectOption(cat, value=cat)
        min_rev = Slider(min=0, max=10000, value=0).ref

    filtered = Computed(
        tool="filter_sales",
        args={"category": category_select.ref, "min_rev": min_rev},
    )

    with Row(gap=4):
        Metric(label="Results", value=filtered.length())
        Metric(label="Revenue", value=filtered.sum("revenue").currency())

    BarChart(
        data=filtered,
        x_axis_key="product",
        series=[ChartSeries(data_key="revenue", name="Revenue")],
    )

    DataTable(
        columns=[
            DataTableColumn(key="product", header="Product", sortable=True),
            DataTableColumn(key="revenue", header="Revenue", sortable=True),
        ],
        rows=filtered,
        searchable=True,
    )

app = PrefabApp(view=view)

Open questions

  • Dot-path vs pipe ambiguity: ref.length (dot-path to a .length property) vs ref.length() (the | length pipe). Proposed: uncalled = dot-path, called = pipe. Need to ensure this is unambiguous for all existing pipes.
  • Should Ref.__format__ emit {{ }} wrapping, or just the expression? (With wrapping: f-strings work naturally. Without: more explicit but requires manual wrapping.)
  • Key generation: random suffix? Incrementing counter? Content hash? Need uniqueness within an app, stability across re-renders.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementImprovement to existing functionality or new capabilities.pythonRelated to the Python SDK: components, actions, serialization.rendererRelated to the TypeScript/React renderer.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions