DEV Community

Cover image for Making the Graph Actually Move: Implementing effect (Part I)
Luciano0322
Luciano0322

Posted on

Making the Graph Actually Move: Implementing effect (Part I)

Introduction

In the previous article, we finished a Signal core that supports subscriptions. In this one, we’ll implement Effect, so each dependency can be tracked automatically—turning a previously static graph into a reactive one that truly “moves.”

Goals of this article

  • createEffect(fn): Run fn inside a tracking scope and automatically collect dependency edges.
  • signal.set(): Notify dependent effects and re-run them once via microtask batching.
  • onCleanup(cb) and dispose(): Clean up before re-runs, and allow manual disposal (unsubscribe).

Core class relationships (with the Registry abstraction)

Let’s establish the role boundaries with this diagram:

  • Node: A single node model, distinguished by kind (signal / computed / effect).
  • EffectInstance: Owns an effect node and is responsible for run / schedule / dispose.
  • EffectRegistry: An abstraction interface. Both SymbolRegistry and WeakMapRegistry implement it (Part 2 will demonstrate switching).

Effect Registry

EffectInstance owns the node (Node). The registry is just a “lookup table” that lets us recover the instance from the node.

The createEffect construction flow

This diagram shows construction and the first execution:

  1. Construct an EffectInstance.
  2. Register “node → instance” into the registry.
  3. First run() executes within a tracking scope to collect dependencies (build the graph). Notification is not the focus yet.

Effect

Graph building comes before notification. The behavioral core of this article is: effects can re-run. Graph maintenance (collecting and detaching deps) is handled internally in run().

How a signal notifies an effect (via the registry)

This diagram walks through “signal update → re-run” end-to-end:

  1. signal.set() updates the value.
  2. Iterate subs (who subscribed to this signal).
  3. If the subscriber is an effect node, retrieve the corresponding instance from the registry and call schedule().

Effect flow chart

The registry is only a lookup. The real work happens inside EffectInstance.schedule(), which batches via microtasks and then calls run().


Recap: the base model

Single node + tracking utility

// Previous article model
// graph.ts
export type Kind = 'signal' | 'computed' | 'effect';

export interface Node {
  kind: Kind;
  deps: Set<Node>; // who I depend on (effect / computed)
  subs: Set<Node>; // who depends on me (signal / computed)
}

export function link(from: Node, to: Node) {
  if (from.kind === 'signal') throw new Error('Signal nodes cannot depend on others');
  from.deps.add(to);
  to.subs.add(from);
}

export function unlink(from: Node, to: Node) {
  from.deps.delete(to);
  to.subs.delete(from);
}

// Tracking: reads under an observer context automatically create an edge Observer -> Trackable
let currentObserver: Node | null = null;

export function withObserver<T>(obs: Node, fn: () => T): T {
  const prev = currentObserver;
  currentObserver = obs;
  try {
    return fn();
  } finally {
    currentObserver = prev;
  }
}

export function track(dep: Node) {
  if (!currentObserver) return;
  link(currentObserver, dep);
}
Enter fullscreen mode Exit fullscreen mode

Effect Registry abstraction

In this article we’ll start with a Symbol-based registry (Part 2 will switch to a WeakMap-based version):

// registry.ts
import type { Node } from "./graph.js";

export interface EffectInstanceLike {
  schedule(): void;
}
export const EffectSlot: unique symbol = Symbol("EffectSlot");
export interface EffectCarrier {
  [EffectSlot]?: EffectInstanceLike;
}

export interface EffectRegistry {
  get(node: EffectCarrier): EffectInstanceLike | undefined;
  set(node: EffectCarrier, inst: EffectInstanceLike): void;
  delete(node: EffectCarrier): void;
}

export const SymbolRegistry: EffectRegistry = {
  get(node) {
    return node[EffectSlot];
  },
  set(node, inst) {
    Object.defineProperty(node, EffectSlot, {
      value: inst,
      enumerable: false,
      configurable: true
    });
  },
  delete(node) {
    Reflect.deleteProperty(node, EffectSlot);
  }
};
Enter fullscreen mode Exit fullscreen mode

The registry’s job is to maintain the Node → EffectInstance relationship, so signal.set() can find the effect to re-run in O(1) time.


Effect

Batch scheduling, re-running, and cleanup (depending on the registry abstraction, not a concrete implementation):

// effect.ts
import { unlink, withObserver, type Node } from "./graph.js";
import { SymbolRegistry, type EffectInstanceLike } from "./registry.js";

type Cleanup = () => void;

// Shared helper: execute cleanups in LIFO order and ensure the list is cleared
function drainCleanups(list: Cleanup[], onError?: (err: unknown) => void) {
  // LIFO: run from tail to head
  for (let i = list.length - 1; i >= 0; i--) {
    const cb = list[i];
    try {
      cb();
    } catch (e) {
      onError?.(e);
    }
  }
  list.length = 0;
}

// Microtask batching
const pending = new Set<EffectInstance>();
let scheduled = false;
function schedule(inst: EffectInstance) {
  if (inst.disposed) return;
  pending.add(inst);
  if (!scheduled) {
    scheduled = true;
    queueMicrotask(() => {
      scheduled = false;
      const list = Array.from(pending);
      pending.clear();
      for (const ef of list) ef.run();
    });
  }
}

let activeEffect: EffectInstance | null = null;
export function onCleanup(cb: Cleanup) {
  if (activeEffect) activeEffect.cleanups.push(cb);
}

export class EffectInstance implements EffectInstanceLike {
  node: Node = {
    kind: 'effect',
    deps: new Set(),
    subs: new Set()
  };
  cleanups: Cleanup[] = [];
  disposed = false;

  constructor(private fn: () => void | Cleanup) {
    SymbolRegistry.set(this.node, this); // Only touch the registry
  }

  run() {
    if (this.disposed) return;

    // 1) cleanup from last run
    drainCleanups(this.cleanups);

    // 2) detach old dependencies
    for (const dep of [...this.node.deps]) unlink(this.node, dep);

    // 3) run inside tracking scope to collect new deps; support returning a cleanup
    activeEffect = this;
    try {
      const ret = withObserver(this.node, this.fn);
      if (typeof ret === 'function') this.cleanups.push(ret);
    } finally {
      activeEffect = null;
    }
  }

  schedule() { schedule(this); }

  dispose() {
    if (this.disposed) return;
    this.disposed = true;

    drainCleanups(this.cleanups);
    for (const dep of [...this.node.deps]) unlink(this.node, dep);
    this.node.deps.clear();

    SymbolRegistry.delete(this.node); // Only touch the registry
  }
}

export function createEffect(fn: () => void | Cleanup) {
  const inst = new EffectInstance(fn);
  inst.run(); // Run once first to collect deps
  return () => inst.dispose();
}
Enter fullscreen mode Exit fullscreen mode

Signal

Notify dependent effects inside set():

// signal.ts
import { link, track, unlink, type Node } from "./graph.js";
import { SymbolRegistry } from "./registry.js";

type Comparator<T> = (a: T, b: T) => boolean;
const defaultEquals = Object.is;

export function signal<T>(initial: T, equals: Comparator<T> = defaultEquals) {
  const node: Node & { kind: 'signal'; value: T; equals: Comparator<T> } = {
    kind: 'signal',
    deps: new Set(), // guaranteed empty by link rules
    subs: new Set(),
    value: initial,
    equals,
  };

  const get = () => {
    track(node);
    return node.value;
  };

  const set = (next: T | ((p: T) => T)) => {
    const nxtVal = typeof next === 'function' ? (next as (p: T) => T)(node.value) : next;
    if (node.equals(node.value, nxtVal)) return;
    node.value = nxtVal;

    for (const sub of node.subs) {
      if (sub.kind === 'effect') SymbolRegistry.get(sub)?.schedule();
    }
  };

  const subscribe = (observer: Node) => {
    link(observer, node);
    return () => unlink(observer, node);
  };

  return { get, set, subscribe, peek: () => node.value };
}
Enter fullscreen mode Exit fullscreen mode

Example usage

import { signal } from './signal';
import { createEffect, onCleanup } from './effect';

const a = signal(1);
const b = signal(2);

const stop = createEffect(() => {
  console.log('sum =', a.get() + b.get());
  onCleanup(() => console.log('cleanup before rerun'));
});

a.set(10); // microtask: cleanup before rerun → sum = 12
b.set(20); // microtask: cleanup before rerun → sum = 30

stop(); // unsubscribe + cleanup
Enter fullscreen mode Exit fullscreen mode

run() flow diagram

run flow diagram


Conclusion

With the flow above, we’ve implemented a functional Effect core. There are still a few things worth calling out:

  • Snapshot trap: const v = a.get(); a.set(10); v is still the old value. If you need the latest value, call get() again.
  • Batched multiple sets: multiple set() calls in the same tick will re-run the effect only once (microtask batching).
  • Dependency drift: every re-run unlinks old deps first, then collects new deps via withObserver, preventing the subscription set from growing indefinitely.

I spent a lot of time reproducing the diagrams in this article to make it easier to understand. Once graphs are involved, there are many subtle details to get right.

In the next article, we’ll discuss internal design choices for Effect (Symbol vs WeakMap), review the fundamentals of WeakMap, and try an alternative implementation via WeakMapRegistry.

Top comments (0)