Dependency-tracking reactivity

What if the language had dependency-tracking reactivity?

For example, with some contrived syntax, in the following example, any time foo or bar change, both values are logged to console:

reactive foo = 0
reactive bar = 0

autorun {
  console.log(foo, bar)
}

setInterval(() => foo++, 1000)
setInterval(() => bar++, 820)

The autorun expression reruns any time any of the reactive variables (auto-detected dependencies of the autorun) change. By using the variables inside the autorun, the system automatically tracks those variables as dependencies of the autorun, and that's how it knows to automatically rerun the expression if any of the dependencies change.

For those not familiar with dependency-tracking reactivity, it is a key feature in these libs or frameworks (note that API naming varies across them):

and many more.

If we want to stop logging in the above example, we could perhaps write

reactive foo = 0
reactive bar = 0

const stop = autorun {
  console.log(foo, bar)
}

setInterval(() => foo++, 1000)
setInterval(() => bar++, 820)

// Stop the autorun (stop logging) later:
setTimeout(stop, 10_000)

I also wrote about how dependency-tracking reactivity makes code shorter, cleaner, and more concise compared to other event patterns here:

Do you think we can have a language-level dependency-tracking reactivity feature built in?

some related threads:

Actors Beat you to it, just in a slightly different flavor. :wink:

I think Actors is on a better path than most frameworks using today's JS syntax except for the ones with dependency-tracking reactivity and fine-grained updates. I replied there on how I think syntax could be, and to me it feels more natural that way.

Are you familiar with Qt? Dependency-tracking reactivity is built into Qt's QML+JavaScript language.

Many of us in the Solid.js community have been thinking about this. I think that for this to work, it needs unique syntax so that it is essentially new primitives that don't conflate with existing paradigms in the language, like I described in a comment to Ryan's article:

With syntax being bike sheddable, that comment, plus this snippet gets the idea across:

export signal count@ = 0

// increment it every second
setInterval(() => count@++, 1000)
import {count@} from './count.js'
import {someOtherValue@} from './somewhere.js'

const effect = effect {
  // This runs any time either count@ or someOtherValue@ change, batch-deferred in the next microtask after either was modified
  console.log('values are: ', count@, someOtherValue@) 
}

// any time later,
effect.stop()

Sometimes we want read-only signals:

signal _count@ = 0

@readonly // perhaps using a future decorators proposal, decorating the export
export signal count@ = _count@

// increment it every second
setInterval(() => _count@++, 1000)

Or maybe we hav enew syntax for readonly derived values:

signal _count@ = 0

export memo count@ {
  return _count@
}

or

signal _count@ = 0

export memo count@ = _count@

Just food for thought, but the main idea is syntax differentiates the semantics from current JS features. The meaning of existing languages features is not ambiguous this way. A regular var foo will always operate like it currently does, never being a reactive signal (unless future declaration decorators are added, which allow people to achieve it via decorating get/set of a variable).

There would be a new "pass by signal" that passes the signal along, so that it can be reacted to anywhere. F.e.:

signal foo@ = 123

setInterval(() => foo@++, 1000)

function makeAnEffect(bar@) {
  effect {
    console.log(@bar)
  }
}

var someVar = 456

makeAnEffect(123) // syntax error, 123 is not a signal, it cannot be passed to a signal parameter.
makeAnEffect(someVar) // syntax error, someVar is not a signal, it cannot be passed to a signal parameter.

makeAnEffect(foo@) // this works, foo@ is a signal, and the internal bar@ reads from it (merely an alternate name for the same signal within the function.

Without the syntax disambiguation, things will get very messy very quickly.

2 Likes

You'd probably want a cleanup mechanism to go along with this (thinking of Surplus' S.cleanup or Solid's onCleanup hook).

Yes indeed, I think we need the ability to cleanup when desired like in S.js or Solid.js, but also it should implicitly cleanup (be garbage collected) if all dependencies are no longer referenced (also as with those libraries).

As for explicit cleanup, how could that look like?

const effect = effect {
  // re-run when a, b, or c change
  console.log(@a, @b, @c)
}

// later:
effect.stop()

Maybe effect here is some sort of instance based on an Effect class that represents the effect. Similar to how a Function class represents instances of function() {} or Array represents [].

Also effects should be synchronously dependent (children) of the effect they're created in, and would therefore also be cleaned up with their parent:

const effect = effect {
  // re-run when a, b, or c change
  console.log(@a, @b, @c)

  const d@ = 0

  const interval = setInterval(() => @d++, 1000) // increment every second

  // inner effect (no need to keep a reference in this example)
  effect {
    // additionally log any time d changes
    console.log(@d)
  }

  // a "cleanup" block re-run any time the effect it is defined in re-runs (cleanup in S.js, onCleanup in Solid.js)
  cleanup {
    clearInterval(interval)
  }
}

// later:
effect.stop() // also cleans up the inner effect (and whichever interval is currently running)

This is very similar to S.js and Solid.js, just in a syntactical form.

I think having this be a native feature would be great because:

  • it is powerful for data modeling
  • issues with debugging are eliminated: the JS engine can provide accurate call stack when an error is thrown, even on infinite recursion (it could throw if recursion is too big just like with functions), whereas with libs like S.js and Solid.js they have to catch errors and re-throw which can get problematic (the trace you see may not match with your actual source).
  • With an official syntax, we can provide IDE tooling to help with best practices, f.e. we can syntactically highlight effects that are children or not children in differing ways, etc.
  • Declarative toolchains (f.e. JSX compilers) can have a standard output for reactivity
  • signals and effects can be implemented and optimized natively in the JS engine in different ways, while providing the top-level syntactical pattern
  • ...

This is a great idea. Keywords and decorators are highly under utilized nowadays. This could change that and bring a new paradigm into the ecosystem

1 Like

Some people have been saying that perhaps it makes sense to create a runtime API first, then syntax sugar after. For example, maybe we could start with built-in APIs like:

const count = new Signal(0)

setInterval(() => count.value++, 1000)
// or setInterval(() => count.set(val => ++val), 1000)

const effect = new Effect(() => {
  console.log(count.value) // runs any time count changes
})

// later
effect.stop()

where count.set(val => ++val) is form that you'd use inside of an effect to avoid both reading and writing (infinite loop):

const effect = new Effect(() => {
  count.value = count.value + other.value // this reads and writes count, causing a reactivity loop
})

vs

const effect = new Effect(() => {
  count.set(val => val + other.value) // this only writes count, no reactivity loop (updates only on other.value changes)
})

With syntax sugar, the reactivity loop problem goes away:

const effect = effect {
  count@ = count@ + other@ // this could be equivalent to using count.set() (read is always untracked in an expression that writes to the same variable)
}
const effect = effect {
  count@ = count.value + other@  // escape hatch (count.value is tracked)
}

Would it make sense to have untrack API (and later sugar)?

const effect = new Effect(() => {
  count.value = a.value + untrack(() => b.value) // update only if `a` changes
})
const effect = effect {
  count@ = a@ + untrack { b@ } // update only if a changes (do{} syntax and similar semantics)
}

and shortcut for single untracked value:

const effect = effect {
  count@ = a@ + b.raw // update only if a changes
}
1 Like