Back to flin
flin

The Reactivity Engine: How FLIN Makes Everything Reactive

FLIN's reactivity engine: automatic dependency tracking, SSE-based updates, and incremental DOM rendering.

Thales & Claude | March 25, 2026 11 min flin
flinreactivitysseincrementaldomreactive-programming

Reactivity is the soul of a modern web framework. When data changes, the UI should update. That sounds simple. It is not.

React solves it with a virtual DOM and diffing algorithm. Svelte solves it with compile-time dependency tracking. Vue solves it with proxies and a reactive effect system. Angular solves it with change detection and zones. Each approach has trade-offs in complexity, performance, and developer experience.

FLIN solves it differently. Reactivity is not a framework feature -- it is a runtime feature, built into the VM and the server layer. Sessions 025, 032, and the reactivity work spread across multiple sessions combined to create a system where every variable assignment automatically propagates to every view binding that references it. No manual subscriptions. No dependency arrays. No useEffect hooks. You write count = count + 1, and every {count} in the view updates.

This article explains how.

---

The Three Layers of Reactivity

FLIN's reactivity system operates at three layers:

1. Server-side rendering: The VM executes view opcodes and produces HTML with reactive annotations. 2. Client-side proxy: A JavaScript Proxy intercepts variable assignments and triggers DOM updates. 3. Server-sent updates: SSE pushes data changes from the server to the browser in real time.

Each layer handles a different aspect of reactivity. Together, they create a system where changes propagate instantly, whether they originate from user interaction (client-side), server logic (SSE), or database mutations (entity subscriptions).

---

Layer 1: Reactive Annotations

When the VM renders a view, it marks dynamic content with data-flin-bind attributes:

count = 0
name = "Thales"

view {

Welcome, {name}

Count: {count}

} ```

The VM produces:

<h1>Welcome, <span data-flin-bind="name">Thales</span></h1>
<p>Count: <span data-flin-bind="count">0</span></p>
<button onclick="count++; _flinUpdate()">Increment</button>

Static text ("Welcome, ", "Count: ") is rendered directly. Dynamic interpolations ({name}, {count}) are wrapped in elements with data-flin-bind attributes. The attribute value is the expression to re-evaluate when state changes.

This annotation approach has two advantages over virtual DOM diffing:

Precision. The runtime knows exactly which DOM nodes need to update. It does not need to diff the entire tree -- it queries [data-flin-bind] and updates only those elements.

Simplicity. The entire reactive update is a DOM query plus text content assignment. No tree reconciliation. No fiber scheduling. No concurrent mode. The code is small enough to fit in a single function.

---

Layer 2: The Reactive Proxy

The client-side runtime wraps application state in a JavaScript Proxy:

const _state = { count: 0, name: "Thales" };

const $flin = new Proxy(_state, { set(target, property, value) { target[property] = value; _scheduleUpdate(); return true; } });

// Expose state as global variables Object.defineProperty(window, 'count', { get() { return $flin.count; }, set(v) { $flin.count = v; } });

Object.defineProperty(window, 'name', { get() { return $flin.name; }, set(v) { $flin.name = v; } }); ```

Every state variable is exposed as a global property with a getter and setter. When a setter is called (e.g., count++ in an event handler), the Proxy's set trap fires, updates the underlying state, and schedules a DOM update.

The Object.defineProperty bridge is what makes event handlers so clean. The developer writes click={count++}, which compiles to onclick="count++; _flinUpdate()". The count++ triggers the global setter, which triggers the Proxy's set trap, which schedules the update. The _flinUpdate() call is a safety net that forces an immediate update if the Proxy's scheduled update has not fired yet.

---

Layer 2.5: Update Batching

Naive reactivity updates the DOM on every state change. If a handler changes three variables, the DOM updates three times. This is wasteful -- the intermediate states are never visible to the user.

FLIN batches updates using requestAnimationFrame:

let _updateScheduled = false;

function _scheduleUpdate() { if (!_updateScheduled) { _updateScheduled = true; requestAnimationFrame(function() { _flinUpdate(); _updateScheduled = false; }); } }

function _flinUpdate() { document.querySelectorAll('[data-flin-bind]').forEach(function(el) { const expr = el.getAttribute('data-flin-bind'); try { const value = eval(expr); if (el.textContent !== String(value)) { el.textContent = value; } } catch (e) { // Expression evaluation failed -- leave content unchanged } }); } ```

The _updateScheduled flag ensures that only one requestAnimationFrame callback is queued at a time. Multiple state changes within the same event handler result in a single DOM update at the next animation frame.

The if (el.textContent !== String(value)) check avoids unnecessary DOM writes. If the value has not actually changed (e.g., setting count = count), the DOM node is not touched. This prevents layout thrashing and unnecessary repaints.

---

Layer 3: Server-Sent Events

The SSE layer handles reactivity that originates on the server. When the server detects a state change (a database mutation, a background task completion, a timer firing), it pushes the update to the browser via the SSE connection.

The SSE endpoint (/_sse) serves two purposes in FLIN:

1. HMR: Pushing reload events when source files change (covered in the previous article). 2. Data updates: Pushing entity change notifications when the database is modified.

For entity change notifications, the server broadcasts events in a specific format:

event: entity_change
data: {"type":"Todo","action":"create","id":42,"fields":{"title":"Write article"}}

The client-side runtime receives this event and updates the relevant state:

source.addEventListener('entity_change', function(event) {
    const change = JSON.parse(event.data);
    const entityType = change.type;
    const action = change.action;

// Trigger subscribed handlers if (_entityHandlers[entityType]) { _entityHandlers[entityType].forEach(fn => fn(change)); }

// Trigger general reactivity update _flinUpdate(); }); ```

This means that when another user (or a server-side process) modifies a Todo entity, every connected browser receives the change and updates its view. No polling. No manual refresh. The data flows from database to browser in real time.

---

Incremental DOM Updates

Session 032 introduced incremental recompilation and incremental DOM updates. The goal was to avoid full page reloads for small changes.

Full page reload works, but it has a cost: any client-side state (scroll position, form input values, focus state) is lost. For the HMR case (developer saves a file), this is acceptable -- the developer expects a fresh page. For the SSE case (data changes while the user is interacting with the page), losing state is unacceptable.

The incremental update system works as follows:

1. The server compiles the FLIN source and produces a new HTML fragment. 2. The server diffs the new fragment against the previous render. 3. Only the changed portions are sent via SSE. 4. The client applies the changes to the existing DOM without a full reload.

The diff algorithm is simple because FLIN's HTML output is annotated with binding markers. Instead of diffing arbitrary HTML trees (which is complex and error-prone), the system compares binding values:

// Incremental update: only changed bindings
function _flinIncrementalUpdate(changes) {
    for (const [expr, newValue] of Object.entries(changes)) {
        const els = document.querySelectorAll(
            '[data-flin-bind="' + expr + '"]'
        );
        els.forEach(function(el) {
            el.textContent = newValue;
        });
    }
}

The server sends a JSON object mapping expressions to new values. The client finds the corresponding DOM elements and updates their text content. No tree diffing. No reconciliation. Just targeted updates.

---

The Reactivity Pipeline

Putting all three layers together, the complete reactivity pipeline looks like this:

User clicks button
    -> Event handler executes (count++)
    -> Proxy set trap fires
    -> _scheduleUpdate() queued
    -> requestAnimationFrame fires
    -> _flinUpdate() runs
    -> All [data-flin-bind] elements re-evaluated
    -> Changed elements updated

Server saves entity -> FlinDB notifies entity watchers -> SSE broadcasts entity_change event -> Client receives SSE event -> Entity handler fires -> _flinUpdate() runs -> Changed elements updated

Another browser modifies data -> WebSocket broadcasts entity_change -> Client receives WS message -> Entity handler fires -> _flinUpdate() runs -> Changed elements updated ```

Three entry points (user interaction, server logic, remote updates), one exit point (_flinUpdate). This convergence simplifies debugging: if a binding is not updating, the issue is either in the entry point (the event is not firing) or in the update function (the expression is not evaluating correctly). There is no complex dependency graph to trace.

---

What FLIN's Reactivity Is Not

It is worth being explicit about what FLIN's reactivity system does not do, because the design choices are as important as the features:

No virtual DOM. There is no in-memory representation of the DOM that gets diffed against the real DOM. The reactive proxy + data-flin-bind approach is simpler, faster for small-to-medium applications, and produces far less JavaScript.

No fine-grained dependency tracking. Svelte tracks which variables each binding depends on at compile time. FLIN re-evaluates all bindings on every update. This is O(n) where n is the number of bindings, but for typical FLIN applications (dozens to hundreds of bindings), the overhead is negligible. If FLIN ever needs to scale to thousands of bindings, compile-time dependency tracking would be the natural next step.

No computed properties (yet). Frameworks like Vue and MobX support derived state that automatically recalculates when its dependencies change. FLIN does not have this yet -- derived values must be recalculated explicitly. This is a planned feature for a future version.

No component-level reactivity. FLIN applications are single-file. There is no component system with isolated reactive scopes. All state is global. For the applications FLIN targets (small-to-medium web apps built by solo developers or small teams), global state is sufficient. A component system would add complexity without proportional benefit.

---

Performance Characteristics

The reactivity system's performance is dominated by three factors:

1. Number of bindings: _flinUpdate iterates over all [data-flin-bind] elements. For 100 bindings, this takes about 0.5ms. For 1,000 bindings, about 5ms.

2. Expression complexity: Each binding's expression is evaluated with eval(). Simple expressions (count, user.name) are fast. Complex expressions with function calls or string concatenation are slower.

3. DOM write frequency: The textContent !== String(value) check prevents unnecessary DOM writes, but the comparison itself has a cost. Batching with requestAnimationFrame ensures at most 60 updates per second.

For FLIN's target use case -- applications with tens to hundreds of reactive bindings, updated in response to user interactions or server events -- the system is fast enough that the user never perceives a delay. The update completes well within the 16ms frame budget required for 60fps rendering.

---

How It Compares

FLIN's reactivity system occupies a specific point in the design space. It is useful to compare it with the major alternatives.

React's virtual DOM reconciles the entire component tree on every state change. The virtual DOM diff algorithm is O(n) in the number of elements, with heuristics to skip unchanged subtrees. The advantage is generality: React handles any DOM structure and any update pattern. The disadvantage is overhead: the virtual DOM itself consumes memory, and the diff algorithm runs even when only one text node changed. FLIN's data-flin-bind approach is more targeted: it only touches annotated elements, ignoring the rest of the DOM entirely.

Svelte's compile-time reactivity analyses variable dependencies during compilation and generates targeted DOM update code. When count changes, Svelte calls a generated function that updates only the DOM nodes that reference count. This is the gold standard for performance. FLIN does not yet have compile-time dependency analysis, but the architecture supports it: the compiler knows which variables each binding references, and could generate targeted update functions instead of the generic _flinUpdate loop.

SolidJS's fine-grained reactivity uses signals and computations to track dependencies at runtime with minimal overhead. SolidJS does not use a virtual DOM -- it updates DOM nodes directly, like FLIN. The difference is that SolidJS tracks dependencies automatically through the JavaScript execution context, while FLIN uses explicit data-flin-bind annotations.

FLIN's approach trades sophistication for simplicity. It is less performant than Svelte or SolidJS for large applications with thousands of bindings. But it is dramatically simpler to implement, debug, and understand -- and for FLIN's target applications, it is fast enough.

---

The Design Principle

The reactivity engine embodies FLIN's core design principle: make the common case automatic and the programmer invisible.

In React, the developer must call useState, destructure the state and setter, call the setter to trigger a re-render, and manage the dependency array of useEffect to avoid stale closures. In FLIN, the developer writes count = 0 and {count}. The reactivity system handles everything else.

This is not a philosophical preference. It is a practical decision rooted in FLIN's target audience: developers in Cote d'Ivoire and across West Africa who want to build web applications quickly, without mastering the intricacies of React hooks or Svelte's $: syntax. The reactivity system should be invisible because the developer has more important things to think about -- like the application they are building.

---

This is Part 28 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built a programming language from scratch.

Next up: [29] The First Browser Render -- the milestone moment when FLIN code compiled to HTML and appeared in Chrome for the first time.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles