DEV Community

Cover image for Internal Design Choices for Effects: Implementing Effects (II)
Luciano0322
Luciano0322

Posted on

Internal Design Choices for Effects: Implementing Effects (II)

Quick Recap

Do you remember this diagram?

In the previous article, we introduced the Registry abstraction, which gives us the flexibility to choose different underlying data structures for Effect scheduling.

One key detail from Part I: schedule() does not run effects immediately—it enqueues them. We flush the queue in a microtask so multiple set() calls in the same tick collapse into a single re-run. This avoids re-entrancy/cascading runs during synchronous writes and makes effect execution more predictable.

At this point, you might be wondering:

Why do we even need a Registry?


Why a Registry Is Necessary

When signal.set() is called, we need to go from an effect node back to its corresponding EffectInstance so that we can invoke schedule().

To achieve this without polluting the public API and while keeping O(1) lookup, we introduced the EffectRegistry abstraction in the previous article:

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

export interface EffectInstanceLike { schedule(): void }

export interface EffectRegistry {
  get(node: Node): EffectInstanceLike | undefined;
  set(node: Node, inst: EffectInstanceLike): void;
  delete(node: Node): void;
}
Enter fullscreen mode Exit fullscreen mode

Callers (both effect and signal) only interact with get / set / delete.
They do not care whether the underlying implementation uses a Symbol, a WeakMap, or something else entirely.


Understanding WeakMap

A Simple Comparison

  • Map
    • Iterable
    • Has .size
    • Keys are strongly referenced (you must delete entries manually)
  • WeakMap
    • Not iterable
    • No .size
    • Keys are weakly referenced (entries may be removed automatically by GC)

Side-by-Side Comparison

Aspect Map WeakMap
Key types Any (including primitives) Objects only (Function / Array / DOM / your Node)
Iterable keys / values / entries / for..of ❌ Not iterable
.size ✅ Yes ❌ No
GC behavior Strong reference: entry stays until deleted Weak reference: entry can be GC’d
Typical use Enumeration, sorting, stats, LRU Object → side data (cache, state, executors)
Risk Forgetting delete → memory growth Cannot inspect or count entries

Intuition:
WeakMap is ideal for attaching side data to external objects—without modifying their public structure and without preventing garbage collection.

This matches our exact use case:

Node (effect node) → EffectInstance (executor)


A Quick Example

const wm = new WeakMap();

const o1 = { firstName: "John" };
const o2 = { lastName: "Wick" };
const o3 = { nickName: "papayaga" };

wm.set(o1, o2);
wm.set(o2, o1);

wm.get(o1); // O(1) → { lastName: "Wick" }
wm.get(o2); // O(1) → { firstName: "John" }
wm.get(o3); // undefined

wm.has(o1); // true
wm.has(o2); // true
wm.has(o3); // false

wm.delete(o1);

wm.get(o1); // undefined
wm.get(o2); // O(1) → { firstName: "John" }
Enter fullscreen mode Exit fullscreen mode

Key takeaways:

  • Only set / get / has / delete
  • No iteration
  • No .size

You cannot use WeakMap to list all entries (e.g. for DevTools).
If you need that, maintain a separate list or use Map in development builds only.


Common Pitfalls

  1. WeakMap does not clean up your strong references

    • If anywhere still holds a strong reference to the key or value, GC will not reclaim it.
    • You should still call delete during dispose().
  2. Key equality is by reference

    • You must use the same object instance.
    • Re-creating an identical object does not work.

Choosing an Implementation

In our implementation, these are simply two different backends for the same EffectRegistry interface.

Switching requires only changing an import.
The call sites remain unchanged.


Two Registry Implementations

SymbolRegistry (same approach as the previous article)

// registry.ts
export const EffectSlot: unique symbol = Symbol('EffectSlot');

type EffectCarrier = {
  [EffectSlot]?: EffectInstanceLike;
};

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

WeakMapRegistry

// registry.ts
const table = new WeakMap<Node, EffectInstanceLike>();

export const WeakMapRegistry: EffectRegistry = {
  get: (n) => table.get(n),
  set: (n, i) => {
    table.set(n, i);
  },
  delete: (n) => {
    table.delete(n);
  },
};
Enter fullscreen mode Exit fullscreen mode

One-Line Switch at the Call Site

// effect.ts & signal.ts
import { SymbolRegistry } from './registry';
// or
import { WeakMapRegistry } from './registry';
Enter fullscreen mode Exit fullscreen mode

WeakMap vs Symbol: A Comparison

Aspect SymbolRegistry WeakMapRegistry
Mental overhead Low: a private non-enumerable slot Medium: requires understanding weak references
Modifies Node ✅ Adds a private Symbol slot (invisible externally) ❌ Completely external
Iterable / .size Not iterable (private slot) Not iterable, no .size
GC behavior Tied to node; must delete on dispose() Weak key; GC-friendly, but delete still recommended
Call-site typing Clean (EffectRegistry always takes Node) Same
Common risk Accidentally using different Symbol instances Expecting iteration (impossible); lingering strong refs

So… Which One Should You Use?

If you already understand WeakMap, just use it.
It is the more intuitive choice here.

That said, WeakMap is rarely encountered in day-to-day JavaScript.
I have even seen interviewers who did not know what Map was.

For that reason, I chose to teach using the Symbol-based approach first, and this article mainly serves as an add-on for experienced engineers.


A Note on Using Arrays

Earlier examples sometimes used plain arrays for simplicity.
In real implementations, avoid arrays for graph-based problems.

This is a Graph, not a list:

  • Map is almost always a better fit than arrays.
  • Arrays impose lookup costs.
  • You can work around this—but it requires extra bookkeeping.

Since the Registry abstraction already exists, swapping implementations later is trivial.


Conclusion

At this point, we now have:

  • Graph construction withObserver automatically builds dependency edges
  • Notification signal.set() triggers SymbolRegistry.get(sub)?.schedule() or WeakMapRegistry.get(sub)?.schedule()
  • Lifecycle management onCleanup and dispose()

In the next article, we will implement computed, completing the core mechanics of our signal system.

Top comments (4)

Collapse
 
harsh2644 profile image
Harsh

The reasoning behind using a continuation-passing style here could be elaborated; it helps understand the benefits for effect sequencing.

Collapse
 
luciano0322 profile image
Luciano0322 • Edited

Thanks! I covered the scheduling rationale in Part I (microtask batching for effect sequencing).
Article's link here

TL;DR: schedule() only enqueues the effect; the microtask flush collapses multiple set() calls into one run per tick, avoids re-entrancy/cascading runs during synchronous writes, and keeps effect execution predictable.
I’ll also add a short recap to Part II so readers don’t have to jump back.

Collapse
 
harsh2644 profile image
Harsh

"Ah, that makes so much sense now! 🙌 Thanks for the clarification—the microtask batching approach is really clever. So if I'm understanding correctly, this basically prevents the 'waterfall' effect where one set() triggers cascading runs? Definitely going to check out Part I for the full picture. Great stuff! 👏"

Thread Thread
 
luciano0322 profile image
Luciano0322

Exactly 👍 It prevents sync waterfall (nested runs during set()), by enqueueing and flushing once per microtask.
Note: cascades can still happen across ticks if effects call set() inside the flush.