Why Observable + Syntactic Sugar is the Answer™ (maybe 😉)
A little over a week ago (10/25) at ReactConf Dan Abramov & Sophie Alpert announced the Hooks proposal. Instead of rehashing things here, I’m going to jump right in. If you’re lacking context, check out those links and Dan’s article.
It’s a bit disheartening to see so much energy being poured into a framework-specific solution to (primarily) managing state. And to see vue, svelte, preact, etc. implementing (or thinking about implementing) similar solutions, again tied to their framework.
Additionally, hooks have some footguns as pointed out by Evan You. As well as a set of restrictions that stem from limitations of the implementation. The React team is aware that these restrictions are likely to cause issues, so they’ve also shipped an eslint plugin to help you not break things. Despite these things, I do believe the React community is going to greatly benefit from the compositional patterns hooks open up.
Side Note: To be fair, hooks are no more composable than the render props pattern — you just get to avoid nesting in the component tree and to write something that looks more like JS. Which is great. But components like <state> and <effect> could easily be created. And imagine if you could have used render props without nesting as suggested by Jamie Kyle.
For the past year or so I’ve been toying with some pretty drastic changes to Marko with the goal of reducing Marko’s API surface to just language features and a set of core components — essentially dropping the class. I’ve been calling this concept Marko X. I’ve also been driving the rest of the team insane constantly throwing out terrible ideas.
The primary challenge, of course, is how to represent state and lifecycle events. On the state side of things, I’ve looked at a number of different approaches including observables, a reducer-like api (but I didn’t want another api), proxied values (including some crazy ideas to have primitive proxies/pass primitives by reference), and watching the values in a scope.
That last one — watching the values in a scope — is the one that has intrigued me the most. It’s a bit out there, but I’m not the only one thinking this way. And we could do it, because as Rich Harris aptly put it, “WE’RE A COMPILER, MOFOS.”
To illustrate what that might look like in Marko X:
That looks quite beautiful, but the beauty kinda starts to fade when you think about how to extract functionality. Let’s say I want to extract the increment out into a separate function. Because the scope is the source of truth, I need to pass data from the scope to the function and set it back into the scope.
And I can’t just receive data from a source such as geolocation or network status. I’d have to listen for events and set data back into the outer scope. Which is a bit more convoluted than, for example, an equivalent custom hook.
Wouldn’t it be great if there were a better, framework-independent way to represent a value over time? At this point I think I can hear Ben Lesh and the reactive streams community crying, “Observables! We’ve been saying this for years!”
“observables are used extensively within Angular, and are recommended for app development as well” — Angular Docs
Back to Marko X, then we’ll get to the point
However, assuming that timeObservable is emitting Date objects, I might want to format it differently than the default toString. And to do that, I would need to operate on the reactive stream.
Now, there’s a number of concepts and APIs a developer would have to understand to parse the above code — and I don’t think that’s ideal. Functional Reactive Programming (FRP) requires thinking about problems in a different way, and I believe there’s a reason why this programming model hasn’t seen mass adoption despite being such a powerful paradigm.
The current generation of UI frameworks makes dealing with state quite approachable. The developer only concerns themselves with the current state. And it would be ideal if we could treat observables as values that happen to change over time rather than bringing FRP into it all.
With a combination of compiler transforms and proxies, we could maaaybe get that working. It would be much easier if we knew statically which values were observables. And it would probably be clearer to a developer reading the code as well. We could introduce a new keyword called observe.
That is, until last week
Reading through the React Hooks announcement, and thinking about how this could be implemented in a framework-agnostic way, such that “hooks” could actually be shared across frameworks, it became clear: Observables are the answer (maybe 😉).
Here’s how the state hook could be implemented as an observable:
Nice! I mean… that component got kinda nested though. Let’s tackle that from the outside in.
First up, observeComponent. This function creates a component that knows how to deal with the observable that is returned from the inner function. However this could be eliminated if React knew how to handle an observable returned from a render function.
Which leads us to the inner function: pipe, map, lambda — there’s still a bunch of FRP going on here. And this would get more complicated as additional state values, etc. were added. In fact, the nesting looks somewhat similar to how things would have to be done with render props.
But what if just like Promises got async/await, Observables had something similar: observable/observe.
Whoa. That looks a lot like hooks. 🔥
Side Note: This would essentially be syntactic sugar that provides the functionality of the combineLatest and map operators. For other operations, you would still need to use a library function, much like with Promises, you still have Promise.all, Promise.race and user-land apis from packages like Bluebird.
I wrote up some rough notes the night of the hooks announcement. You can take a look there for some of my initial thoughts on this. Ignore the Marko X stuff at the end… 🙈
I’d also mention that, again, I’m not the only person thinking about these things. A couple days ago Paul Gray posted an article proposing an alternate design for hooks. The core of the design he’s proposing is similar in many ways to what I’m proposing here, although he’s approaching it purely from a React perspective and his proposed syntactical sugar is different. I’d definitely recommend giving it a read.
Oh, and did I mention he’s got a babel-plugin for his syntax already?
Now, let’s take this full circle and crank up the sweetness 🍭
You could see how we could then de-sugar a let statement in an observable function into creating a new state value and observing it. Anywhere in this scope that variable is assigned to, that would de-sugar into a call to the update function.
You could see how template-based languages like Svelte and Marko could have their rendering logic implicitly wrapped in an observable function. That means this reactive behavior wouldn’t be SvelteScript or MarkoScript… It’s Just JS.
As far as completely replacing hooks with framework-independent observable functions, there are still certain hooks that need access to data provided by the framework to function:
- useContext needs knowledge of the tree in which it is rendered.
These hooks could still provide observables, but when being created they would need knowledge of where React was in the render tree. Depending on the implementation this could bring back some of the limitations of hooks.
Demo (hacky, fragile, and limited — but kinda works)
The above demo is a poorly implemented babel plugin with many limitations that shouldn’t be there. But it’s cool to see it kinda working.
Thanks to Dylan Piercey for getting the plugin started and basically being a walking babel api reference. I apologize for the state this ended up in...
The reactive programming wikipedia page has a pretty good overview of the challenges faced by reactive programming languages.
- Do we allow cyclic dependencies? If so, how do we allow breaking the cycle?
- When a value updates, do all expressions in the observable function re-evaluate or only the ones that depended on the value? That is, do we implicitly memoize things?
- If there is implicit memoization, what are the rules around that and how can the developer be explicit?
- When observables depend on other observables, how do we update them without tearing them down and recreating them?
- Many more.
There’s definitely a number of challenges associated with introducing a language feature such as this, and at this point I probably have more questions than answers.
What do you think? Is this the future or a terrible idea? Leave a comment or shoot me a tweet (@mlrawlings).