Change a value and the interface changes with it. That sentence makes reactivity sound almost trivial, yet a runtime must answer several difficult questions to make it true: which code read the value, which derived values are now stale, when should effects run, and how can old subscriptions be removed without leaking memory?

The useful mental model is not “the framework watches every variable.” Reactivity is a graph maintained while ordinary code executes. Reactive sources are nodes, computations are nodes, and a read creates an edge between them. A write follows those edges to invalidate or schedule only the work that may have changed.

This article builds a compact TypeScript engine with the same core ideas used by production systems. It is intentionally smaller than Vue, Solid, MobX, or Angular signals, but it is large enough to expose the hard parts: effect stacks, dynamic dependencies, lazy computed values, schedulers, batching, cleanup, identity, and debugging.

Reactivity is a changing dependency graph

Suppose a render effect evaluates price.value * quantity.value. While that function runs, the runtime marks it as the active consumer. Each signal getter records an edge from the signal to that consumer. Later, assigning quantity.value = 3 finds the subscribers for quantity and schedules the render effect.

flowchart LR P[price signal] --> T[total computed] Q[quantity signal] --> T D[discount signal] --> T T --> R[render effect] T --> A[analytics effect] Q -. write .-> S[scheduler queue] S -. rerun .-> T S -. rerun .-> R

There are two directions of information flow:

  • Pull means a consumer asks for a value. Calling a computed getter pulls from its inputs. Lazy computed values can remain dirty without doing work until somebody reads them.
  • Push means a source announces that it changed. A signal setter pushes invalidation to subscribers. An eager effect may then run immediately or enter a scheduler queue.

Most practical systems combine both. Writes push a cheap “stale” notification; reads pull the fresh derived value. Pure push eagerly propagates values even when nobody needs them, while pure pull needs some external event to know when to render again.

Tracking can happen at different granularity. A proxy intercepts property access, so state.user.name can subscribe to the name key of a particular object. A signal wraps one value and makes reads explicit through .value or a function call. Proxies are ergonomic for object-shaped state; signals make ownership and dependency boundaries easier to see. Both still need the same subscriber graph.

::: info Reactivity tracks reads performed during a computation, not every value mentioned in source code. A value captured earlier, read asynchronously, or destructured outside the effect may never create the edge you expected. :::

Building a small TypeScript engine

The engine below has five layers. track records reads, trigger publishes writes, effect manages the active consumer, reactive instruments object properties, and signal instruments a single value. computed, the scheduler, and batch build on exactly those primitives.

ts
type Job = () => void;
type Dependency = Set<ReactiveEffect>;

interface ReactiveEffect {
  readonly dependencies: Set<Dependency>;
  readonly run: Job;
  readonly stop: Job;
  active: boolean;
  running: boolean;
  scheduler?: (job: Job) => void;
}

interface EffectOptions {
  lazy?: boolean;
  scheduler?: (job: Job) => void;
}

const targetMap = new WeakMap<object, Map<PropertyKey, Dependency>>();
const proxyCache = new WeakMap<object, object>();
const effectStack: ReactiveEffect[] = [];
let activeEffect: ReactiveEffect | undefined;

function cleanupEffect(effect: ReactiveEffect): void {
  for (const dependency of effect.dependencies) dependency.delete(effect);
  effect.dependencies.clear();
}

function track(target: object, key: PropertyKey): void {
  if (!activeEffect || !activeEffect.active) return;

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

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

  if (!dependency.has(activeEffect)) {
    dependency.add(activeEffect);
    activeEffect.dependencies.add(dependency);
  }
}

function trigger(target: object, key: PropertyKey): void {
  const dependency = targetMap.get(target)?.get(key);
  if (!dependency) return;

  // Clone before iteration because rerunning changes subscription sets.
  for (const effect of new Set(dependency)) {
    if (effect === activeEffect) continue;
    if (effect.scheduler) effect.scheduler(effect.run);
    else effect.run();
  }
}

function effect(fn: Job, options: EffectOptions = {}): ReactiveEffect {
  const reactiveEffect: ReactiveEffect = {
    active: true,
    running: false,
    dependencies: new Set(),
    scheduler: options.scheduler,
    run: () => {
      if (!reactiveEffect.active) {
        fn();
        return;
      }
      if (reactiveEffect.running) return;

      cleanupEffect(reactiveEffect);
      try {
        reactiveEffect.running = true;
        effectStack.push(reactiveEffect);
        activeEffect = reactiveEffect;
        fn();
      } finally {
        effectStack.pop();
        activeEffect = effectStack.at(-1);
        reactiveEffect.running = false;
      }
    },
    stop: () => {
      if (!reactiveEffect.active) return;
      cleanupEffect(reactiveEffect);
      reactiveEffect.active = false;
    },
  };

  if (!options.lazy) reactiveEffect.run();
  return reactiveEffect;
}

function reactive<T extends object>(target: T): T {
  const cached = proxyCache.get(target);
  if (cached) return cached as T;

  const proxy = new Proxy(target, {
    get(source, key, receiver) {
      const value = Reflect.get(source, key, receiver);
      track(source, key);
      return typeof value === 'object' && value !== null
        ? reactive(value)
        : value;
    },
    set(source, key, value, receiver) {
      const previous = Reflect.get(source, key, receiver);
      const changed = !Object.is(previous, value);
      const written = Reflect.set(source, key, value, receiver);
      if (written && changed) trigger(source, key);
      return written;
    },
  });

  proxyCache.set(target, proxy);
  return proxy;
}

interface Signal<T> {
  value: T;
}

function signal<T>(initialValue: T): Signal<T> {
  let value = initialValue;
  const holder = {};

  return {
    get value() {
      track(holder, 'value');
      return value;
    },
    set value(nextValue: T) {
      if (Object.is(value, nextValue)) return;
      value = nextValue;
      trigger(holder, 'value');
    },
  };
}

const queuedJobs = new Set<Job>();
let flushPending = false;
let batchDepth = 0;

function scheduleFlush(): void {
  if (flushPending || batchDepth > 0) return;
  flushPending = true;
  queueMicrotask(() => {
    try {
      while (queuedJobs.size > 0) {
        const jobs = [...queuedJobs];
        queuedJobs.clear();
        for (const job of jobs) job();
      }
    } finally {
      flushPending = false;
    }
  });
}

function queueJob(job: Job): void {
  queuedJobs.add(job);
  scheduleFlush();
}

function batch<T>(update: () => T): T {
  batchDepth += 1;
  try {
    return update();
  } finally {
    batchDepth -= 1;
    if (batchDepth === 0 && queuedJobs.size > 0) scheduleFlush();
  }
}

function computed<T>(getValue: () => T): Signal<T> {
  let cachedValue: T;
  let dirty = true;
  const holder = {};

  const computation = effect(
    () => {
      cachedValue = getValue();
      dirty = false;
    },
    {
      lazy: true,
      scheduler: () => {
        if (!dirty) {
          dirty = true;
          trigger(holder, 'value');
        }
      },
    },
  );

  return {
    get value() {
      track(holder, 'value');
      if (dirty) computation.run();
      return cachedValue;
    },
    set value(_nextValue: T) {
      throw new TypeError('computed values are read-only');
    },
  };
}

The WeakMap is important: it lets an unreachable raw target disappear without the dependency index itself keeping it alive. The inner map separates subscriptions by property. The cloned dependency set in trigger is equally important because an effect removes and re-adds itself while running; iterating the live set could repeat or skip work.

The stack handles nested computations. A render may read a computed value, which runs its own internal effect. When that inner effect finishes, the outer render must become active again so later reads still belong to it. A single global variable without a stack loses that context.

Computed values, scheduling, and glitch-free batches

A computed value is not merely an effect that writes another signal. It has three useful properties: it is lazy, it caches its last result, and a source change marks it dirty before notifying consumers. The next getter call pulls a fresh value. Multiple consumers therefore share one calculation until an input changes.

The scheduler separates what is invalid from when work runs. Source writes happen synchronously, but rendering usually should not. queueJob stores stable job identities in a Set, so five writes in one JavaScript turn cause one render in the next microtask:

ts
const cart = reactive({ price: 40, quantity: 1, member: false });
const total = computed(() => {
  const subtotal = cart.price * cart.quantity;
  return cart.member ? subtotal * 0.9 : subtotal;
});

const view = effect(
  () => console.log(`Total: ${total.value}`),
  { scheduler: queueJob },
);

batch(() => {
  cart.quantity = 3;
  cart.member = true;
});

// The scheduled view observes 108, never the intermediate 120.

An inconsistent intermediate observation is called a glitch. Imagine fullName depends on both firstName and lastName; updating them sequentially can expose a half-old, half-new name if effects run immediately. Batching delays outward effects until the transaction-like update finishes. It does not roll back errors or provide database isolation, but it establishes a stable observation boundary.

Ordering still matters. Computed nodes should become dirty before user effects consume them, and a production scheduler often has priority queues for computed invalidation, component rendering, and post-render callbacks. It also needs recursion limits, deterministic parent-before-child ordering, error isolation, and a policy for jobs added during a flush. The loop in this mini engine handles newly queued jobs, but deliberately leaves those larger policies out.

Warning

Making every effect asynchronous is not automatically correct. Validation, cache invalidation, and state-machine transitions may require synchronous semantics. Scheduling is part of an effect’s contract, not a universal optimization switch.

Dynamic dependencies and cleanup

Dependencies are conditional because program control flow is conditional. Consider an effect that reads companyCard only when account.kind === 'business'. On its first run it may subscribe to kind and personalCard; after the kind changes, it must unsubscribe from personalCard and subscribe to companyCard.

ts
const account = reactive({
  kind: 'personal' as 'personal' | 'business',
  personalCard: 'P-101',
  companyCard: 'C-900',
});

const cardEffect = effect(() => {
  const card = account.kind === 'business'
    ? account.companyCard
    : account.personalCard;
  console.log(card);
});

account.kind = 'business';
account.personalCard = 'P-202'; // No rerun: this edge was removed.
account.companyCard = 'C-901'; // Reruns: this is the current edge.
cardEffect.stop();

That is why every effect keeps a reverse list of dependency sets. Before each run, cleanupEffect removes all previous edges; execution then discovers the current graph. This simple strategy does more set operations than generation-based marking used by highly optimized runtimes, but its behavior is easy to verify.

Cleanup has two meanings that should not be confused. Dependency cleanup removes graph edges, preventing stale triggers and retained effects. Side-effect cleanup disposes resources created by the user’s callback: event listeners, timers, subscriptions, observers, or in-flight requests. A production effect API usually accepts an onCleanup hook and invokes registered disposers before rerunning and when stopped.

Stopping matters for memory. targetMap uses weak target keys, but every live dependency set strongly references its effects, and effect closures strongly reference whatever they capture. A component that disappears must stop its render effect and watchers. Weak maps do not rescue a live target whose subscriber still retains a large component tree through a closure.

Asynchronous reads form another boundary. After await, the original synchronous effect has finished and is no longer active. A signal first read after that point will not be tracked by this engine. Read reactive inputs before awaiting, split the operation into a tracked phase and an async phase, or use a framework API with explicit async dependency semantics.

APIs, pitfalls, debugging, and performance

The core mechanism is small; API choices determine how predictable it feels in an application.

Model Dependency granularity Strength Common cost or pitfall
Dirty checking Expression or component pass Simple writes; no instrumentation Rechecks unrelated work
Proxy object Object property Natural object syntax; deep lazy wrapping Identity and destructuring surprises
Signal One explicit cell Precise edges; easy ownership .value/call syntax and many wrappers
Immutable store + selector Store snapshot and selector Time travel and clear update history Allocation plus selector discipline
Observable stream Event sequence over time Async composition and cancellation Different semantics from current state

Proxy identity deserves special care. reactive(raw) !== raw, so using raw and proxied objects interchangeably as Map keys breaks lookups. The cache ensures repeated reactive(raw) calls return the same proxy, but it cannot make raw and proxy identical. Normalize at API boundaries, avoid leaking raw objects, and expose an escape hatch only when interoperability requires it.

Destructuring can silently disconnect tracking:

ts
const state = reactive({ count: 0 });
const { count } = state; // Reads once and stores a plain number.
effect(() => console.log(count)); // Has no reactive read to track.

const countSignal = computed(() => state.count);
effect(() => console.log(countSignal.value)); // Remains connected.

Methods with private fields, DOM objects, dates, maps, sets, and third-party class instances may reject a proxy receiver or require specialized traps. Production libraries maintain skip lists and dedicated collection handlers. Arrays also need care: writing an index can affect length, and iteration depends on keys being added or deleted, not only existing values being replaced.

Debugging improves dramatically when the graph is observable. Useful development hooks include onTrack(effect, target, key) and onTrigger(effect, target, key), effect names, queue timestamps, run counts, and duration measurements. When an effect reruns unexpectedly, ask which key triggered it and where that key was read. When it does not rerun, confirm that the read occurred synchronously while the intended effect was active.

For performance, optimize graph shape before clever internals. Keep effects focused, avoid reading broad objects during rendering, split frequently changing state from stable state, and use computed values to share expensive derivations. A dependency with 10,000 subscribers makes every write expensive; an effect that reads 10,000 keys makes every rerun expensive. Measure subscriber fan-out, cleanup churn, queue length, duplicate invalidations, and effect duration.

Do not deep-proxy data that is treated as opaque, such as a huge immutable response or editor document. A shallow signal around an immutable snapshot may be cheaper. Conversely, replacing an entire tree for one field can invalidate more selectors than a property-level proxy. Granularity is a workload decision, not an ideology.

Tip

The best performance tool is often an ownership boundary: state should live as close as practical to the effects that consume it, and effects should be stopped with that owner.

Takeaways

  • Reactivity is a runtime dependency graph built from reads and traversed by writes.
  • Push invalidation and pull evaluation complement each other; computed values combine both.
  • Effects need a stack, reverse dependency links, and cleanup to support nested and conditional code.
  • Schedulers control timing, while deduplication and batching prevent redundant work and observable glitches.
  • Weak target maps help, but explicit stop and user-resource cleanup are still required.
  • Proxies and signals share core mechanics but expose different identity, syntax, and granularity tradeoffs.
  • Instrument tracking, triggering, queueing, and effect duration before optimizing the engine or application.

Once these pieces are visible, framework reactivity stops looking like magic. It becomes a disciplined protocol: observe reads, record edges, invalidate on writes, run work at a defined time, and erase every edge or resource that is no longer owned.