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();
}
});
The problem
Say I want to export a function that adds one to a counter. I might do so like this:
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).
Boom! By calling
addOneToCounter(), we've both subscribed tocounterand updatedcounterat 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 anuntrack()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 theeffect()/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 intouse(), 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 thisusefunction around as needed, so anyone who needs to participate in tracking can do so.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 andcounter.use()lets you see the value and start tracking it if you're inside a computed/effect call.