Sometimes we can end up with cyclical dependencies. Sometimes we may want to do them intentionally. For example, when implementing a converter between degrees Celsius and Fahrenheit, where the user can change any of the two values, and the second should be recalculated automatically.
However, in the vast majority of cases, cyclic dependencies indicate a problem with logic, so they are usually avoided. Fortunately, the logic of even a degree converter can always be rewritten so that there are no cyclic dependencies.
So let's look at how different systems react to this emergency situation.
🚫 Unreal: Impossible
💤 Infinite: Endless loop
🎰 Limbo: Arbitrary result
🌋 Fail: Causes an error
🚫 Unreal
It's quite tempting to make it syntactically impossible to create loops. For example, we can require, when creating a state, that all of its dependencies already exist. This is typically the case with push libraries.
It doesn't sound bad. However, we threw out the baby with the bathwater. That is, we have extremely limited ourselves in what kind of logic of invariants we are able to describe. In particular, this practically puts an end to the dynamic configuration of data streams. For example, it will no longer be possible to implement a spreadsheet on such an architecture.
💤 Infinite
A number of libraries simply go into an endless loop, constantly updating the same states.
For Angular and React, for example, this is typical behavior. There is even a crutch - a limit on the number of recalculations of one invariant. But we'll talk about this later.
🎰 Limbo
There is also a very strange solution - when indirectly accessing the state that is currently being calculated, its previous value is used.
Depending on the order of calculations, this approach gives different results. That is, not only does the state turn out to be inconsistent, but also the behavior of the application becomes not stable, but begins to depend on the weather on Mars.
🌋 Fail
The best solution is to detect the loop at runtime and throw an exception.
Further processing proceeds in the same way as with any other emergency situations. So it is especially important here that the system handles exceptions correctly.
Cycles Cutting Practice
As the application runs, dependencies are constantly rearranged to the point of being in diametrically opposite directions. For example, let's take a temperature converter, where, depending on what temperature we set explicitly, the second temperature should be calculated as a derived state. A naive implementation might look something like this:
class Converter extends Object {
@mem fahrenheit( fahrenheit?: number ) {
return fahrenheit ?? this.celsius() * 9 / 5 + 32
}
@mem celsius( celsius?: number ) {
return celsius ?? ( this.fahrenheit() - 32 ) * 5 / 9
}
}
const conv = new Converter
conv.fahrenheit( 32 ) // 32 ✅
conv.celsius() // 0 ✅
But there are two problems here. The first is infinite recursion if you access one of the properties without setting the value of at least one of them. With conventional methods, this would crash the browser, eat up a lot of memory, and crash with a stack overflow. But we have memoized methods - for a specific method called with specific keys in a specific object, they create a unique persistent atom. So, repeatedly accessing an atom while it is already being evaluated means an infinite recursion, which we can stop immediately, avoiding cyclic dependencies:
const conv = new Converter
conv.celsius() // Error: Circular subscription ❌
At the same time, if the method calls differ in any way, then there is no infinite recursion. For example, we can give the classic recursive calculation of the Fibonacci number:
class Fibonacci extends Object {
@mems static value( index ) {
if( index < 2 ) return 1
return this.value( index - 2 ) + this.value( index - 1 )
}
}
Thanks to memoization, even the thousandth number can be calculated instantly. But the price for this, of course, is the creation of thousands of caching atoms.
Another problem is two conflicting sources of truth. When we set the values of both states, both become primary and not necessarily consistent:
const conv = new Converter
conv.fahrenheit( 32 ) // 32 ✅
conv.celsius() // 0 ✅
conv.celsius(32) // 32 ✅
conv.fahrenheit() // 32 ❌
Correcting this code is not difficult - just move the source of truth into a separate property, and make both of ours derivative:
class Converter extends $mol_object2 {
@mem source( value = { celsius: 0 } ) {
return value
}
@mem fahrenheit( fahrenheit ) {
const source = this.source( fahrenheit?.valueOf && { fahrenheit } )
return source.fahrenheit ?? source.celsius * 9 / 5 + 32
}
@mem celsius( celsius ) {
const source = this.source( celsius?.valueOf && { celsius } )
return source.celsius ?? ( source.fahrenheit - 32 ) * 5 / 9
}
}
const conv = new Converter
conv.celsius() // 0 ✅
conv.fahrenheit() // 32 ✅
conv.fahrenheit( 0 ) // 0 ✅
conv.celsius() // -18 ✅
conv.celsius( 0 ) // 0 ✅
conv.fahrenheit() // 32 ✅
However, combining sources of truth is not always possible.
Top comments (8)
Thank you much for your writeup. Impressive to see, how complicated things can get with the wrong approach. Wasn't React invented to make state management easier?
The reason, why the situation is so hard to solve, is a very general one. State based systems know, that the state has changes, but not, why the state has changed. But states does not change by their own, there is always a reason: something happened, that changed the state. We call this an "event". In the case of a Celsius to Fahrenheit converter, the user inputs a value in one of the two input fields.
With an event based approch, you will also get in trouble if you look only for state changes:
But luckily, there are other events you can use. If you fire your event everytime a user inputs a value, things are pretty easy:
Event based systems can handle this kind of situations with the utmost ease.
I´m not telling you to use only event based logic everywhere. But it is important to see, that it´s a myth that state logic makes things easier. It depends much on the case.
I would prefer a mixed approach, where the "short range logic" (like updating a value if the neighbour changes) is handeled with an event based logic. Only golbal states with an influence of greater reach should be handeled by an explicit state logic. It's a real shame that this isn't possible with the React approach (as long as you do not put all the short range logic in webcomponents).
I´m sure, a hybrid approach would make many things much easier.
See this example
React was invented just to write a PHP-style frontend.)
Events, due to their fundamental non-idempotence, are subject to a lot of problems related to handling exceptional situations, and timely (un)subscription, and preventing unnecessary calculations. I will soon prepare a separate article on the topic of detailed comparing reactive and event-driven architectures.
What do you think of the concept of a mixed approach using "short range" and "long range" reactivity? This could make things much simpler I suppose.
This is a sure way to break the invariants and thereby make your life much more difficult. Especially when it comes to asynchronous invariants, which will be discussed in the next series.
Oh, yes, i forgot.... state management makes everyting ... easier?
Right reactive system, yes.
By the way: the page you are promoting in your profile (boosty.to/hyoo) cannot be switched to english anymore. Is this intentionally or is anything wrong with the state management ?
I don't know, it's not my service. I have my own donation service in the plans.