THIS SHOULD BE MOVED TO A REPOSITORY FOR BETTER ORGANIZATION, AS IT IS GETTING PRETTY MESSY HERE.
Also inspect whether a babel plugin can be developed for this (check this and this).
I have always wondered whether we could extend syntax of JavaScript to natively support reactive primitives, how would that look and whether that would be useful or not. This is a mental excercise on that front.
-
Observable:
- a value that changes (perhaps in response to external events such as user events, time, etc.)
- a side effect (change in some other program state) which is dependent on some observables
- combinations of the two
-
Observation: Observables don't do anything until they are observed, resulting in an observation. Observations might be stopped at any time. In RxJS lingo, this is the same as a subscription.
- Shared observation: an observable might be observed by multiple observations. If the observable produces a singular value for all observations, or executes its side effects exactly once regardless of number of observations, then it is shared (amongst all observations). Otherwise, it is not shared (each observation receives their own independent results and causes their own side effects, i.e. they get their own execution path).
-
Operator: A function that accepts an observable (and perhaps some other options), and returns another, perhaps transdformed, observable. For reading convenience, I will use an RxJS-like syntax, which assumes operators to be pipeable, i.e.
OP(observable, options)
is equivalent to:
observable.pipe(OP(options))
Imagine a
and b
are observables. Modifying, combining and using their values is not straight forward and requires
a functional approach, e.g.
console.log(a) // ❌ doesn't log values
a.subscribe(v => console.log(v)) // ✅ logs values
const c = a * 2 // ❌ doesn't work
const c = map(a, x => x * 2) // ✅ works
// ❌ doesn't work
if (b) {
console.log('b:: ' + b + ', a:: ' + a)
} else {
console.log('a:: ' + a)
}
// ✅ works
combine(a, b).pipe(
tap(([_a, _b]) => {
if (_b) {
console.log('b:: ' + _b + ', a:: ' + _a)
} else {
console.log('a:: ' + _a)
}
})
).subscribe()
Having an @
operator that kind of means latest value, i.e. if a
is an observable, then @a
behaves kind of similarly to _a
in the scope of the inline function in the following code:
tap(a, _a => { /* here */ })
From this, observable expressions can be made, i.e. @a + @b
, which are observables themselves, and can be observed in
a more elegant way using an observe
keyword, i.e. something like this:
observe console.log(@a + @b) // 👉 whenever a or b change, their sum is logged.
// this would be the equivalent
combine(a, b).pipe(
tap(([_a, _b]) => console.log(_a + _b))
).subscribe()
Such an operator gives rise to a context issue, so observable expressions must be limited to observable-contexts. Using them outside of such contexts MUST result in syntax errors (similar to how the await
keyword can only be used in async contexts).
Right now I can think of three methods of creating an observable context:
- observable creation:
const @c = @a + @b
observe
keyword:observe console.log(@event.value.toLowerCase())
observe { if (@a) { /* ... */ } }
- explicit observable expressions:
@ => @a + 2
@ => { console.log(@a) }
@ => { return @a + 2 }
A fourth option, JSX expressions, can also be considered.
<div>Hellow {@input.toLowerCase()}!</div>
Further details on each syntax will be provided below, but before that, lets just recap how the problematic codes outlined above would turn out with this syntax:
observe console.log(@a)
const @c = @a * 2
observe {
if (@b) {
console.log('b:: ' + @b + ', a:: ' + @a)
} else {
console.log('a:: ' + @a)
}
}
In all of the examples above, I've assumed that @
only is followed by an identifier (variable name). This raises the question: should it be allowed to also operate on an expression? i.e. should @(interval(1000))
be allowed? Would it be useful to have the following short-hand for any expression E
?
@(E)
// is equivalent to:
const x = E
@x
// note that the first line appears immediately before the observable context where @ is used
What about flattening higher-order observables? @@x
is a nice and intuitive syntax for flattening, @(@x)
is also an acceptable compromise. How would this be used in real life? Many real-life use-cases for flattening arise as an observable is mapped to another single-emission observable (such as an HTTP request), which can be handled nicely by assuming observable contexts are also async contexts (so we can await
inside them). However, there are other use-cases:
const click = event($btn, 'click')
// timer is a higher-order observable here,
// as it returns a new timer (observable) for each emission of click.
const @timer = (@click, interval(1000))
// this basically means the timer that is displayed is reset
// whenever the button $btn is clicked.
observe {
$div.textContent = @(@timer)
}
Maybe @
should be only limited to identifiers still, but allow chaining for flattening? Or perhaps, with having observable contexts also async contexts, the use cases become niche enough that it is ok to not have syntactic support for them?
I strongly feel
@(EXPR)
would be a confusing construct, since it requires EXPR to NOT be executed every time the observable context is executed, which differs from how code generally behaves. However, chain usage of@
is necessary, as it is an exceedingly straightforward tool for flattening. On this front, I feel it would be useful to inspect further flattening constructs, such as the following:const @@x = <observable-expression>// generally having observe's explicit tracking part be an observable context itself. observe (@@a) { }
All the examples outlined above are of active tracking, i.e. for any @a
, whenever a
emits a new value, the corresponding observable context is re-executed. While mostly this is the desired behavior, in some cases you would need the latest value from some observable without re-calculating whenever it has a new value (i.e. withLatestFrom()
operator from RxJS). There are multiple solutions for this:
- Ignore it, so no passive tracking
- Add another operator, e.g.
#
or@_
for passive tracking:observe console.log(@a + @_b) const @c = @a + @_b const c = @ => @a + @_b
observe console.log(@a + #b) const @c = @a + #b const c = @ => @a + #b
- Explicit tracking: allow to explicitly determine what is to be actively tracked, having all else passively tracked.
observe(a) console.log(@a + @b) const c = @(a) => console.log(@a + @b)
observe(a, b) { ... } @(a, b) => ...
The first solution simply means for any case of passive tracking we should default back on some functional reactive notation. The second solution introduces a new symbol and increases syntax complexity. The third solution seems the most promising as it intuitively reuses already introduced syntax, however it further complicates meaning of @
operator based on context which makes the code a harder to read. Note that @
operator is already context based, so this might not be too big of an issue. Still, I will wait until proper real-life use-cases for passive tracking are introduced before adding it to the proposal.
Note that a prominent use case is resolving circular dependencies, but that is an issue for reactive states and I feel keeping the scope isolated helps (i.e. maybe the solution is simply to not have reactive states instead of introducing explicit tracking to the base proposal).
I feel like explicit dependency specification is generally a useful construct for increased readability of code (and perhaps can act as a flattening tool).
A pretty useful construct would be to have an observable start with some default value. Perhaps it is worth considering adding syntactic support for this as well? A curious (but perhaps dangerous) step would be to have every observable expression start with an undefined
value, as this way using the ||
operator would simply provide a starting point. However, this alters behaviour of many observables and I am unsure of this: specifically as we could simply have const x = startWith(@ => ..., initial)