Skip to content

loreanvictor/reactive-javascript

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

61 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Reactive Javascript

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

Table of Contents


Terminology

πŸ‘‰ 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.


The Idea

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


Extensions

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.


Observable Creation

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.


Observation

Unlike Promises, 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.


Explicit Dependencies

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.


Nullish Start

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.


State Management

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.


Async Interactions

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.


About

An investigation of whether JavaScript syntax could be extended to better facilitate reactive programming

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published