calmm-js / estates

Estates is a library for first-class state and observable properties
MIT License
12 stars 0 forks source link

Stateless? #1

Open raimohanska opened 5 years ago

raimohanska commented 5 years ago

You're saying that this library is about stateless properties. YettakeN, for example, is clearly stateful. Have I understood your definition of statelessness wrong?

polytypic commented 5 years ago

Well spotted and you probably have not. (And also, having takeFirst be stateful actually does cause problems and one needs to wrap property constructions inside lambdas to have them evaluated freshly in some situations.) This repo doesn't actually have my latest draft code where takeFirst is indeed stateless such that when a property like takeFirst is subscribed to (becomes part of the dependency graph) then it is instantiated (calling fork). A couple of snippets:

const TakeFirstIn = I.inherit(
  function TakeFirstIn(info, sources, n) {
    Ineffectual.call(this, info, sources)
    this.n = n
  },
  Ineffectual,
  {
    next(value) {
      propagateNext(value, this)
      const n = this.n
      if (0 === n || 0 === (this.n = n - 1)) enqueueComplete(this)
    }
  }
)

const TakeFirstEf = effectualFrom1(TakeFirstIn)

export const takeFirst = I.curry((n, sources) => {
  const info = analyzeSources(sources)
  return hasVarying(info)
    ? new TakeFirstEf(info, sources, Math.max(0, n))
    : valueFrom(info, sources)
})
export const effectualFrom1 = Class1 =>
  I.inherit(
    function EffectualFrom1(info, sources, arg1) {
      Effectual.call(this, info, sources)
      this.arg1 = arg1
    },
    Effectual,
    {
      fork(info, sources) {
        return new Class1(info, sources, this.arg1)
      }
    }
  )

It is been nearly a year since I last worked on this. IIRC, the draft code I have on my machine introduced some significant changes to the implementation (basically making more intelligent analysis of the source observable template separating what is known statically vs what is known dynamically etc) and is not yet working properly (which is probably why I didn't push it to the repo).

polytypic commented 5 years ago

Speaking of stateful combinators, then actually one of the reasons why serializer takes actions that must be functions to be called is to help ensure that combinator expressions are evaluated freshly. If you look at the CodeSandbox example, you'll see that the action there uses takeFirst and that is precisely an example where a stateful combinator is problematic. If it was forked when added to the dependency graph, then there would be no need to re-evaluate the combinator expression every time the action needs to be executed.

polytypic commented 5 years ago

Let me try to clarify a bit more.

As said in the README.md, this library is primarily about stateless applicative combinations of properties derived from external state. For example, you have a property a and a property b and you applicatively combine them to produce a property a + b:

const a_plus_b = E.lift((a, b) => a + b)(a, b)

Of course, a and b are not stateless and neither is a_plus_b. If there was no state of any kind involved, then a, b, and a_plus_b would be constants. However, the combination expression E.lift(R.add)(a, b) itself essentially introduces no new state and that is the point.

(Strictly speaking it introduces a bit of state in the form of a cache of the latest value of the property. However, that cache is to be updated from the sources a and b using the function (a, b) => a + b and has a kind eventual consistency guarantee, which stateful properties generally do not have.)

IOW, the idea is to support a style of programming where state is primarily kept outside of program components so that those program components themselves then become stateless (and become easier to reason about, compose in various ways, and to refactor).

Contrast the canonical Calmm Counter component

export const Counter = ({value}) => (
  <div>
    <button onClick={U.doModify(value, R.dec)}>-</button>
    {value}
    <button onClick={U.doModify(value, R.inc)}>+</button>
  </div>
)

and e.g. the Counter component example from Yolk. The Calmm Counter component function is essentially pure (referentially transparent). The Yolk Counter component functions is essentially impure. Every time the Yolk version is called, a new count$ stream is allocated.

Now, obviously, for a library like this to make any sense, there must be ways to introduce stateful properties (otherwise there would be only constants and there would be no point to have this library). One way to introduce those in this library would be by creating first class "atoms" that explicitly store state and that can be explicitly mutated using actions like modify.

This differs from traditional event stream libraries where state is to be primarily introduced via combinators like scan that maintain state in response to events coming from streams. There are a number of difficulties with that approach. Take another look at the Calmm and Yolk Counter components. The Yolk version has more than twice the amount of code. That is not just poor programming. What the event stream approach asks you to do is to introduce a lot of code that merely manages events. Routing the events (gathering input events, mapping them to a suitable form, merging them and possibly passing all the streams around) to the scan operation is a major burden that makes it difficult to move the state in scan to a different place. Consider what the component would have to look like if you indeed moved the counter state outside of the Counter and tried to make Counter stateless (and pure).

So, I would argue that introducing the notion of stateful atoms is an essential ingredient of actually enabling one to create stateless components, because the other approach of using event streams, merge, and scan makes it significantly more difficult.

Now, back to takeFirst and other combinators like debounce. First of all, I would like combinators to be pure — with the obvious exception of operations like atom(initialValue) which explicitly allocates state. RxJS actually mostly has pure combinators. However, state in RxJS is primarily supposed to be introduced via combinators like scan that tend to make it rather difficult as I weakly argued above. I believe this is one motivation behind the idea in Bacon.js to have only "hot" observables. Perhaps the real problem is not so much in the difference between "hot" and "cold", but rather in that using "cold" observable producing combinators like scan to introduce state is a bad idea. And that difficulty can be avoided by having atoms rather than combinators like scan.

On the other hand, there are cases where it does seem preferable to be able to introduce some temporary (local) state for various reasons. Being able to execute side-effects is one of those. That is what takeFirst serves. Another example is e.g. debouncing things for performance reasons (if everything could happen instantaneously there would be no point to debounce ever). It would be possible to make it so that any state used by such operations would be stored externally to them (e.g. require an atom to be passed to takeFirst and have takeFirst store its state in that atom), but it doesn't seem to me that it would make things better.