First things first. We need a better way to manage reactive data. So there's the proposal for Observables.
Observables provide a built-in way to create an object that can pass multiple values over time and know how to clean up after themselves.
This is great, but the API is kinda... bleh. In the same way Promises are kinda bleh. There's callback functions, custom error handling, etc.
let subscription = observable.subscribe({
next(val) { console.log("Received data: " + val) },
error(err) { console.log("Received an error: " + err) },
complete() { console.log("Stream complete") },
});
// some time later...
subscription.unsubscribe();
But what if... there was an async
/await
equivalent for Observables? observable
/observe
if you will:
observable function distanceTo(targetLocation) {
const currentLocation = observe navigator.geolocation;
return computeDistance(targetLocation, currentLocation.coords);
}
// Maths below. The above is the important thing for this discussion.
function computeDistance(target, current) {
const earthRadius = 6371000;
const φ1 = toRadians(target.latitude);
const φ2 = toRadians(current.latitude);
const Δφ = toRadians(current.latitude - target.latitude);
const Δλ = toRadians(current.longitude - target.longitude);
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return earthRadius * c;
}
function toRadians (angle) {
return angle * (Math.PI / 180);
}
The above example probably didn't need to get so mathy. But it illustrates pretty well how this works.
- The
observable
keyword works much likeasync
does. It means the function will return an observable. Even if it doesn't actually observe anything. - The
observe
keyword indicates a value to be watched. It will get the observable by callingSymbol.observable
. In this example, we assume that this property has been added to certain core web apis that provide watching functionality, likenavigator.geolocation
. - Any time the watched value emits a new value, the return value of the function will be recalculated.
- Observables know how to clean up after themselves, and this is no different. When this observable is cleaned up it will automatically clean up any observables it is observing.
- Errors are automatically propagated, can be caught with
try
/catch
.
So that's pretty cool, we can call distanceTo(targetLocation)
and get an Observable back. As I walk around, it will emit new distances. Functionally, those two lines above are somewhat equivalent to the following:
function distanceTo(targetLocation) {
return Observable(observer => {
const subscription = Observable.from(navigator.geolocation).subscribe({
next(currentLocation) {
observer.next(computeDistance(targetLocation, currentLocation.coords));
},
error(err) {
observer.error(err);
},
complete() {
// the geolocation is the only thing that can cause this to update
// so if it were to complete (it won't, but whatever), this would also be complete
observer.complete();
}
});
return () => subscription.unsubscribe();
});
}
Where this gets even more powerful is in our ability to observe multiple things. Let's say I have a function that observes my friend's location (friendGeolocation
). I could combine this with distanceFrom
to watch how far we are at any given time:
observable function distanceToFriend() {
const friendLocation = observe friendGeolocation();
return observe distanceTo(friendLocation.coords);
}
Now whenever either one of us moves, this observable will report the new distance between us! Easily composed observables. Pretty cool.
Now. Observables are naturally async, so it would make sense that we could call other async functions from them. That's right, you can also await
inside an observable
function.
observable function userData() {
const user = observe loggedInUser();
return await fetchUserData(user.id);
}
observable function Counter() {
const count = observe counter();
return <div>{count}</div>;
}
It's like React's state hook, but better because it's not tied to a framework.
What if all scope in a observable
function was watched?
observable function counter() {
let count = 0;
setInterval(() => count++, 1000);
return count;
}
☝️ TODO: figure out how to clean up that interval
Okay, so, the above is pretty cool, and we're going to bring that into the template:
<template({ count = 0 })>
`The current count is ${count}.`
<button on-click=(() => count++)>
"Click Me"
</button>
</template>
But what are these string nodes and why are they here? I'm glad you asked. Text must be represented as a string. Because javascript can go anywhere.
<template>
const count = observe counter();
<div>
`${count}`
try {
<error-prone-component/>
} catch(e) {
"Yeah, that didn't work"
}
</div>
</template>
But inside a <template>
, things work a little bit different than the outside JS world. For one, everything is memoized. And await
only kinda awaits. It actually continues immediately. But the value is undefined
(or an unresolved value proxy thing). When it resolves any parts of the template relying on it will be updated.
<template({ friendId })>
const friend = await getUserData(friendId);
<div>
if (friend) {
`Hello ${friend.name}!`
} else {
`Waiting...`
}
</div>
</template>
Syntax highlight, because why not?
Actual magic: if the template does not check if the value is resolved and tries to access it when it is not, it will pause and throw a loading thing. This can be caught somewhere higher up in the tree:
<template>
try {
<component-that-may-need-to-load/>
} loading {
"Yeah, it's not ready yet."
} catch(error) {
"Yeah, it's failed to load."
}
</template>