Reactive programming in JavaScript is not easy, as it is not directly supported by the language itself. The community has tried to address this shortcoming via frameworks that try to solve it specifically for client side applications (like React), and reactive programming utilities and libraries (the most famous being RxJS) that address this issue in isolation.
Nevertheless, the status quo still feels lacking for such a fundamental use case of JavaScript. Frameworks mostly need to bend the semantics of the language (e.g. React components are NOT like other functions) and libraries shift towards paradigms that increase code complexity (e.g. the FRP style of RxJS). Per 2021 State of JS Survey, amongst features missing from JavaScript, native support for observables was ranked as fifth.
React Example
import { useState } from 'react'
function Counter({ name }) {
const [count, setCount] = useState(0)
// βοΈ this function can only be used in a React component.
const color = count % 2 === 0 ? 'red' : 'blue'
// βοΈ `color` is recalculated even when `count` has not changed.
// π a new function is defined every time `count` has a new value.
// a new function is also redefined whenever `name` changes.
function incr() {
setCount(count + 1)
}
return <div onClick={incr} style={{ color }}>
{ name || 'You' } have clicked {incr} times!
</div>
}
RxJS Example
import { map, fromEvent, scan } from 'rxjs'
// π FRP style programming is not convenient for many developers.
// JavaScript is NOT a functional language, so combination of these styles
// inevitably increases code complexity.
const count = fromEvent(button$, 'click').pipe(scan(c => c + 1, 0))
const color = count.pipe(map(c => c % 2 === 0 ? 'red' : 'blue'))
count.subscribe(c => span$.textContent = c)
color.subscribe(c => div$.style.color = c)
This work is an invesitagtion of a potential syntactic solution to this shortcoming. The main idea is to be able to treat observable values as plain values in expressions, thus removing syntactic overhead required for conducting basic operations on observable values (re-evaluation of functional components in React, FRP-style programming with RxJS).
React Example Reimagined
function Counter({ name }) {
let @count = 0
// βοΈ this can be used anywhere, with consistent semantics.
const @color = (@count % 2 === 0) ? 'red' : 'blue'
// βοΈ `color` is recalculated only when `count` has changed.
// π the click callback is defined once
return(
<div onclick={() => @count++} style={{ color }}>
{ name || 'You' } have clicked { count } times!
</div>
)
}
Same Example without JSX
// No need for FRP style programming
let @count = 0
button$.addEventListener('click', () => @count++)
observe {
span$.textContent = @count;
if (@count % 2 === 0) {
div$.style.color = 'red'
} else {
div$.style.color = 'blue'
}
}
Same Example with DOM API Support
let @count = 0
const @color = (@count % 2 === 0) ? 'red' : 'blue'
button$.addEventListener('click', () => @count++)
span$.textContent = count
div$.style.color = color
π For more precise definitions of various terms and expressions used in this repository regarding reactive programming and observables (such as observable, shared observation, flattening, etc.), read the definitions document. Generally speaking, semantics similar to that of RxJS are used, for example it is assumed that observables have the same interface as an RxJS Subscribable
.
As mentioned above, the idea is syntactically flattening observables, i.e. adding new syntax that allows working with observables
directly within expressions by providing access to values they wrap. This is similar to how Promise
s are flattened with the await
keyword:
const a = new Promise(...)
// ...
// Not flattened
a.then(_a => _a + 2)
// Flattened
(await a) + 2
We could use similar syntax, the @
operator, for flattening observables:
const a = makeObservable(...)
// ...
// Not flattened
a.pipe(map(_a => _a + 2))
// Flattened
@a + 2
Similar to how flattening Promise
s can only occur in asynchronous contexts, flattening observables can also only occur in observable contexts (read this for more on why). This can be achieved with a construct similar to anonymous async functions, explicitly specifying boundaries of an observable context:
const a = makeObservable(...)
const b = @ => @a + 2
π Read this for more details on the proposed syntax for observable flattening.
The proposed observable flattening syntax can be further extended with additional syntactic sugar, further simplifying common use cases where observables are used. Each of the following extensions can be considered and implemented independently, though they all depend on the original base syntax.
A common use case when handling observables is creating dependent observables, i.e. observables whose value is dependent on some source observables. This can be simplified via an additional observable creation syntax:
const a = makeObservable(...)
const b = makeAnotherObservable(...)
const @c = @a * 2 + @b
Which is shorthand for
const c = @ => @a * 2 + @b
And would translate to:
const c = combineLatest(a, b).pipe(map(([_a, _b]) => _a * 2 + _b))
π Read this for more details on the proposed syntax for observable creation.
Unlike Promise
s, which are immediately executed, observables are lazy, which means they don't get executed until they are observed. This basic operation can be further streamlined with additional syntax based on a new keyword, observe
:
observe { console.log(@a) }
Which would be equivalent to:
a.subscribe(_a => { console.log(_a) })
This syntax can be further enhanced to allow handling errors that occur on execution path of an observation and finalize the whole process:
observe {
console.log(@a + 2)
} catch (error) {
// an error has occured while computing new values for this observation,
// which terminates the observation.
console.log('Something went wrong ...')
} finally {
// the observation is finished because all of its observable sources have
// finished producing data.
console.log('a is completed now.')
}
Which would be equivalent to:
a.subscribe(
_a => console.log(_a + 2),
(error) => {
console.log('Something went wrong ...')
},
() => {
console.log('a is completed now.')
}
)
π Read this for more details on the proposed syntax for observation.
In the observable flattening syntax, we create observable expressions that are dependent on some other observables. In the proposed syntax these dependencies are implicitly detected:
const c = @ => @a * 2 + @b
It can be particularly useful to be able to explicitly specify these depencies as well. For example, you might want to have run some side effect without using the observed value:
// implicit dependencies
const log = @ => { @click; console.log('CLICKED!') }
// explicit dependencies
const log = @(click) => console.log('CLICKED!')
It can also increase readability of the code in case of complex expressions:
// implicit dependencies
const dependent = @ => {
// some code here
somethingDependsOn(@a)
// some more code here
somethingElseDependsOn(@b)
// ...
}
// explicit dependencies
const dependent = @(a, b) => {
// some code here
somethingDependsOn(@a)
// some more code here
somethingElseDependsOn(@b)
// ...
}
And it can be used as a method of passively tracking some other observables, i.e. using their latest value without re-calculating and re-emitting the expression when they emit new values:
const c = @(a) => @a * 2 + @b
// βοΈ c is only re-evaluated when a has a new value, though latest value of b will be used.
π Read this for more details on the proposed syntax for explicit dependencies.
In many cases it is helpful to assume a default value for an observable before it emits its first value (for each observation). With the proposed flattening operator @
, the observable expression won't be calculated until each observable emits at least once. This can be resolved by adding a cold start operator @?
, that causes the observable to emit undefined
initially upon observation, allowing addition of default values:
const greeting = new Subject()
const name = new Subject()
const msg = @ => (@?greeting ?? 'Hellow') + ' ' + (@?name ?? 'World')
// βοΈ msg will be 'Hellow World' initially.
greeting.next('Hallo')
// βοΈ msg will be 'Halo World'.
name.next('Welt')
// βοΈ msg will be 'Halo Welt'.
Which would be roughly equivalent to:
const msg = combineLatest(
greeting.pipe(startWith(undefined)),
name.pipe(startWith(undefined)),
).pipe(map(([_greeting, _name]) => (_greeting ?? 'Hellow') + ' ' + (_name ?? 'World')))
π Read this for more details on the proposed syntax for cold start.
In reactive applications, it is common to be able to observe and react to changes in program state as it is to external events. State can be modeled with observables who can be programmatically instructed to assume new values (e.g. RxJS's BehaviorSubject
). Managing state can be further streamlined with syntax similar to observable creation syntax:
let @count = 0
$btn.addEventListener('click', () => @count++)
observe {
$div.textContent = `You have clicked ${@count} times!`
}
Which would be equivalent to:
const count = new BehaviorSubject(0)
$btn.addEventListener('click', () => count.next(count.value + 1))
count.subscribe(_count => {
$div.textContent = `You have clicked ${_count} times!`
})
Typically program state is more complicated than plain values and involves nested object trees (composed of arrays and objects). Managing these deep states can also be facilitated by treating properties and indexes of a reactive state as reactive states themselves:
let @people = [
{ name: 'Jack', age: 32 },
{ name: 'Jill', age: 21 },
]
const averageAge = @ => {
const sum = @people.reduce((total, person) => total + person.age, 0)
return sum / @people.length
}
// βοΈ recalculated whenever there is a change in people
observe { console.log(@people[0].name) }
// βοΈ logs only when the name of the first person changes
@people[1].age = 23
// βοΈ averageAge recalculated, no log
@people[0].name = 'Jack'
// βοΈ logs 'Jack', also averageAge recalculated
@people.push({ name: 'Joel', age: 30 })
// βοΈ averageAge recalculated, again no log
π Read this for more details on the proposed syntax for state management.
A curious property of observable flattening is possible interactions with asynchronous operations. For this to work, we can assume observable contexts as async contexts, and assume cancellation of a pending execution when a new execution is triggered. This way, observably async operations such as debouncing can be expressed in an exceedingly natural and intuitive manner:
const @obs = makeSomeObservable(...)
const @debounced = (await sleep(1000), @obs)
π Check this librry to see how this would look like in practice.