DEV Community

Joshua Amaju
Joshua Amaju

Posted on

Effection Scope: Resource Lifetimes and Cleanup

Effection Scope

In JavaScript, values and variables belong to a scope. When a function runs, its variables are automatically cleaned up once execution exits that scope. Effection takes this idea and applies it to concurrent operations, giving you control over how asynchronous work is scoped and cleaned up.


Problem

We want to double the value by the value provided to any previous call to setter.

function* setter(value: number) {
  let current = (yield* Provider.get()) ?? 0;
  const newValue = current + value;

  yield* Provider.set(newValue);

  try {
    return newValue;
  } finally {
    // clean up here because we don't want any sibling call to setter
    // to see the value of this scope instead of the parent.
    yield* Provider.set(current);
  }
}

main(function* () {
  const value = yield* setter(1);

  yield* (function* () {
    const value = yield* setter(2);

    yield* (function* () {
      const value = yield* setter(3);
      console.log("inner child", value);
    })();

    console.log("child", value);
  })();

  console.log("parent", value);
});
Enter fullscreen mode Exit fullscreen mode

Output:

inner child 3
child 2
parent 1
Enter fullscreen mode Exit fullscreen mode

Instead of:

inner child 6
child 3
parent 1
Enter fullscreen mode Exit fullscreen mode

Why?

Because the cleanup happens immediately when we return, which means the current value is restored before any child operation can observe it.


First Attempt: Using a Resource

What we actually want is for setter to not immediately exit upon return. This is where an Effection resource comes in.

We can rewrite setter like this:

function setter(value: number) {
  return resource<number>(function* (provide) {
    let current = (yield* Provider.get()) ?? 0;
    const newValue = current + value;

    yield* Provider.set(newValue);

    try {
      yield* provide(newValue);
    } finally {
      yield* Provider.set(current);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Our setter no longer returns directly. Instead, it lifts its lifetime into the surrounding operation.

So what does the program now output?

Still:

inner child 3
child 2
parent 1
Enter fullscreen mode Exit fullscreen mode

Why This Still Doesn’t Work

At this point, it’s reasonable to ask: why isn’t setter seeing the values from the parent and child scopes?

The key detail is that functions like resource, spawn, and similar helpers create their own scope that exists for the lifetime of the closed-over operation.

Each call to setter therefore gets its own scope, and those scopes are siblings, not nested according to call order.

So instead of this:

- parent
  └─ child
     └─ inner child
Enter fullscreen mode Exit fullscreen mode

We actually get this:

- parent
- child
- inner child
Enter fullscreen mode Exit fullscreen mode

This explains why the values do not accumulate as expected.


Explicitly Passing the Parent Scope

One way to solve this is to explicitly pass the desired parent scope into setter:

function setter(value: number, scope: Scope) {
  return resource<number>(function* (provide) {
    let current = (yield* Provider.get()) ?? 0;
    const newValue = current + value;

    scope.set(Provider, newValue);

    try {
      yield* provide(newValue);
    } finally {
      scope.set(Provider, current);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

The program now outputs:

inner child 6
child 3
parent 1
Enter fullscreen mode Exit fullscreen mode

This works, but threading scopes around like this is tedious and can easily become error-prone in more complex code.


A Cleaner Solution

A cleaner approach is to capture the scope before creating the resource:

function* setter(value: number) {
  const scope = yield* useScope();
  const current = (yield* Provider.expect()) ?? 0;

  const val = value + current;
  scope.set(Provider, val);

  return yield* resource<number>(function* (provide) {
    try {
      yield* provide(val);
    } finally {
      scope.set(Provider, current);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Here, we explicitly capture the scope using useScope() and then create the resource within that scope. By yielding the resource directly, we also avoid ending up with an Operation<Operation<number>>.

This preserves the intended parent-child relationship between calls while keeping the code simpler and easier to reason about.

Top comments (0)