Skip to main content
Expressions are how components read and compute from state. Every {{ }} you write is a live binding: the renderer evaluates it against current state, tracks which keys it depends on, and re-evaluates whenever any of them change. The display stays current automatically, so you write the expression once and the renderer handles the rest. The expression language is intentionally bounded. Prefab UIs are designed to be safely serializable, so the expression grammar covers arithmetic, comparisons, boolean logic, conditionals, and formatting. That’s enough to drive any display while keeping business logic where it belongs: in your Python code or in a server action.

Template Expressions

The {{ }} syntax is the protocol-level unit of reactivity. Any string prop on any component can contain {{ }} expressions, and the renderer treats each one as a live dependency. Text("{{ count }}") displays whatever count holds and updates whenever it changes. The SetState actions write new values to count, and the text responds automatically. The expression {{ count + 1 }} computes before writing: the renderer evaluates it against current state each time. The reactivity is built in, so you write the binding once and the renderer keeps everything in sync. Expressions can appear anywhere a string prop does. Button(disabled="{{ loading }}") disables a button based on state. Badge(label="{{ items | length }}") shows a count. Progress(value="{{ percent }}") drives a progress bar. The renderer re-evaluates each expression whenever any of its referenced keys change.

The Rx Class

The expression language is accepted everywhere in the protocol, but writing {{ }} strings by hand in Python means giving up autocomplete, refactoring support, and typo detection. Rx lets you write the same expressions as Python code, with full editor support. Rx("key") creates a reactive reference to a state key. The object it returns compiles to {{ key }} when serialized, but in your Python code it behaves like a regular value. You can do arithmetic on it, compare it, format it, and embed it in f-strings. Each operation returns a new Rx that represents the composed expression:
from prefab_ui.rx import Rx

count = Rx("count")

Text(count)                # → Text("{{ count }}")
Text(count + 10)           # → Text("{{ count + 10 }}")
Text(f"Count: {count}")    # → Text("Count: {{ count }}")
Each operation on an Rx returns a new Rx, building an expression tree. At serialization time, that tree renders to the correct {{ }} protocol syntax. You work in Python; the protocol gets the right strings. Method calls on Rx compile to pipes in the protocol, which format and transform values for display. The Formatting with pipes section covers them in detail, but here’s the basic idea:
price = Rx("price")
quantity = Rx("quantity")

Text(f"Total: {(price * quantity).currency()}")
# → Text("Total: {{ price * quantity | currency }}")
F-strings work naturally with Rx. Each Rx reference inside an f-string becomes a separate {{ }} interpolation in the output. Surrounding text stays as literal strings:
first = Rx("first")
last = Rx("last")

Text(f"Hello, {first} {last}!")
# → Text("Hello, {{ first }} {{ last }}!")

The .rx Shorthand

Every interactive component manages a state key through its name prop, and that state can be read by any other component or action that knows the key name. The challenge is that key names are often auto-generated (like slider_22) or defined far from where you need them. Passing string key names around is fragile and hard to refactor. The .rx property solves this. It returns Rx(component.name), giving you a reactive reference to that component’s state without needing to know or repeat the key name: Drag the slider and the text and progress bar update together. slider.rx compiles to {{ slider_22 }} (the auto-generated name), but you never need to know or type that key. The dependency is expressed through the Python variable, which keeps your code readable and refactor-safe. Because .rx returns an Rx object, all the same operations work on it. slider.rx.number() formats the value. slider.rx > 50 produces a boolean expression. slider.rx.then("High", "Low") creates a conditional. Use .rx when you have a component reference in scope. Use Rx("key") when working with keys from PrefabApp(state={...}), ForEach iteration, or state keys that belong to components you don’t have a reference to. Sometimes you need a component’s .rx before the component exists — a label above a slider that shows its current value, for instance. Rx(lambda: slider) defers resolution until render time, when the variable is available. See Forward References for details.

Operators

Rx overloads Python’s operators so arithmetic, comparisons, and logic compile naturally to their protocol equivalents. Each operation returns a new Rx representing the composed expression. Operators follow standard mathematical precedence: multiplication and division before addition and subtraction, with parentheses for explicit grouping.

Arithmetic

PythonProtocolDescription
count + 1{{ count + 1 }}Addition
total - discount{{ total - discount }}Subtraction
price * quantity{{ price * quantity }}Multiplication
amount / 2{{ amount / 2 }}Division
-score{{ -score }}Negation
Arithmetic expressions can appear anywhere a value is expected: in a Text component, in a SetState action value, or in a prop like Progress(value=...). price * quantity compiles to {{ price * quantity }}, and .currency() formats the result for display.

String Concatenation

F-strings are the cleanest way to mix reactive values with literal text. Each Rx reference becomes a separate {{ }} interpolation:
first = Rx("first")
last = Rx("last")

Text(f"Hello, {first} {last}!")
# → Text("Hello, {{ first }} {{ last }}!")
The + operator also concatenates when either operand is a string: {{ 'Hello, ' + name }}. F-strings are almost always more readable.

Comparisons

PythonProtocolMeaning
count > 0{{ count > 0 }}Greater than
count >= 10{{ count >= 10 }}Greater than or equal
count < 100{{ count < 100 }}Less than
count <= 50{{ count <= 50 }}Less than or equal
status == 'active'{{ status == 'active' }}Equal (loose)
status != 'done'{{ status != 'done' }}Not equal
Comparisons return boolean expressions, which are the foundation for conditional rendering with If/Elif/Else and conditional values with .then():
from prefab_ui.rx import Rx
from prefab_ui.components import Alert
from prefab_ui.components.control_flow import If, Elif

inventory = Rx("inventory")

with If(inventory == 0):
    Alert("Out of stock", variant="destructive")
with Elif((inventory > 0) & (inventory < 10)):
    Alert("Low stock")
The If component receives a boolean expression and only renders its children when the expression is true. When inventory changes, the conditions re-evaluate and the display updates.

Logical Operators

PythonProtocolMeaning
a & b{{ a && b }}AND
a | b{{ a || b }}OR
~a{{ !a }}NOT
The protocol also accepts and, or, not as keyword alternatives. Both && and || short-circuit. || doubles as a falsy default: {{ name || 'Anonymous' }} returns 'Anonymous' when name is falsy (empty string, false, 0, or undefined). For null/undefined-only defaults, use the default pipe instead (see Default values).
Python precedence gotcha. Bitwise & and | bind tighter than >, <, == in Python. Always wrap each comparison in parentheses:
# Correct
(score > 0) & (score < 100)

# Wrong: parsed as score > (0 & score) < 100
score > 0 & score < 100
This is a Python quirk. The {{ }} protocol expressions use standard precedence and don’t require extra parentheses.

Ternary

PythonProtocol
active.then("On", "Off"){{ active ? 'On' : 'Off' }}
(score > 90).then("Pass", "Fail"){{ score > 90 ? 'Pass' : 'Fail' }}
.then(if_true, if_false) chooses between two values based on a boolean expression. It compiles to the ternary operator ? : in the protocol: Toggle the switch and the text updates instantly. .then() works on any boolean Rx, including comparison results: (score >= 90).then("Pass", "Fail"). For choices beyond a simple two-way branch, If/Elif/Else is cleaner.

Formatting with Pipes

Raw state values often need formatting before display. Pipes transform expression results: formatting numbers as currency, dates as readable strings, arrays into counts, and more. In the protocol, pipes use the | syntax: {{ revenue | currency }}. In Python, they’re method calls on Rx: revenue.currency(). Pipes process values left to right. In {{ todos | rejectattr:done | length }}, the array is first filtered, then counted. You can chain as many pipes as needed.

Number Formatting

PipePythonProtocolResult
currency.currency() / .currency("EUR"){{ x | currency }} / {{ x | currency:EUR }}$1,234.00 / €1,234.00
percent.percent() / .percent(1){{ x | percent }} / {{ x | percent:1 }}76% / 75.6%
number.number() / .number(2){{ x | number }} / {{ x | number:2 }}1,234 / 1,234.00
compact.compact() / .compact(0){{ x | compact }} / {{ x | compact:0 }}1.8M / 2M
round.round(2){{ x | round:2 }}3.14
abs.abs(){{ x | abs }}42
percent multiplies by 100 before formatting. A value of 0.756 becomes 75.6%, not 0.756%. number and currency produce locale-formatted output (en-US by default). compact uses compact notation to abbreviate large numbers: 1800000 becomes 1.8M, 470000 becomes 470K, and small values like 42 pass through unchanged. The optional decimals argument controls precision.

Date and Time

PipeArgumentExample Output
dateshort1/15/2025
date(default: medium)Jan 15, 2025
datelongJanuary 15, 2025
time2:30 PM
datetimeJan 15, 2025, 2:30 PM
Input must be an ISO date string. The time pipe also accepts time-only strings like "14:30".

String Formatting

PipeArgumentDescription
upperUppercase the string
lowerLowercase the string
truncatemax lengthClamp to N characters, append ... if truncated
pluralizesingular word (default item)Returns the word as-is for count 1, appends s otherwise
pluralize pairs well with length for labeling dynamic counts: Rx("items").length().pluralize("item") produces {{ items | length | pluralize:'item' }}, rendering as “1 item” or “3 items”.

Array Operations

Array pipes filter and summarize lists stored in state. Given a list of todo items, each with a done flag, you can count how many are complete, how many remain, and how many there are total — all reactively. selectattr("done") keeps only items where done is truthy. rejectattr("done") keeps items where it’s falsy. Chain .length() to count the filtered results. These pipes work together to turn a raw array into meaningful summary numbers.
PipeArgumentDescription
lengthNumber of elements (also works on strings)
joinseparator (default , )Join elements into a string
firstFirst element
lastLast element
selectattrattribute nameKeep items where the attribute is truthy
rejectattrattribute nameRemove items where the attribute is truthy

Chaining

Pipes chain left to right; each pipe receives the output of the one before it:
PythonProtocol
name.lower().truncate(20){{ name | lower | truncate:20 }}
todos.rejectattr("done").length(){{ todos | rejectattr:'done' | length }}
revenue.currency().upper(){{ revenue | currency | upper }}

Default Values

A bare literal after | acts as a default when the left side is null or undefined. In Python, use .default():
PythonProtocolBehavior
name.default("Anonymous"){{ name | 'Anonymous' }}"Anonymous" if name is undefined
count.default(0){{ count | 0 }}0 if count is undefined
The default pipe checks specifically for null/undefined: an empty string "" or 0 will not trigger the default. For broader falsy defaults (including empty strings, zero, and false), use || instead: {{ name || 'Anonymous' }}. Unknown pipe names pass the value through unchanged; no error, just unformatted output.

Context and Variables

Local Scope

The let prop on any container introduces scoped bindings visible to its children only: The first Text renders “Don’t Panic, Arthur”. The second renders “Don’t Panic, Ford”: the inner let shadows name with a new value, but greeting is inherited unchanged. Use PrefabApp(state={...}) for mutable data the user can change. Use let for fixed data passed into a section: labels, configuration, static values. let bindings are read-only.

Capturing Loop Variables

When nesting ForEach loops, both define $index. Capture the outer index with let before entering the inner loop:
from prefab_ui.components import Text
from prefab_ui.components.control_flow import ForEach

with ForEach("groups", let={"gi": "{{ $index }}"}):
    with ForEach("groups.{{ gi }}.todos"):
        Text("Group {{ gi }}, item {{ $index }}")

Special Variables

These are runtime values injected by the framework at specific points in the component tree. They don’t exist in global state; access them with {{ }} template strings.

$event

Available inside action handlers. Contains the value emitted by the interaction:
Component$event value
Input / TextareaCurrent text (string)
SliderCurrent position (number)
Checkbox / SwitchChecked state (boolean)
SelectSelected value (string)
RadioGroupSelected value (string)
Buttonundefined
To capture $event explicitly, pass EVENT as the value: SetState("last_volume", EVENT). Form controls update their own state key automatically, so this is mainly useful when writing the event value to a different key.

$error

Available inside on_error callbacks. Contains the error message from the failed action:
CallTool(
    "save_data",
    on_error=ShowToast("Failed: {{ $error }}", variant="error"),
)

$index

Available inside ForEach. The zero-based index of the current item. Essential for actions targeting a specific row: SetState("todos.{{ $index }}.done").

$item

Available inside ForEach. The entire current item object. Individual fields are accessible directly as {{ name }}, but $item is useful for passing the whole object to an action:
with ForEach("users"):
    Text("{{ name }}")
    Button("Edit", on_click=CallTool("edit_user", arguments={"user": "{{ $item }}"}))

Dot Paths

Attribute access on Rx builds dot paths: Rx("user").address.city compiles to {{ user.address.city }}. Integer segments address array items: {{ todos.0.done }}. .length works on arrays and strings. If any segment is null or undefined, the expression resolves to undefined rather than throwing.

Type Preservation

When an Rx expression is the entire value of a prop, the renderer resolves it to the appropriate JavaScript type. Progress(value=slider.rx) serializes to "{{ slider_22 }}", and the renderer resolves it to the number 50, preserving the type for props that expect numbers or booleans. When an expression is embedded in surrounding text, the result is always a string. f"Volume: {slider.rx}%" resolves to "Volume: 50%" because the surrounding text forces string concatenation. The practical rule: if a prop expects a number or boolean (value, min, max, disabled, checked), pass the Rx expression as the sole value. Wrapping it in an f-string converts the result to a string, which may cause unexpected behavior.

Undefined Values

For sole-value templates ("{{ missing }}"): returns the literal template string unchanged, which helps with debugging. For mixed templates ("Hi {{ missing }}!"): undefined resolves to an empty string, producing "Hi !". Use the default pipe for fallbacks: {{ missing | 'N/A' }}.
Boolean prop gotcha. disabled="{{ waiting }}" with undefined waiting evaluates to the string {{ waiting }}, which is truthy. Fix with: disabled="{{ waiting | false }}".

Grammar

The full BNF for the expression language inside {{ }} delimiters. The Rx DSL handles expression construction for you, but this is useful for writing protocol JSON directly or building tooling.
expr       -> pipe
pipe       -> ternary ( '|' ( ident ( ':' arg )? | literal ) )*
ternary    -> or ( '?' expr ':' expr )?
or         -> and ( '||' and )*
and        -> not ( '&&' not )*
not        -> '!' not | comp
comp       -> add ( ( '==' | '!=' | '>' | '<' | '>=' | '<=' ) add )?
add        -> mul ( ( '+' | '-' ) mul )*
mul        -> unary ( ( '*' | '/' ) unary )*
unary      -> ( '-' | '+' ) unary | primary
primary    -> '(' expr ')' | number | string | 'true' | 'false' | 'null' | ident
ident      -> name ( '.' name )*
Pipe has the lowest precedence, so price * quantity | currency parses as (price * quantity) | currency. The ternary operator is next-lowest, meaning a > 0 ? a : -a | abs parses as (a > 0 ? a : -a) | abs. Use parentheses to override. Keywords and, or, and not are interchangeable with &&, ||, and !. Strings use single quotes inside expressions: {{ status == 'active' }}.