DEV Community

Cover image for Reactive Interactions with External Systems
Jin
Jin

Posted on • Originally published at mol.hyoo.ru

Reactive Interactions with External Systems

Sometimes an invariant requires asynchronous communication. For example, for heavy calculations in a separate worker. Most reactive libraries do not support asynchronous invariants, but there are some that do. Let's consider both options..

🏊 Sync: Synchronous invariants
πŸ‡ Async: (A)synchronous invariants

🏊 Sync

If only synchronous reactivity is supported, and we need to perform some kind of asynchronous call, then it usually goes somewhere to the side. Let's take a simple example using RxJS...

const image = source_element.pipe( map( capture ) )
const data = image.pipe( map( recognize ) )
const text = data.pipe( map( data => data.text ) )

text.subscribe( text => {
  output.innerText = text
} )
Enter fullscreen mode Exit fullscreen mode

The capture and recognize functions are asynchronous, since the first one needs to wait for the image to load, and the second one launches neurons on a pool of workers. When we change source_element, output.innerText will not change at all. That is, the states will no longer be consistent. And they will come to consistency only when all asynchronous operations are completed.

This problem is usually solved by interactively setting some isLoading flag at the beginning and interactively resetting it at the end. And when this flag is raised, the waiting indicator is drawn reactively.

Not only is this a routine, but it is also often prone to bugs when several tasks being performed are tied to one indicator. Which, with interactive logic, can cause a so-called race condition.

πŸ‡ Async

If asynchronous invariants are also supported, then runtime maintains consistency automatically. A typical solution is through a mechanism for dealing with emergency situations. Let's write what the code might look like using, for example, generators..

@computed
text*() {
  const image = yield capture( this.source_element )
  const data = yield recognize( image )
  return data.text
}
Enter fullscreen mode Exit fullscreen mode

Why not asynchronous functions? Yes, because they are made in JS through the ass. So the authors of libraries have to rely on generators that are made through the opposite place, but also not through what they should.

In fact, you can even do without generators. $mol_wire and React support SuspenseAPI, which allows you to write pseudo-synchronous code and not have to worry about yield and await. Well, it doesn’t matter, the generators for my story will be clearer.

When runtime calls the text generator, it is given a promise instead of a string. It understands that the final result will come later, subscribes to the finalization of the promise, and in the meantime marks the state as β€œpending value”. This wait flag applies to all dependent states. And the rendering system, seeing this, automatically draws a waiting indicator. Cool!

Function Colors

There are two value access interfaces: sync and async. Let's look at their features...

Synchronous access assumes that the returned value will always be valid. If there is nowhere to get such a value, then an exception occurs, which can either be skipped or intercepted and processed:

something(): string {

  try {

    // returns always string
    return do_something()

  } catch( cause: unknown ) {

    if( cause instanceof Error ) {
      // Usual error handling
    }

    if( cause instanceof Promise ) {
      // Suspense API
    }

    // Something wrong
  }

}
Enter fullscreen mode Exit fullscreen mode

Please note that not only an error, but also a promise can be thrown as an exception. This is the so-called Suspense API, which allows you to work with asynchronous code as if it were synchronous. And this can be supported in a wide variety of libraries. It works like this:

  1. A synchronous idempotent function is called.
  2. If you need to wait, then a promise is thrown as an exception.
  3. This exception is caught outside that synchronous function and a subscription is made to finalize the promise.
  4. Once the promise is finalized, the function is automatically called again.
  5. This time, instead of throwing a promise, the result of the promise is immediately returned, or an error is thrown from it.

Asynchronous access, on the contrary, always returns a Promise even if there is already a valid value and asynchrony is not needed:

async something(): Promise< string > {

  try {

   // returns always string
   return await do_something()

  } catch( cause: unknown ) {

    if( cause instanceof Error ) {
      // Usual error handling
    }

    // Something wrong
  }

}
Enter fullscreen mode Exit fullscreen mode

With synchronous code, we can easily automatically track dependencies by temporarily placing the subscriber in a global variable:

function tracked() {
  let backup = current
  current = new Subscriber
  try {
    // sync code with tracking deps
  } finally {
    current = backup
  }
}
Enter fullscreen mode Exit fullscreen mode

With asynchronous, this will not work, since at the very first await we will exit the function without rolling back the global variable, which will break everything:

async function tracked() {
  let backup = current
  current = new Subscriber
  try {
    // sync part with tracking deps
    await something // dirty current
    // other path with wrong current
  } finally {
    // break others current
    current = backup
  }
}
Enter fullscreen mode Exit fullscreen mode

You can abandon asynchronous functions and switch to generators, as they did in MobX:

const tracked = suspendable( function*() {
  let backup = current
  current = new Subscriber
  try {
    // sync part with tracking deps
    yield something // suspendable temp rollbaks current
    // other part with continue tracking deps
  } finally {
    current = backup
  }
} )
Enter fullscreen mode Exit fullscreen mode

But generators, like asynchronous functions, do not solve the very exhausting problem of color functions, but only aggravate it by introducing another color. In addition, they are also much slower than synchronous code, since they cannot be properly optimized by the JIT compiler:

Let’s summarize why it’s better to leave most of the code synchronous:

  • It's easier to understand.
  • It can be called from any function.
  • It can be passed as a callback to any function.
  • It's faster.
  • It requires less memory.
  • It's easy and reliable to track dependencies.

Recoloring functions

Many native APIs and third-party libraries are asynchronous and we must be able to transparently integrate with them. That is, we need mechanisms for transparent transformation of a synchronous API into an asynchronous one and vice versa.

To do this, we implement a couple of wrappers:

For example, let's implement the simplest synchronous json loading function:

function getData( uri: string ): { lucky: number } {
  const request = $mol_wire_sync( fetch )
  const response = $mol_wire_sync( request( uri ) )
  return response.json().data
}
Enter fullscreen mode Exit fullscreen mode

And vice versa, let's implement an asynchronous function that is compatible with SuspenseAPI, and does not fall with Promise logging to the console:

async function lucky_update( uri: string ): Promise< undefined > {

  const fetchData = $mol_wire_async( getData )
  const data = await fetchData( uri )
  data.lucky = 777

  await fetch( uri, {
    method: 'put',
    body: JSON.stringify({ data }),
  } )

}
Enter fullscreen mode Exit fullscreen mode

Thus, synchronously describing any application logic, be it reactive invariants or interactive actions, we can always pause on an asynchronous task, and the reactive system will respond adequately to this: dependency tracking will not break, and the fiber will automatically restart when necessary.

Concurrent tasks

Once our tasks start pausing to allow other tasks to run, we have contention problems where the same task runs multiple times and we have to decide what to do about it.

With invariants, everything is trivial: if any state on which a suspended atom depends has changed, then this atom is simply restarted under new conditions, so no competition arises.

But with actions everything is more interesting: they are launched from the outside in response to an event:

button.onclick = function() {
   // run multiple tasks concurrently
   $mol_wire_async( counter ).sendIncrement()
}
Enter fullscreen mode Exit fullscreen mode

Here, each time a new task will be launched that is in no way related to the already running ones. How many times you click - so many tasks will start working simultaneously. But often this is a waste of resources, and even harmful behavior. Therefore, as a rule, it is better to launch actions so that the new launch cancels the previous one:

// next run abortions previous
button.onclick = $mol_wire_async( function() {
   counter.sendIncrement()
} )
Enter fullscreen mode Exit fullscreen mode

Since here a wrapper around the function is not created every time, but the same one is used, no matter how many times we click on the button, only the last launched task will reach the end, and the rest will be cancelled. If they did not manage to be completed by the time the new one was launched, of course.

And if we insert a time delay at the beginning of the task, then due to this behavior we simply get debounce:

button.onclick = $mol_wire_async( function() {
   $mol_wait_timeout( 1000 )
   // no last-second calls if we're here
   counter.sendIncrement()
} )
Enter fullscreen mode Exit fullscreen mode

Cascade Abortions

When the promises were still in their infancy, there were many different implementations. The most advanced of them supported cancellation of asynchronous tasks. Unfortunately, this functionality was not included in the JavaScript standard, and we are offered to manually drag an instance of AbortSignal through the parameters, which is not very convenient .

However, with us, all fibers form a connected graph, which means the cancellation of any fiber should lead to unsubscribes, followed by automatic cascading cancellation of other fibers that remain without subscribers. And if a fiber owns an object, then when it is destroyed it also calls its destructor, in which we can already implement our logic. For example, let's implement a simple HTTP data loader with the ability to cancel an incomplete request:

const fetchJSON = $mol_wire_sync( function fetch_abortable(
  input: RequestInfo,
  init: RequestInit = {},
) {

  const controller = new AbortController
  init.signal ||= controller.signal

  const promise = fetch( input, init )
    .then( response => response.json() )

  const destructor = ()=> controller.abort()
  return Object.assign( promise, { destructor } )

} )
Enter fullscreen mode Exit fullscreen mode

Now we can simply call functions and not worry about storing controllers and passing signals, but be sure that all connections will be canceled correctly:

button.onclick = $mol_wire_async( function() {

  const { profile } = fetchJSON( 'https://example.org/input' )

  fetchJSON( 'https://example.org/output', {
    method: 'PUT',
    body: JSON.stringify( profile ),
  } )

} )
Enter fullscreen mode Exit fullscreen mode

It is important to note that if you make multiple conversions from a synchronous API to an asynchronous one and vice versa, then the fibers do not form a single graph, since the graph will be broken at points of asynchrony. Accordingly, due to the lack of a cancellation mechanism for native promises, you will have to send the corresponding signals manually. But this is a rather marginal case, since normally the entire application should form a single graph with a synchronous API, and asynchrony should appear only at the interface with external APIs.

Top comments (0)