Stating the Problem

The Imperative Model

Let’s look at the simplest possible code:

let a = 6
let b = 7
let c = a + b     // c = 13

Question for the audience: what will c be after a changes?

a = 67

There’s no trick here, of course. c obviously stays 13, since it was only ever computed once. Here’s what happened, step by step:

  1. Declare variable a with value 6
  2. Declare variable b with value 7
  3. Declare variable c with value a + b
    1. Read a => 6
    2. Read b => 7
    3. Compute a + b => 6 + 7 => 13
  4. Change the value of a to 67

From an imperative-programming standpoint this is expected behavior: a was evaluated once and is no longer tied to the expression that produced it. But when building user interfaces we run into a fundamentally different problem: application state and its representation need to stay in sync. A data change should automatically propagate to whatever parts of the interface depend on that data.

This problem isn’t trivial. In complex applications the number of dependencies between data and UI elements can run into the thousands, and the dependency graph can be arbitrarily deep and arbitrarily shaped. Managing these links by hand leads to an avalanche of code complexity and to hard-to-catch state-inconsistency bugs.

This was one of the triggers behind the concept of reactivity.

The Reactive Model

| Reactivity — the ability of a software system to automatically keep dependent entities consistent whenever the underlying data changes.

A fairly formal definition. In essence, though, it means that instead of the familiar imperative pattern:

let a = 6
let b = 7
let c = a + b     // c = 13
let a = 67        // c = 13

we get behavior like this instead:

let a = 6
let b = 7
let c = a + b     // c = 13
let a = 67        // c = 74

Sure, with just two variables this doesn’t buy you much. But I trust it’s fairly obvious how much this responsiveness matters for a real application’s frontend.

A few key properties fall directly out of the definition:

  • Directionality. A reactive relationship between states is always one-directional. In other words, if a change to state AA reactively causes a change to state BB (let’s write that as A→BA \to B), it does not follow that B→AB \to A.
  • Transitivity. A→BA \to B and B→CB \to C   ⟹  \implies A→CA \to C.

Models of Reactive Systems

There are three basic models for building reactive systems.

The Pull Model

This is the most primitive reactivity model.

In the pull model, the dependent entity actively requests the current state of the source at specific points in time. The source of changes is passive — it only stores data and never initiates notifications.

Conceptually this can be implemented as a loop that periodically calls some boolean check() function, which returns true if the state has changed since last time.

Such a system’s efficiency is inversely proportional to how often it checks, and directly proportional to the number of variables being tracked.

The Push Model

In the push model, the source of changes actively notifies dependent entities the moment something changes. Consumers stay passive until they receive a notification — they only react once a change has actually happened.

The classic push-model implementation is the Observer pattern. A source (the subject) keeps a list of subscribers (observers) and broadcasts a notification to all of them whenever its own state changes.

The Push-Pull Model

In practice pure models are rare. Most modern systems use hybrid strategies that combine the advantages of both approaches.

Push-pull hybrids work like this:

  1. The notification stage uses push: the source announces that data has changed.
  2. Retrieving the actual value uses pull: the subscriber requests the new value itself.

This lets you avoid wasted checks (thanks to push notifications) on one hand, while not dictating to the consumer what shape the data should arrive in (thanks to the pull request) on the other.

Next, we’ll walk through concrete implementations of reactivity in a few modern frameworks.

The Dirty-Checking Mechanism

Historically, the first widely adopted framework to offer a declarative approach to building interfaces was AngularJS (2010). At the time, it solved a genuinely important problem: freeing developers from manually manipulating the DOM and syncing data by hand. The mechanism AngularJS used to achieve reactivity became known as “dirty checking.”

| Dirty checking — a change-detection algorithm based on cyclically comparing the current values of tracked expressions against their previous state. If a value has changed (become “dirty”), the corresponding handlers fire and the view updates.

Architecture in AngularJS

AngularJS revolves around a few central concepts:

  • Scope ($scope) — the context object that data and expressions are bound to.
  • Watch ($watch) — registers an expression for tracking.
  • Digest cycle ($digest) — walks every registered $watch and checks for changes.
  • Apply ($apply) — the mechanism for entering the AngularJS context from outside code.

When a binding to the view is created, AngularJS implicitly registers a $watch for the corresponding expression. As a result, every scope ends up with a list of observations ($$watchers). The $digest cycle iteratively walks all $$watchers and compares the results of evaluating each expression.

An important detail: the check runs at least twice. If the first pass finds changes and fires listeners that could have changed other tracked values, another pass runs. This continues until nothing changes anymore (stabilization) or a maximum iteration count is hit (usually 10).

A minimal implementation is enough to see how the mechanism works:

class Scope {
  constructor() {
    // Storage for every registered watcher
    this.$$watchers = [];
  }

  // Register a watch on an expression
  $watch(watchFn, listenerFn) {
    this.$$watchers.push({
      watchFn: watchFn,       // how to compute the current value
      listenerFn: listenerFn, // what to do when it changes
      lastValue: undefined    // last computed value
    });
  }

  // The main change-checking loop
  $digest() {
    // Flag tracking whether anything changed this pass
    // Start as true to guarantee at least one pass
    let dirty = true;

    // Guard against an infinite loop
    // Real AngularJS uses 10 here
    let loopCount = 0;
    const maxLoops = 10;

    while (dirty && loopCount < maxLoops) {
      dirty = false;

      for (const watcher of this.$$watchers) {
        const newValue = watcher.watchFn(this);
        const oldValue = watcher.lastValue;

        // If the values don't match, call the
        // corresponding listener.
        // Also flag another iteration.
        if (newValue !== oldValue) {
          watcher.listenerFn(newValue, oldValue, this);
          watcher.lastValue = newValue;
          dirty = true;
        }
      }

      loopCount++;
    }
  }
}

Real AngularJS code obviously has plenty of optimizations and edge cases on top of this — deep object comparison, tracking by reference vs. by collection — but the underlying logic stays exactly this.

Strengths and Weaknesses

The undeniable strength of dirty checking is how easy it is to reason about — the mechanism explains itself in one sentence and involves no metaprogramming. It’s also quite predictable: any data change will be caught, no matter how it happened.

The obvious downside is computational cost — the algorithm has O(n) asymptotic complexity per check cycle. Performance degrades linearly as the number of watchers grows.

On top of that, the mechanism runs plenty of wasted iterations — even if nothing changed, the check still runs, burning resources.

And, of course, the mechanism is simply unfit for particularly large applications — the AngularJS community settled on a rule of thumb of no more than 2000 watchers per page, since going beyond that produces noticeably laggy UI.

Where It Fits in the Reactivity Classification

Given the push/pull distinction introduced earlier, dirty checking is a pure pull model:

  • The data source is passive and never notifies anyone of a change;
  • The consumer (the $digest cycle) is active and polls every source itself;
  • A change is only detected at the moment of the check.

Interestingly, in AngularJS the $digest cycle is triggered by events that could plausibly have changed data (user input, server responses, timers). But inside the cycle, a full check of every watcher still runs regardless of which event actually fired. That’s the defining trait of a pull model — treating the event as a signal to go poll, rather than as a direct notification about a specific value changing.

The Observer Pattern

Dirty checking, which we just covered, solves the change-detection problem, but inefficiently — by actively polling every source. A natural question follows: can we build a system where the source itself notifies dependent entities the moment something changes? That’s exactly the problem the Observer pattern solves.

| Observer — a behavioral design pattern in which a subject keeps a list of objects that depend on it (observers) and automatically notifies them of any change to its state, typically by calling one of their methods.

In modern frontend development, the cleanest and most consistent implementation of this pattern is Solid’s signal mechanism. Solid doesn’t use a virtual DOM, doesn’t rely on dirty checking, and needs no special syntax — it’s a textbook Observer, implemented at the level of individual state cells.

| Signal — a reactivity primitive that stores a value and lets you subscribe to its changes.

In Observer-pattern terms:

  • Subject — the signal, holding the current value and its list of subscribers.
  • Observers — the effects, derived values, or components that depend on the given signal.
  • Notification method — when the value changes, the signal walks its subscribers and calls them.

Architecture in Solid

In Solid, signals are created with the createSignal function, which returns a two-element tuple: a getter and a setter.

import { createSignal } from 'solid-js';

const [state, setState] = createSignal(0);

It’s important to understand that count is not a property — it’s a getter (i.e. a function). Accessing the value means calling count().

Signal reactivity is achieved through side effects, for example:

import { createSignal, createEffect } from 'solid-js';

const [count, setCount] = createSignal(0);

createEffect(() => {
  console.log(`Current: ${count()}`);
});

setCount(1); // The signal notifies the effect, which runs again

How Signals Work Under the Hood

Let’s implement a minimal analog of a signal that demonstrates the Observer pattern directly:

The createSignal function holds a set of observers that can subscribe, read, and unsubscribe from the signal:

function createSignal(initialValue) {
  let value = initialValue;
  const observers = new Set();  // list of observers (effects)

  // Getter — reads the value and registers the current observer
  const read = () => {
    if (currentEffect) {
      observers.add(currentEffect);
    }
    return value;
  };

  // Setter — updates the value and notifies observers
  const write = (newValue) => {
    value = newValue;
    for (const observer of observers) {
      observer();
    }
  };

  return [read, write];
}

Effects work like this: a global slot tracks the relationship between the currently-running effect and the signal. createEffect updates that slot and calls the effect function:

let currentEffect = null;   // the effect currently running

function createEffect(fn) {
  const effect = () => {
    currentEffect = effect;
    fn();
    currentEffect = null;
  };

  effect();
}

The key advantage of this implementation is automatic subscription management. On every re-run of an effect:

  1. The effect runs.
  2. Every signal it reads adds it to their own list of observers.
  3. The old subscriptions (from the previous run of the effect) are dropped automatically, since the effect re-registers itself from scratch every time.

This solves a classic problem with the Observer pattern — having to manually unsubscribe from sources once an observer is no longer needed.

It’s worth noting that this only works correctly because of closures. For the full details, see this Github Gist.

Strengths and Weaknesses

The key strength here is, of course, granularity of updates — each signal only updates the effects that actually depend on it. As a result there are no wasted checks or cascading re-checks like with dirty data.

On top of that — in theory, cascading checks could theoretically leave some changes unreflected in the UI; that’s impossible in this implementation.

To be fair, this isn’t exactly the classic Observer described by the Gang of Four — but the core ideas are genuinely preserved.

That said, the main drawback of Observer from a large-application architecture standpoint is direct coupling between components. When you have many independent parts of a system that all need to react to events, keeping direct links between all of them becomes unwieldy.

The Publisher-Subscriber Pattern

As pointed out at the end of the previous section, Observer has a fairly serious architectural problem. A fairly elegant extension of the pattern helps reduce that coupling.

The Publisher-Subscriber pattern (PubSub) differs from Observer in one key way: it introduces a middleman — a message broker. Publishers and subscribers hold no direct references to each other; all interaction goes through a central component that accepts messages and delivers them to interested parties.

React’s implementation of this pattern lives in its internal update-scheduling machinery. We won’t dig too deep into the internals — from here on we’ll just call this mechanism the broker.

Architecture in React

Let’s look at React through the lens of communication patterns. When a developer calls setState (or dispatch inside useReducer), here’s what happens:

  1. Publishing a message. The setState call produces a “component state changed” event. The publisher (the component) doesn’t specify who should react to it.
  2. The broker — accepts every change message, queues it, deduplicates, prioritizes, and decides which components need to update and in what order.
  3. Delivery to subscribers. Components that need to re-render (subscribers) get notified through the rendering machinery. The subscriber component has no idea which publisher actually triggered the update.

In other words, React implements a PubSub architecture where the update queue acts as the central broker, and components can be publishers, subscribers, or both.

How React’s Update Queue Works

A simplified sketch is enough to understand how React processes updates:

class ReactScheduler {
  constructor() {
    this.updateQueue = [];
    this.pendingUpdates = new Set();
    this.isProcessing = false;
  }

  // Publish an event (e.g. when setState is called)
  scheduleUpdate(component) {
    // Deduplication
    if (!this.pendingUpdates.has(component)) {
      this.pendingUpdates.add(component);
      this.updateQueue.push(component);
    }

    // Process the queue asynchronously
    if (!this.isProcessing) {
      Promise.resolve().then(() => this.processQueue());
    }
  }

  // Queue handler that delivers updates
  processQueue() {
    this.isProcessing = true;

    // Copy the queue and clear it
    // so new updates can be published
    const componentsToUpdate = [...this.updateQueue];
    this.updateQueue = [];
    this.pendingUpdates.clear();

    // Deliver updates
    for (const component of componentsToUpdate) {
      component.render();
      // in reality React would also figure out which
      // child elements need updating
    }

    this.isProcessing = false;
  }
}

And in this implementation a component can be a publisher, a subscriber, or both:

class ReactComponent {
  constructor(scheduler) {
    this.scheduler = scheduler;
    this.state = {};
  }

  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.scheduler.scheduleUpdate(this);
  }

  render() {
    // render implementation
  }
}

What Makes React’s Broker Distinct

  1. Asynchrony and batching. One of the most important properties of the queue system is grouping multiple updates together. If you call setState several times inside the same synchronous block of code, React will publish only a single update event.
  2. Deduplication. React’s broker tracks which components are already queued for an update. If a component requests an update twice before the queue is processed, it’s only added once.
  3. Prioritization. Modern React (16 and up) uses the concept of update priorities. Urgent updates (like text input) get processed before background ones (like data loading). This is another broker capability that a direct Observer link-up simply can’t offer.
  4. No direct links. A component calling setState doesn’t know, and shouldn’t need to know, which other components will end up re-rendering as a result. That could be:
    • the component itself;
    • its child components (via props);
    • components consuming the same context.

Strengths and Weaknesses

Compared to Observer, the coupling here is clearly looser — components interact without holding direct references to each other. That makes the system arbitrarily scalable, and fairly easy to optimize at the broker level.

The obvious downside is overall complexity. It’s hard to imagine anything more complex, honestly. And because interaction is indirect, you get extra overhead from scheduling and queuing.

It’s also worth noting that a classic PubSub implementation typically keeps an explicit subscriber list per event type. React’s queue system has no such explicit subscription table. Instead it relies on the shape of the component tree and the props/context propagation mechanism. But, same as with Observer and Solid, this doesn’t really change the substance: communication stays mediated, centrally scheduled, with no direct reference between publisher and subscriber.

Intercepting Access: Proxy

Both Observer (Solid) and PubSub (React) require explicitly calling a function to change state: setCount(1) or setState({ count: 1 }). That creates a real cognitive and syntactic barrier — the developer has to remember to use a special API instead of JavaScript’s natural assignment.

The ideal solution, from a developer’s point of view, would be writing plain imperative code with ordinary assignments and getting reactive behavior for free. Going back to the example from stating the problem, we’d want something like this:

let a = 6
let b = 7
let c = a + b     // c = 13
let a = 67        // c = 74

The code above has no extra wrappers around signals or state — it simply extends the imperative model we already know.

So we need to intercept assignment behavior on a plain object and change it. That’s a metaprogramming problem, one that lets you run extra logic on property reads and writes.

The Proxy Mechanism

| Proxy — a mechanism built into JavaScript (since ES6) that lets you create a wrapper object which intercepts and redefines fundamental operations on an object: reading properties, writing, deleting, calling functions, and more.

In the context of reactivity, Proxy gives you two critical capabilities:

  • Intercepting writes (set) — lets you run extra code when a property is assigned a value.
  • Intercepting reads (get) — lets you track exactly which parts of the code read a given property (dependency collection).

These two capabilities are what Vue 3’s reactivity system is built on.

Worth noting: Proxy wasn’t used before version 3, since Vue 2 was built in the era of earlier ES specs. Instead of Proxy, it manipulated objects through Object.defineProperty — something we won’t get into in this lecture.

Let’s look at a minimal example of working with Proxy:

const original = { count: 0 };

const proxy = new Proxy(original, {
  // Intercept property reads
  get(target, property, receiver) {
    console.log(`Reading ${property}`)
    return Reflect.get(target, property, receiver);
  },

  // Intercept property writes
  set(target, property, value, receiver) {
    console.log(`Writing ${property} = ${value}`)
    return Reflect.set(target, property, value, receiver);
  }
});

proxy.count;      // Reading count
proxy.count = 5;  // Writing count = 5

A Quick Detour on Reflect

An especially attentive reader might ask: why can’t we use plain accessors like target[property] instead of Reflect.get/Reflect.set?

The answer is in the receiver argument. It defines the execution context. Using target[property] directly would lose the information that we’re operating not on the original object, but on the proxied one. receiver is exactly what lets us implement reactivity this way.

Architecture in Vue 3

In Vue 3, reactivity is built around the reactive function, which takes a plain object and returns its Proxy wrapper:

import { reactive } from 'vue';

const state = reactive({
  count: 0,
  user: {
    name: 'Ivan'
  }
});

state.count = 5;
state.user.name = 'Pyotr';

How reactive Works Under the Hood

A minimal version of reactive is enough to demonstrate the key mechanisms:

// Dependency storage: a weak map where
// the key   is the object,
// the value is a map of properties to sets of effects
const targetMap = new WeakMap();

// The effect currently running (for dependency collection)
let activeEffect = null;


// Registers an effect
function effect(fn) {
  activeEffect = fn;
  fn(); // Running it collects dependencies via get
  activeEffect = null;
}


// Tracks a dependency (called inside get)
function track(target, key) {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }

  dep.add(activeEffect);
}


// Fires effects on change (called inside set)
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}


// The main reactive function
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);

      // Collect the dependency
      track(target, key);

      // Automatic deep reactivity
      if (typeof result === 'object' && result !== null) {
        return reactive(result);
      }

      return result;
    },

    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);

      // If the value changed, notify dependencies
      if (oldValue !== value) {
        trigger(target, key);
      }

      return result;
    }
  });
}

// Usage example
const state = reactive({ count: 0 });

effect(() => {
  console.log(`Effect running: count = ${state.count}`);
  // Reading state.count triggers track and registers the effect
});

state.count = 5; // Triggers trigger and re-runs the effect

Implementation Details

It’s worth noting first that Vue uses two-way interception of state:

  • get — collects dependencies, remembering which effects read a given property;
  • set — on a value change, finds every dependency collected for that property and fires them.

Now, on to optimizations. Proxies are only created when a nested object is actually accessed. If a deep property is never read, it’s never wrapped in a Proxy, which saves memory. And using a WeakMap guarantees that once an object is no longer referenced and gets garbage-collected, its dependency records are automatically cleaned up too, preventing memory leaks.

Strengths and Weaknesses

So here we get natural, plain-assignment syntax for working with state. We get dynamism — new properties automatically become reactive state. The implementation needs no extra functions or methods to call.

One non-obvious pitfall: the integrity of the original object. Proxy only works through the wrapper object. If you keep a reference to the original object and mutate it directly, reactivity won’t fire.

Compile-Time Reactivity

Every system covered so far implements reactivity as a runtime mechanism: while the application is running, live data structures (signals with subscribers, a scheduler’s update queue, a Proxy with its dependency store) actively track changes and drive updates.

But there’s a fairly young (2019) framework in this family with a different approach entirely. Svelte proposes a fundamentally different paradigm: reactivity happens at compile time, not at runtime. The framework itself acts as a compiler, turning declarative code into imperative instructions that manipulate the DOM directly.

So instead of interpreting reactivity at runtime, Svelte analyzes the code at build time and generates optimal imperative code for every state update.

Architecture in Svelte

In Svelte, reactivity is triggered by the very act of assignment. The compiler analyzes the code, and wherever it sees an = operator, it inserts the code needed to update the dependent parts of the DOM.

Here’s a simple Svelte component:

<script>
  let count = 0;

  function increment() {
    count += 1;
  }
</script>

<button on:click={increment}>
  Clicks: {count}
</button>

At compile time, Svelte turns this into something close to the following:

function createComponent() {
  let count = 0;

  const button = document.createElement('button');
  const text = document.createTextNode('Clicks: ');
  const countNode = document.createTextNode(count);

  button.appendChild(text);
  button.appendChild(countNode);

  // UI update function
  function updateCount(newValue) {
    countNode.data = newValue;
  }

  // Click handler
  button.addEventListener('click', () => {
    count += 1;         // updated the state
    updateCount(count); // updated the view
  });

  return button;
}

In short, Svelte figures out which variables are used in the template, and on every assignment to those variables it inserts direct code to update the corresponding DOM nodes.

Reactive Declarations

Svelte offers special syntax for creating derived values that automatically recompute when their dependencies change:

<script>
  let width = 10;
  let height = 20;

  // Reactive declaration
  $: area = width * height;

  // Reactive block
  $: {
    console.log(`Dimensions changed: ${width} x ${height}`);
  }
</script>

<div>Area: {area}</div>

The $: label tells the compiler: “the following expression depends on the variables used inside it.” The compiler analyzes those dependencies and, on every assignment to the relevant variables, adds code to recompute and update.

Strengths and Weaknesses

It’s fairly obvious that handling reactive state at compile time reaches a level of optimization none of the previous approaches can match. Svelte ships a much smaller bundle and the app runs an order of magnitude faster.

One could point out a few surface-level drawbacks — awkward handling of object-shaped state, and so on. But almost all of them were addressed in Svelte 5 with the introduction of a backward-compatible “rune syntax.” In particular, it added $state() and $derived() runes, which extend an already-powerful system even further.

Interestingly, this approach doesn’t do away with Observer or PubSub — it just implements their semantics at compile time. Inside the generated code you’ll find direct calls to update functions, which can be read as an implementation of the Observer pattern, just without the shared runtime data structures.

Conclusion

We’ve traced the evolution of reactivity mechanisms from primitive cyclic checks to today’s compiled solutions. Each approach solved concrete problems with the one before it, while introducing its own trade-offs.

Every solution balances a few axes:

  • Performance — the overhead of tracking changes.
  • Granularity — how precisely the system can tell what actually needs updating.
  • Syntax — how natural the resulting code feels to write.
  • Scalability — how the system behaves as the application grows.
  • Bundle size — how much code has to ship to the user.

None of the solutions covered here is objectively the best — the right choice depends on what a project actually prioritizes.

And yet, despite the differences, reactivity keeps converging on a fairly small set of shared ideas, one way or another. Understanding these mechanisms is what turns us from mere framework users into developers who can build reliable, efficient applications on any technological foundation.