Back to flin
flin

Attribute Reactivity

How FLIN's fine-grained reactivity system tracks dependencies at the attribute level -- updating only the specific DOM attributes that change, not entire components.

Thales & Claude | March 25, 2026 9 min flin
flinreactivityattributesbinding

When you write <div class="card {if active then 'active' else ''}" style="opacity: {opacity}"> in FLIN, two things happen when active changes: the class attribute updates, and nothing else. The style attribute does not re-evaluate. The element is not destroyed and recreated. The div's children are not re-rendered. Only the specific attribute that depends on the changed variable is touched.

This is attribute-level reactivity -- FLIN's approach to making UI updates as cheap as possible. Session 253 refined this system to its final form, building on the reactivity engine (covered in article 028) to track dependencies not just at the component level, but at the individual attribute level.

The Problem With Component-Level Reactivity

Most frameworks operate at the component level. When a state variable changes, the entire component re-renders, producing a new virtual DOM tree. The framework then diffs the old and new trees to find what changed and patches the real DOM accordingly.

This works, but it does unnecessary work. Consider a component with 50 elements:

flincount = 0
active = false

<div class="dashboard">
    <header>
        <h1>Dashboard</h1>
        <Badge variant={active ? "success" : "default"}>{active ? "Active" : "Inactive"}</Badge>
    </header>
    <div class="stats">
        <Stat label="Count" value={count} />
    </div>
    <!-- ... 47 more elements that don't use count or active ... -->
</div>

When count changes from 0 to 1, the only thing that needs to update is the Stat component's value prop. But in a framework with component-level reactivity, the entire dashboard re-renders: all 50 elements, all their attributes, all their children. The virtual DOM diff finds that only the Stat changed, but it had to compare everything to discover that.

FLIN skips the comparison. It knows, at compile time, exactly which attributes depend on which variables. When count changes, it updates only the attributes that reference count. Everything else is untouched.

How Attribute Tracking Works

During compilation, the emitter analyzes each attribute expression and records which variables it references:

flin<div
    class="card {if active then 'active' else ''}"
    style="opacity: {opacity}; transform: translateX({offset}px)"
    data-count={count}
>

The compiler produces a dependency map:

class  -> [active]
style  -> [opacity, offset]
data-count -> [count]

At runtime, each entry in this map becomes a "reactive binding." When active changes, only the class binding is re-evaluated. When opacity changes, only the style binding is re-evaluated. When count changes, only the data-count binding is re-evaluated.

rustpub struct ReactiveBinding {
    pub element_id: usize,
    pub attribute: String,
    pub expression: CompiledExpr,
    pub dependencies: Vec<VariableId>,
}

pub struct ReactiveScope {
    bindings: Vec<ReactiveBinding>,
    // Map from variable -> bindings that depend on it
    dependency_graph: HashMap<VariableId, Vec<usize>>,
}

impl ReactiveScope {
    pub fn notify_change(&mut self, var_id: VariableId, vm: &mut Vm) {
        if let Some(binding_ids) = self.dependency_graph.get(&var_id) {
            for &binding_id in binding_ids {
                let binding = &self.bindings[binding_id];
                let new_value = vm.eval_expr(&binding.expression)?;
                dom_set_attribute(binding.element_id, &binding.attribute, &new_value);
            }
        }
    }
}

The notify_change function is O(k) where k is the number of bindings that depend on the changed variable. For a typical component, k is 1-5. For a complex component with 50 elements, k is still 1-5 -- because most elements do not depend on any given variable.

Text Content Reactivity

The same tracking applies to text content:

flincount = 0
<p>You have clicked {count} times</p>

The text node "You have clicked {count} times" depends on count. When count changes, the text node's content is updated. The <p> element itself is not touched. Other text nodes in the component are not touched.

For components with many text nodes (a data table with 100 rows and 5 columns = 500 text nodes), this granularity is significant. Updating one cell's value touches one text node, not 500.

Two-Way Binding

Form inputs need two-way binding: the input displays a variable's value, and typing in the input updates the variable. FLIN handles this with a single value attribute:

flinname = ""
<input value={name} />
// Typing "Juste" updates name to "Juste"
// Setting name = "Claude" updates the input display

When the user types, FLIN's event system captures the input event and updates name. When name changes (from code), the reactive binding updates the input's value attribute.

This is simpler than React's controlled component pattern (which requires both value and onChange) and more explicit than Vue's v-model (which is syntactic sugar for the same thing). In FLIN, value={name} is a bidirectional binding by default for input elements.

The compiler detects that value on an <input>, <textarea>, or <select> element should be bidirectional and generates both the read binding (variable -> attribute) and the write binding (input event -> variable).

Conditional Rendering Reactivity

Conditional blocks ({if}) are reactive at the block level:

flinshow_details = false

<div>
    <Button click={show_details = !show_details}>
        {show_details ? "Hide" : "Show"} Details
    </Button>

    {if show_details}
        <Card>
            <Text>Details content here...</Text>
        </Card>
    {/if}
</div>

When show_details changes from false to true, the {if} block creates the Card and its children. When it changes back to false, the Card is removed. The Button's text content updates through attribute reactivity (the {show_details ? "Hide" : "Show"} expression depends on show_details).

The {if} block does not re-evaluate its children on every render. It mounts the children once when the condition becomes true and unmounts them when it becomes false. While mounted, the children have their own reactive bindings that update independently.

List Rendering Reactivity

List rendering ({for}) tracks items by identity:

flinitems = ["Apple", "Banana", "Cherry"]

<ul>
    {for item in items}
        <li>{item}</li>
    {/for}
</ul>

When items changes (an item is added, removed, or reordered), FLIN computes the minimal set of DOM operations:

  • Adding an item: creates one new <li> element
  • Removing an item: removes one <li> element
  • Reordering: moves existing elements without recreating them

This is keyed diffing, similar to React's key prop. FLIN uses the item's identity (its reference or value) as the implicit key. For entity instances, the id field is used as the key. For primitive values, the value itself is the key.

flin// Entity items use id as key automatically
{for user in users}
    <UserCard user={user} />
{/for}
// Adding a user creates one UserCard
// Removing a user removes one UserCard
// Reordering users moves existing UserCards

Batched Updates

Multiple state changes in the same event handler are batched into a single DOM update:

flinfn handle_submit() {
    name = form_name          // State change 1
    email = form_email        // State change 2
    submitted = true          // State change 3
    error = none              // State change 4
}

Instead of four separate DOM updates (one per state change), FLIN batches them. All four variables change, then all affected reactive bindings are re-evaluated in a single pass. The DOM is touched once, not four times.

Batching happens automatically for synchronous code. All state changes within a single function call, event handler, or lifecycle hook are batched. The DOM update happens after the function returns.

Computed Values

Values derived from other reactive values are automatically reactive:

flinitems = [1, 2, 3, 4, 5]
total = items.sum                    // Reactive: updates when items changes
average = total / items.len          // Reactive: updates when total or items changes
display = "Total: {total}, Avg: {average}"  // Reactive: updates when total or average changes

<Text>{display}</Text>

The reactivity system tracks the dependency chain: items -> total -> display, and items -> average -> display. When items.push(6) is called, total is recomputed, average is recomputed, display is recomputed, and the Text element's content is updated. All in a single synchronous pass, with no unnecessary intermediate renders.

Performance Characteristics

Attribute-level reactivity has specific performance characteristics:

Update cost: O(k) where k is the number of bindings that depend on the changed variable. Not O(n) where n is the total number of elements.

Memory cost: O(b) where b is the total number of reactive bindings. Each binding is approximately 64 bytes (element reference, attribute name, compiled expression, dependency list).

Compilation cost: O(t) where t is the total number of template expressions. The compiler analyzes each expression once to determine its dependencies.

For a typical page with 200 elements and 400 reactive bindings: - Memory: ~25KB for the reactivity graph - Update latency: <1ms for a single variable change - Batch update: <5ms for 10 simultaneous variable changes

These numbers are fast enough that the user never perceives a delay between action and visual feedback.

Comparison: Reactivity Granularity

FrameworkReactivity GranularityRe-render Unit
ReactComponentFull component (VDOM diff)
VueComponentComponent (VDOM diff, with optimization)
SvelteStatementAssignment targets
SolidJSSignalIndividual DOM nodes
FLINAttributeIndividual DOM attributes

FLIN's attribute-level granularity is closest to SolidJS's signal-based approach. Both avoid virtual DOM diffing entirely, updating the real DOM directly. The difference is in the developer experience: SolidJS requires explicit createSignal calls and getter/setter patterns. FLIN's reactivity is implicit -- assign to a variable, and everything that depends on it updates automatically.

Why This Matters

For a simple counter, the difference between component-level and attribute-level reactivity is invisible. Both produce smooth, instant updates.

For a complex dashboard with 500 elements, 50 state variables, and real-time data updates, the difference is dramatic. Component-level reactivity re-renders the entire dashboard on every change, diffing 500 elements. Attribute-level reactivity updates only the specific attributes that changed -- typically 1-5 DOM operations instead of 500 element comparisons.

This is the kind of performance optimization that makes the difference between a dashboard that feels sluggish and one that feels instant. And in FLIN, it happens automatically, without any developer optimization effort.


This is Part 92 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built attribute-level reactivity into a component system.

Series Navigation: - [91] Slots and Content Projection - [92] Attribute Reactivity (you are here) - [93] Theme Toggle and Dark Mode - [94] The Raw Tag: Escape Hatch for HTML

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles