Skip to content

Retrieving signal values can result in leaky abstractions. #273

@theScottyJam

Description

@theScottyJam

The problem

Say I want to export a function that adds one to a counter. I might do so like this:

const counter = new Signal.State(0);

export function addOneToCounter() {
  counter.set(counter() + 1);
}

...

This works, but there's a problem. I'm inadvertently leaking implementation detail - The consumer of this file might not even know or care that I'm using a signal under the hood, and yet, if they happened to call my function at the wrong time (i.e. inside an effect), bad things will happen. (I know effect() isn't provided by this proposal, so we'll just have to pretend I'm using an effect implementation from a framework that builds on top of the signals).

import { addOneToCounter } from './counter.js';

const selectedDate = new Signal.State(Date.now());

...

effect(() => {
  // If the date changes
  if (selectedDate()) {
    // increment the counter.
    addOneToCounter();
  }
});

Boom! By calling addOneToCounter(), we've both subscribed to counter and updated counter at the same time, sending us into an infinite loop, or perhaps an early error? Certainly, whatever it ends up doing won't be what the user was trying to make it do.

This sort of thing isn't theoretical - I've actually ran into it in Angular. I was using an instance of a third-party component library's class, and was trying to assign to one of its members inside of an effect() call. Turns out it was a setter function that must have been updating an internal signal, and at the time, Angular didn't allow you to update signals inside of effect calls without providing a special opt-in argument. I ended up using an untrack() call instead to avoid subscribing to whatever internal signals they were using. All of this felt like an accidental leakage of internals.

Of course, this library author could have wrapped their code in untrack() to fix the issue, and I could do the same in my above example as well, but in practice, from what I can tell, people don't tend to do this. untrack() is used more often at the place where the effect()/computed() call happens, not inside the API side. Plus, untrack() is somewhat clunky to use for this purpose.

Potential solutions

The root problem is that the default way to get a value out of a signal also automatically causes the function to leak implementation detail (i.e. what signals it would track if used in a computed/effect block). It would be better if 1. you opt into tracking instead of opting out, and 2. one-off untracked retrieval of values wasn't so clunky.

There's multiple ways to accomplish this. Here's one approach:

The computed() function now provides a single argument, use. (effect() functions could do so as well). If you pass a signal into use(), it will automatically return the signal's value and start tracking it. You can still call a signal to get its value as well, but doing so does not automatically cause tracking to happen. You can, of course, pass this use function around as needed, so anyone who needs to participate in tracking can do so.

const counter = new Signal.State(0);

export function addOneToCounter() {
  // use() isn't used here, so calling addOneToCounter()
  // will never cause tracking to happen on `counter`.
  counter.set(counter() + 1);
}

---

effect(use => {
  // Check if the date has changed.
  // We did `use(selectedDate)` instead of `selectedDate()` to get the signal value,
  // which means we'll automatically track this signal.
  if (use(selectedDate)) {
    // increment the counter.
    addOneToCounter();
  }
});

I like the explicitness of the above approach - I like how all functions (including helper functions) that are participating in tracking are required to receive a "use" function as an argument, making the tracking behavior part of the function signature instead of a detail hidden inside the function.

But, that's not the only way to solve the problem. You could also provide two different functions on the signal object that you can use to decide if you just want to read a value or read+track. So, in this picture, signals wouldn't be callable anymore, but counter.get() can be used to see the value without tracking and counter.use() lets you see the value and start tracking it if you're inside a computed/effect call.

const counter = new Signal.State(0);

export function addOneToCounter() {
  // get() is used here, so calling addOneToCounter()
  // will never cause tracking to happen on `counter`.
  counter.set(counter.get() + 1);
}

---

effect(() => {
  // Check if the date has changed.
  // We did `selectedDate.use()` instead of `selectedDate.get()` to get the signal value,
  // which means we'll automatically track this signal.
  if (selectedDate.use()) {
    // increment the counter.
    addOneToCounter();
  }
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions