tc39 / proposal-signals

A proposal to add signals to JavaScript.
MIT License
2.87k stars 54 forks source link

Use case for `Signal.subtle.{un,}watched` options for `Signal.Computed`? #172

Closed dead-claudia closed 1 month ago

dead-claudia commented 1 month ago

I'm struggling to come up with one. It also seems redundant, since you can just do this:

function computedWithOpts(body, opts) {
    const state = new Signal.State(undefined, opts)
    return new Signal.Computed(() => {
        state.get()
        return body()
    })
}
littledan commented 1 month ago

Do you mean, as opposed to just having it for state? I think @shaylew and @modderme123 had some thoughts about how it could relate to async signals.

dead-claudia commented 1 month ago

@littledan Yes, that was the idea.

shaylew commented 1 month ago

There are definitely async-related reasons to want these on States, but if there was an async-related reason we needed them on Computeds I'm blanking on that one. (It's possible @modderme123 had one?)

The case that comes to mind for me is for caching of computeds for derived state, where you want to get sharing where possible but don't want to leak computeds that could otherwise be collected. For example, if you have a map where equality comparison of values is potentially expensive, you might want something like this to share the equality comparison work by sharing the selector computeds:

// (I neither ran nor typechecked this code but I think it's coherent?)
function selectFrom<K, V>(map: Signal<Map<K, V>>, options: ComputedOptions = {}): (k: K) => Computed<V> {
  const table = new Map<K, Computed<V>>()
  return (k: K) => {
    return table.get(k) ?? new Signal.Computed(() => map.get().get(k), {
      ...options,
      [Signal.subtle.watched](): { table.set(k, this) },
      [Signal.subtle.unwatched](): { table.delete(k) }
    })
  }
}

There are likely other ways to accomplish this, and yeah the "use a dummy State" construction is sufficient in theory, it just may be unergonomic in practice. So this is more of a "seems like it's probably a good idea" argument than a "this is strictly necessary" one.

dead-claudia commented 1 month ago

@shaylew Wouldn't it be easier to just, in the parent Computed directly, do map.get().get(k)?

Like instead of this:

const getKey = selectFrom(someMap)

const selected = getKey("foo")

const useSelected = new Signal.Computed(() => {
    doThings(selected.get())
})

Just doing this:

const useSelected = new Signal.Computed(() => {
    const selected = someMap.get().get("foo")
    doThings(selected)
})
shaylew commented 1 month ago

@dead-claudia That makes useSelected rerun if the map changes but the value at the key in question doesn't, since it performs both reads directly.

dead-claudia commented 1 month ago

@shaylew Fair point. What about this?

const selected = new Signal.Computed(() => someMap.get().get("foo"))

const useSelected = new Signal.Computed(() => {
    doThings(selected.get())
})

Just trying to see the point of caching a computed here that's simple enough to where the perf difference is meaningful.

shaylew commented 1 month ago

Yeah, that's the same idea as where I was going and where Solid tries to go with createSelector (although theirs is a slightly different beast is an eager system with an ownership hierarchy).

In cases like these the value is actually all in memoizing/sharing the equals cutofff comparison, since the get is too fast to be worth memoizing but (depending on the contents and the custom equals) equality might not be so fast.

Stuff like this selectAt -- and, honestly, most uses of the watched/unwatched callbacks -- are more often used for exposing reactive models than used directly by views. So maybe your model has some big internal map structure, and you want to let people react to changes in whichever particular entries they're interested in: you don't know which keys they care about, and you don't know what the lifetimes of those interests are. You may want to make multiple reactors share selector computeds under the hood, without leaking computeds all over the place that nobody is interested in anymore... so you reach for something like this, and expose an API where your callers don't have to worry about "releasing" keys when they're done but you still get to share.

dead-claudia commented 1 month ago

@shaylew Ah, thank you!

I'll go ahead and close this since my question here was answered.