Skip to content

Instantly share code, notes, and snippets.

@loreanvictor
Last active November 10, 2022 23:22
Show Gist options
  • Save loreanvictor/ef4fbc84e6be32adfb1404d6e0e7d05e to your computer and use it in GitHub Desktop.
Save loreanvictor/ef4fbc84e6be32adfb1404d6e0e7d05e to your computer and use it in GitHub Desktop.
Reactive JavaScript

Reactive JavaScript

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.

Some Definitions

  • 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))

The Problem

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()

The Solution

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)
  }
}

Consideration: Identifier vs Expression

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) {
}

Consideration: Active vs Passive Tracking

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).


Consideration: Cold Start

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)

The Context Issue

To outline the context issue, lets assume that @ operator can be used anywhere. Should console.log(@a + 2) be an observable expression that logs values of a (plus 2) whenever they change? Or should it treat @a + 2 as an observable itself and pass it directly to console.log(), causing it to log an observable? i.e. which is the equivalent code?

// whole statement / expression is an observable
a.subscribe(_a => console.log(_a + 2))
// the observable is passed to the function
const b = map(a, _a => _a + 2)
console.log(b)

The issue is that both choices violate some key syntactic invariance. The first choice violates variable invariance: if you extract a sub-expression and wrap it in another variable, you expect the code to behave exactly the same. The second choice violates function invariance: if you turn any (sub-)expression into a function and call that function instead, you expect the code to behave exactly the same.


Function Invariance

By function invariance I basically mean if we have a function f like this:

const f = x => x + 2

Then we expect the following codes to behave identical for any expression E:

f(E)
E + 2

If it holds, then f(@a) should behave identically to @a + 2, i.e.

map(a, _a => f(_a))
map(a, _a => _a + 2)

We expect this invariance to hold with regards to any function f, including console.log, which means that console.log(@a + 2) should be translated to the following:

map(a, _a => console.log(_a + 2))

Which means the second translation cannot be correct, if we assume function invariance.


Variable Invariance

By variable invariance, I mean that for any expression E, the following two pieces of code should behave identically:

f(E)
const x = E
f(x)

This means that the following codes should behave identically:

console.log(@a + 2)
const x = @a + 2
console.log(x)

However, in the above code, x must resolve to a singular value, and console.log(x) must always just log that value, regardless of its type, which means the first translation outlined above cannot be correct if variable invariance is to be upheld. Note that IF we assume @a + 2 to be equivalent(-ish) to map(a, _a => _a + 2), then because of variable invariance the second translation MUST be the correct one. However, however we translate x in the above code, the second statement translates to a singular log, and not a logging of values of a (plus 2) as they change.


Solution

The above contradiction arises based on the assumption that @ can be used freely in any context. This means that @ necessarily needs to be restricted to some contexts, which I will call observable contexts.

This means that console.log(@a + 2) should not appear out of context. Similarly, const x = @a + 2 must result in a syntax error. Assuming the observable contexts outlined above:

const @x = <observable-context>
observe <observable-context>
observe { <observable-context> }
@ => <observable-context>
@ => {
  <observable-context>
}

The context issue is that without context, console.log(@a + 2) can be either of the following:

@ => console.log(@a + 2)
console.log(@ => @a + 2)

The same issue with const c = @a + 2. Requiring a context (simply disallowing usage of @ out of context) resolves the issue, as the invariances mentioned above do not need to cross the context boundaries that are explicitly specified here, i.e. we can write this:

observe console.log(@a + 2)

Which translates to:

a.pipe(
  tap(_a => console.log(_a + 2))
).subscribe()

Or this:

const @x = @a + 2
observe console.log(@x)

Which translates to:

const x = map(a, _a => _a + 2)
x.pipe(
  tap(_x => console.log(_x))
).subscribe()

Or this:

const @x = @a + 2
console.log(x)

Which inambiguously logs an observable object once.


Consideration: Operators

While the @ syntax removes the need for many lower level operators (such as map(), tap(), filter(), etc.), more stream-aware operators (such as debounce()) still remain of crucial importance. The limits arising from function invariance simply prohibit inter-operability of @ syntax with such operators, i.e.:

debounce(a, 200)                  // this is ok
debounce(@a + 2, 200)             // this is not ok
const @x = debounce(@a + 2, 200)  // this is ok, but is wrong since x is a higher-order observable

However, the @ => syntax actually helps with this issue (and generally with passing around observable expressions as observables without requiring an intermediate variable):

debounce(@ => @a + 2, 200)

Consideration: JSX

It is also perhaps pretty useful to allow JSX expressions to be observable contexts. However, this would mean we cannot have a JSX / function call invariance any more, i.e. we cannot have the folloiwng codes being equivalent:

<Func>{x}</Func>
Func({}, x)   // or any other convention for passing JSX arguments

Since if we could, we could define:

const Func = (_, x) => console.log(x)

Then this code:

<Func>{@a + 2}</Func>

would need to behave identically as this code:

Func({}, @a + 2)

which as outlined above, needs to be prohibited, while we assumed JSX-expressions are observable contexts, and we end up in a syntactic contradiction.

Alternatively, we can have JSX expressions be normal expressions and resolve the issue using the @ => syntax, i.e.

<Func>{@ => @a + 2}</Func>

This is a minor inconvenience compared to <Func>{@a + 2}</Func>, however the JSX / function call invariance outlined above is not a strict part of the language, and its violation is already done in tools like SolidJS (for exactly the same reason).


Consideration: Observable Functions?

This is merely a question to contemplate: should we also have observable functions, i.e. functions whose body is an observable context and will always return an observable? What would the use-case for such functions be? I can imagine short-hands like this:

function fn(a) {
  return @ => @a + 2
}

// To:
function@ fn(@a) {
  return @a + 2
}
const fn = a => @ => @a + 2

// To:
const fn = @a => @a + 2

But the whole point of @ operator is to treat observables like raw values, which means such functions can in theory just be normal functions working on any values and being used in an outer observable context (verification needed, but if true, then there is no need for observable functions).


Consideration: Out of Context Use

An alternative to disallowing out-of-context use of @ would be to give it a third, inambiguouos meaning: when used out of an observable context, @a simply means the last value of a, i.e.

console.log(@a + 2)

Will log the last value of a plus 2, once.

This alternative provides further convenience for working with observables. However, it acts as a serious footgun as unexpected behavior can rise when some observable expression is not put inside an observable context, leading to more confusion. Furthermore, this means all observables should remember the last value they have emitted, which not only disallows ephemeral emissions that are forgotten after being consumed (resulting in better memory management), but also gives rise to confusing behavior in case of non-shared observations and observables (last emission for which observation?).

Subsequently, I feel at this point simply disallowing out-of-context use would be a better option. The aforementioned convenience can be provided explicitly via use of reactive states, specifically if they are equipped with a .value property (like this). Nevertheless I should mention that libraries such as SolidJS do adopt this approach (as the reactive primitive in SolidJS is a signal which is basically a reactive state).

It might be pretty handy to allow out of context use for reactive states, since in those cases @a used out of context can simply be translated to a.value in an inambiguouos way. However, it might further add to the confusion of where can @ operator be used and where it can't.

Reactive States

The baseline syntax proposition makes working with observables smoother in general. However, we can go one step further and make handling reactive states (Subjects in RxJS lingo) also more convenient. A state, in this definition, is an observable that can be instructed to emit values at demand. Observations of states are shared.

This proposal extension requires further contemplation and might be inconsistent.

let @x = 2

observe console.log(`X is {@x}`)
// > X is 2

@x = 3
// > X is 3

Note that the RHS is NOT an observable context, so these codes result in syntax error:

let @x = @a + 2
@x = @a + 2

We can achive this (i.e. subscribing a state to another observable / observable expression) using observe keyword:

observe @x = @a + 2

Sub-states

We can also further enhance this proposal by treating indexes and properties of plain array / object states as states as well:

let @x = [1, 2, 3]

observe console.log(@x)
// > 1, 2, 3

@x[0] = 2
// > 2, 2, 3

@x.push(4)
// > 2, 2, 3, 4

Consideration: Confusing Syntax Footgun

An issue with the proposed syntax is that const @x = ... is an observable context while let @x = ... is not, violating const/let symmetry. One option is to also consider @x = ... and let @x = ... as observable contexts, however this would be semantically confusing, since the state would be implicitly subscribed to some other observable expression, emitting its values alongside its own values, and more importantly, creating an implicit observation that can't be claimed and cleaned up (the observe syntax explicitly returns an observation that can be cleaned up). So I strongly feel this footgun would cause less issues since it could be syntactically checked and prevented while the other can't (and can lead to memory leaks).

Another option would be to use a separate keyword, i.e. state @x = ..., but this introduces a new keyword to the language which increases syntax complexity and chance of clash with real-life code, and also does not solve the actual issue as still let @x = ... would result in a syntax error.


Consideration: Circular Dependency Footgun

Take the following code:

const click = event($btn, 'click')
let @count = 0

observe { @click; @count = @count + 1 }
observe { $div.textContent = @count }

This results in a circular dependency of @count on itself, which means the first observation runs in an infinite loop. This can be prevented syntactically, for example by assuming passive tracking within any observable context that contains a @x = ... statement, but this results in too much magical behavior while only preventing a specific set of cases (specifically if @(<expr>) syntax is allowed), potentially resulting in more confusion (as people would not expect this to be an issue while it can be).

I feel the better option is to not simply handle it, instead providing explicit passive tracking utilities (or explicit tracking in general). For example, with explicit tracking, the issue with the code above can be resolved as follows:

const click = event($btn, 'click')
let @count = 0

observe (click) @count = @count + 1
observe { $div.textContent = @count }

We can achieve the same thing with a .value property on state objects:

const click = event($btn, 'click')
let @count = 0

observe { @click; @count = count.value + 1 }
observe { $div.textContent = @count }

Consideration: Emission on Observation

In these examples, I have assumed reactive states to emit their latest value upon observation (i.e. to behave like RxJS's BehaviorSubject). Another option would be for them to only emit values to observations upon change, in which case an observation won't get the latest value of the state until new values are set.

const speedAdjustRate = 20
const looseThreshold = 20
const levelThreshold = 10
let @rate = 600
const @@timer = interval(@rate)
let @letters = []
let @score = 0
let @level = 1
const event = event(html.document, 'keydown')
const key = startWith(@ => @event.key, '')
observe (timer) {
const newLetter = randomLetter()
const position = randomPosition()
@letters = [{ letter: newLetter, position: position }, ...@letters]
}
observe (key) {
if (@key === @letters[@letters.length - 1]) {
@letters.pop()
@score += 1
if (@score % levelThreshold === 0) {
@rate -= speedAdjustRate
@level += 1
@letters = []
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment