DEV Community

Cover image for Сhannel - Universal Reactive Abstraction
Jin
Jin

Posted on • Originally published at mol.hyoo.ru

Сhannel - Universal Reactive Abstraction

It is good to describe reactive invariants in the semantics of pull, so as not to calculate them again. But to process actions, we need push semantics, so as not to miss a single event, but to deliver it to the right place in the right form, from where further pull will begin.

In native JS there is a special syntax for this - getters and setters:

_title = ''
get title() { return this._title } // pull
set title( text: string ) { this._title = text } // push
Enter fullscreen mode Exit fullscreen mode

But it have a lot of limitations:

  • We cannot pass any additional parameters.
  • It require an owner object.
  • It cannot be asynchronous.
  • Quite a lot of code, especially when delegating.

In some frameworks, hooks like this are common:

//               push         pull
const [ title, setTitle ] = useState( '' )
Enter fullscreen mode Exit fullscreen mode

But it isn't efficient and it isn't convenient to use, since it don't separate the initialization phase (setting the default, receiving a push callback) from the phase of working with values (pulling and pushing the value). In addition, for two-way binding you have to write a lot of code like:

<Input value={ title } onChange={ setTitle } />
Enter fullscreen mode Exit fullscreen mode

We will introduce the abstraction "channel" - a function that, depending on the number of arguments, can act as both a getter and a getter-setter. You can both pull the current value out of it and push a new one into it, obtaining the current result.

Let's create a simple channel to understand its essence:

let _title = ''
const title = ( text = _title )=> _title = text

title() // ''
title( 'Buy some milk' ) // 'Buy some milk'
title() // 'Buy some milk'
Enter fullscreen mode Exit fullscreen mode

Experience with various abstractions in JS has shown that channels are the most practical of them. It abstract the consumer who wants to read and/or write some value from how that value is actually obtained and stored.

For example, we can easily ignore writing a value, and when reading, generate it on the fly, thus obtaining a read-only channel:

let id = ( dur?: number )=> Math.floor( Math.random() * 100 )

id() // 34
id( 18 ) // 83
id() // 13
Enter fullscreen mode Exit fullscreen mode

We can make it write-only events, using it as an event handler:

let _completed = false
complete( event: Event ) {
  _completed = true
}
Enter fullscreen mode Exit fullscreen mode

Or we can completely delegate one channel to another:

let details = ( text?: string )=> title( text )

details() // ''
details( 'Buy some milk' ) // 'Buy some milk'
details() // 'Buy some milk'
Enter fullscreen mode Exit fullscreen mode

Thus, channels can be arranged in chains, transforming data as it intersects layers of abstractions:

let details_html = ( html?: string )=>
html_encode(
  details(
    html && html_decode( html )
  )
)

details() // ''
details_html( 'Buy milk &amp; bread' ) // 'Buy milk &amp; bread'
details() // 'Buy milk & bread'
details_html() // 'Buy milk &amp; bread'
Enter fullscreen mode Exit fullscreen mode

Channels can be:

  • stateless - don't storing any data, but only access other channels.
  • stateful - storing initial data or calculation caches.

Note that singleton channels are not very practical, because they do not allow us to decompose the application. That's why we need OOP...

Object Reactive Programing

Objects allow us to group channels that are related in meaning into a single capsule. Moreover, they allow us to have many such capsules built from the same code (class instances). Simple example:

class Task extends Object {

  // stateful channel
  _title = ''
  title( title = this._title ) {
    return this._title = title
  }

  //stateless channel
  details( details?: string ) {
    return this.title( details )
  }

}
Enter fullscreen mode Exit fullscreen mode

Note that if everything is quite simple with delegates, then with channels that store state, everything is not so rosy: the channel name has to be repeated 4 times. This means that when copy-pasting there will inevitably be problems, because you will have to make the same edits 4 times. And auto-refactoring will not help us here.

Well, notice that we always return the value that we store, and we always store what we want to return. This means that we can write a decorator that will take care of all this:

class Task extends Object {

  @mem title( title = '' ) {
    return title
  }

  details( details?: string ) {
    return this.title( details )
  }

}
Enter fullscreen mode Exit fullscreen mode

Well, or like this, if we are unlucky with the programming language:

class Task extends Object {

  title( title = '' ) {
    return title
  }

  details( details ) {
    return this.title( details )
  }

}
mem( Task.prototype, 'title' )
Enter fullscreen mode Exit fullscreen mode

All we need to know now is that the $mol_wire_solo decorator memoizes the value returned from the method, regardless of whether we passed it an argument or not. However, if the argument was not passed, and there is already something in the cache, then the method call is skipped and the value from the cache is immediately returned. And this will continue until the cache is cleared. And if the cache is empty, the method is still called to get the default value. Well, if the argument is passed, then the method will be called in any case to update the cache.

In addition to code brevity, memoization gives us a couple more important properties: computational savings and idempotency. Let's illustrate this with the following example:

class Task_extended extends Task {

  @mem title_for_mirror() {
    const segmenter = new Intl.Segmenter()
    const segments = [ ... segmenter.segment( this.title() ) ]
    return segments.map( s => s.segment ).reverse().join('')
  }

  @mem Duration() {
    return new $mol_time_duration({ hour: this.duration() })
  }

}
Enter fullscreen mode Exit fullscreen mode

The first method is quite heavy, but thanks to memoization it will be executed only once, and lazily, at the time of the first call, and then the value will be returned from the cache until the value of title changes, of course.

The second method is not so much heavy as it returns a new object each time, which is often unacceptable. Here memoization makes it possible to guarantee that no matter how many times we call a method, the result of its call will be the same. This property is called idempotency.

Experience suggests that when designing an application architecture, it is extremely important that it be as idempotent as possible, because any non-idempotency is a time bomb. We will not substantiate this in detail now. However, idempotency will come in handy later.

For now, let’s remember the famous statement:

There are only two hard things in Computer Science: cache invalidation and naming things.

Two common problems in any field of programming are mentioned here. So, in this series we will solve the first completely, and the second... not completely, but we will make it less problematic.

Recomposition

A channel can produce not only simple types of data, but also complex ones, including those collected from other channels. For example, let's collect all the object data in the form of one DTO:

class Task extends Object {

  @mem title( title = '' ) {
    return title
  }

  @mem duration( dur = 0 ) {
    return dur
  }

  @mem data(
    data?: Readonly< Partial< {
      title: string
      dur: number
    } > >
  ) {
    return {
      title: this.title( data?.title ),
      dur: this.duration( data?.dur ),
    } as const
  }

}
Enter fullscreen mode Exit fullscreen mode

Please note that through a compound channel we can update several simple channels at once:

const task = new Task
task.data() // { title: '', dur: 0 }

const data = task.data({
  title: 'Buy milk',
  dur: 2,
})
task.title() // 'Buy milk'
task.duration() // 2
Enter fullscreen mode Exit fullscreen mode

Conversely, channels can be lenses that allow you to work with part of a large immutable structure, as with an independent mutable entity:

class Task extends Object {

  @mem data( data = {
    title: '',
    dur: 0,
  } ) {
    return data
  }

  @mem title( title?: string ) {
    return this.data(
      //title === undefined ? undefined : { ... this.data(), title }
      title?.valueOf && { ... this.data(), title }
    ).title
  }

  @mem duration( dur?: number ) {
    return this.data(
      dur?.valueOf && { ... this.data(), dur }
    ).dur
  }

}
Enter fullscreen mode Exit fullscreen mode

Please note that these two diametrically opposed approaches lead to the same object interface. This is the main advantage of channels - they allow you to change internal implementation within a fairly wide range without affecting consumers. Moreover, it allow you to customize the internal operation of objects from outside without breaking their operation. But more on that later.

Multiplexing

So far we have named each channel individually. But sometimes we need to write one code for an unlimited set of channels. Here multiplexed channels come to our aid, where the first parameter is the key identifying the channel. For example, let's get rid of the copy-paste of relatively complex logic from the previous section, moving it into a common super class:

class Entity extends Object {

  constructor(
    readonly id: number
  ) { super() }

  destructor() {}

  @mem data( data = {} ) {
    return data
  }

  @mem value<
    Field extends keyof ReturnType< this['data'] >
  >(
    field: Field,
    value?: ReturnType< this['data'] >[ Field ],
  ): ReturnType< this['data'] >[ Field ] {

    return this.data( value === undefined
      ? undefined
      : {
        ... this.data(),
        [ field ]: value,
      }
    )[ field as never ]

  }

}
Enter fullscreen mode Exit fullscreen mode

Now, we can work with the data channel through the multiplexed value channel, without having to do manual (de)structuring:

class Task extends Entity {

  @mem data( data = { title: '', dur: 0 } ) {
    return data
  }

  title( title?: string ) {
    return this.value( 'title', title )
  }

  duration( dur?: number ) {
    return this.value( 'dur', dur )
  }

}
Enter fullscreen mode Exit fullscreen mode

Now let's redirect state storage from memory to local storage:

// At first tab
const task0 = new Task
task0.data = data => $mol_state_local.value( `task=0`, data )
  ?? { title: '', cost: 0, dur: 0 }
task0.title( 'Buy some milk' ) // 'Buy some milk'

// At second tab
const task0 = new Task
task0.data = data => $mol_state_local.value( `task=0`, data )
  ?? { title: '', cost: 0, dur: 0 }
task0.title()                  // 'Buy some milk'
Enter fullscreen mode Exit fullscreen mode

And since our channels are reactive, then applications in different tabs receive instant synchronization automatically:

Top comments (0)