DEV Community

Carlo Straccialini
Carlo Straccialini

Posted on

From Angular.js to Fine-Grained Reactivity: Part 2 — The JS Proxy Runtime

In the first article of this series, we saw how a custom build-time compiler can transform a legacy Angular.js template into raw, optimized JavaScript.

To recap, starting from this template:

<!-- simple.html -->
<p>Hello {{ name }}!</p>
Enter fullscreen mode Exit fullscreen mode

Our Go compiler generates the following JavaScript module:

// simple.js
export function template() {
    const p_0 = document.createElement("p");
    const text_1 = document.createTextNode("");
    p_0.append(text_1);

    return {
        mount(container) {
            container.append(p_0);
        },
        update(change) {
            if ("name" in change) {
                text_1.data = "Hello " + change.name + "!";
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is incredibly clean. By running template(), we get an object with mount and update methods.

Using mount is fully intuitive: we pass a reference to a DOM element, and it injects our empty paragraph (p_0) into it:

import { template } from './simple.js';

const { mount, update } = template();
const container = document.getElementById('view-container');

mount(container); 
// The DOM now contains: <p></p> (waiting for data)
Enter fullscreen mode Exit fullscreen mode

However, the paragraph remains empty until we call update with a change object like this:

let changes = {
    name: "Mario",
};

update(changes);
// The DOM surgically updates to: <p>Hello Mario!</p>
Enter fullscreen mode Exit fullscreen mode

But who is responsible for tracking changes in our application state, building this changes object, and calling update?

The answer lies in marrying the legacy Angular.js $scope with the modern JavaScript Proxy API.

The Legacy State Pattern

In a traditional Angular.js application, developers mutate the state directly inside a controller by assigning properties to the $scope object:

// simple-controller.js
export function SimpleController($scope) {
    $scope.name = "Mario";
}
Enter fullscreen mode Exit fullscreen mode

To bridge the gap between this legacy controller and our new build-time template, we need a way to automatically capture the assignment $scope.name = "Mario" and translate it into a structured update:

let changes = {
    name: "Mario"
};
Enter fullscreen mode Exit fullscreen mode

Instead of running a heavy runtime digest cycle to dirty-check the entire scope, we can intercept these mutations at the exact moment they happen. This is where Proxies shine.

How the JavaScript Proxy API Saves the Day

The Proxy object allows us to wrap a target object and intercept fundamental operations, such as property lookups, assignments, and function invocations.

By wrapping our $scope in a Proxy before passing it to the controller, we can execute custom code whenever a property is set.

Let's look at how we can implement a basic set trap:

import { SimpleController } from './simple-controller.js';

// Define a handler with a "set" trap
const handler = {
    set(target, prop, value) {
        console.log(`Property "${prop}" changed to: ${value}`);

        // Actually set the value on the target object
        target[prop] = value;

        // The set trap must return true in strict mode
        return true; 
    }
};

// Wrap an empty object with our Proxy handler
const $scope = new Proxy({}, handler);

// Run the legacy controller with our reactive scope
SimpleController($scope); 
// Console logs: Property "name" changed to: Mario
Enter fullscreen mode Exit fullscreen mode

Every time the controller executes $scope.name = "Mario", our Proxy intercepts the assignment. We now have a lightweight, non-invasive mechanism to capture state mutations in real time.

Putting It All Together: The Runtime Connection

Now we can connect our Proxy-based $scope directly to the update method generated by our compiler.

Here is the complete runtime implementation:

import { SimpleController } from './simple-controller.js';
import { template } from './simple.js';

// 1. Initialize the compiled template and mount it
const { mount, update } = template();
const container = document.getElementById('view-container');
mount(container);

// 2. Create the reactive $scope using a Proxy
const $scope = new Proxy({}, {
    set(target, prop, value) {
        // Intercept mutation, build the change object, and trigger the DOM update
        update({ [prop]: value });

        // Propagate the change to the underlying object using Reflect
        return Reflect.set(target, prop, value);
    }
});

// 3. Execute the controller to trigger the initial render
SimpleController($scope);
Enter fullscreen mode Exit fullscreen mode

The result:

  1. The controller runs and executes $scope.name = "Mario".

  2. The Proxy intercepts the write and immediately triggers update({ name: "Mario" }).

  3. The compiled update function surgically targets the TextNode and updates the text to "Hello Mario!".

  4. Zero dirty-checking, zero Virtual DOM, zero external dependencies.

What’s Next?

While this reactive loop is extremely elegant, real-world enterprise applications are rarely this simple.

What happens when a controller updates multiple properties in a row? In our current basic implementation, changing three properties sequentially would trigger three immediate, synchronous DOM repaints. To prevent layout thrashing, we need to implement a batching mechanism to queue updates and flush them once per frame.

Furthermore, how do we handle nested objects, arrays, and dependency tracking (Signals)?

In the next part of this series, we will explore how we scaled this runtime architecture to handle production-grade state management. Stay tuned!


Thanks for reading! I’m a Frontend Architect passionate about compilers, reactivity, and performance. Let's connect on LinkedIn to stay updated with the next parts of this journey.

Top comments (0)