Before we begin, Iâm intentionally avoiding loaded words found in stream libraries. If youâre familiar with another library already, try and spot where the concepts align.
Hi Rich! đ
1. Start with an addEventListener
document.addEventListener("click", event => {console.log(event)})
Believe it or not, weâre almost doneâŚ
2. Extract the callback
const callback = event => {console.log(event)}document.addEventListener("click", callback)
Getting closerâŚ
3. Wrap the Setup in a Function (so you can start it later)
const setup = cb => {document.addEventListener("click", cb)}const callback = event => {console.log(event)}setup(callback) //now you gotta call `setup` for anything to run
I can smell the finish lineâŚ
4. Wrap the Callback to Change Behavior
const setup = cb => {document.addEventListener("click", cb)}const callback = event => {console.log(event)}//A sweet util for overriding the callback behaviorconst pluckX = originalCallback => {const newCallback = event => {originalCallback(event.x)}return newCallback}setup(pluckX(callback))
Weâve made it! đ
I Thought This Was Supposed to Be HardâŚ
Rich, new patterns often drive people away because theyâre so comfy with their old patterns. But once a pattern becomes familiar, brains will start to see them everywhere! /insert âEverything is a Streamâ zen dog pic đ
That being said, letâs dive into the patterns:
1. Sources Need an API
Consider how different Web APIs are when working with our async tools:
addEventListener
returnsundefined
setInterval
returns an id- Promises return a promise instance
I mean, câmon, who designed these APIs? Letâs do something about it! Weâll start by wrapping an API around addEventListener
.
Weâll call these object with APIs a âsourceâ. So far, the only method on our API is setup
:
const fromEvent = (eventType, target) => {return {setup: cb => {target.addEventListener(eventType, cb)}}}
Now we can use our source like so:
const clickStream = fromEvent("click", document)clickStream.setup(event => console.log(event))
2. A Consistent API!
Letâs create a timer that will follow that same pattern. Wrap setInterval
with an object that has a setup
method:
const timer = delay => {return {setup: cb => {let i = 0setInterval(()=>{cb(i++)}, delay)}}}const timerStream = timer(1000)timerStream.setup(i => console.log(i))
You can follow this pattern for any other source of events, data, etc.
3. Our API Has a Nesting Problem
Nested callbacks have a long, tormented history in JS. So letâs see how streams approach the problem.
Weâll revisit our clickStream
and timerStream
, but wait to start the timer until we click.
Hereâs how weâre currently stuck doing it:
const clickStream = fromEvent("click", document)const timerStream = timer(1000)//Our redirect behaviorclickStream.setup(event => {timerStream.setup(i => console.log(i))})
This works fine, but its nested callbacks are showing đ. Letâs make this more streamy.
4. Changing Source Behavior with Wrappers
Hereâs a little wrapper âtemplateâ that shows how weâll approach our problem:
const streamWrapper = source => {//return an object with a `setup` method to match the shape of our sourcereturn {setup: callback => {//define custom behavior here using the `source` API and `callback`}}}
These functions are often called decorators/higher order functions/etc.
The key piece is that we can redefine how our source interacts with the callback.
To demonstrate, letâs write a wrapper that invokes the callback
twice:
const streamWrapper = source => {return {setup: callback => {source.setup(value => {callback(value)callback(value)})}}}const clickStream = fromEvent("click", document)streamWrapper(clickStream).setup(event => console.log(event))
5. But We Have Two Sources!
Yeah, I know. Chill, Rich. Itâs common to return a function to accept another source:
const wrapper = source => otherSource => {return {setup: callback => {//define custom behavior, but now with multiple source options!!!}}}
Our âclick then start timerâ behavior looks like this:
//Hey, look! It's our good friend "Nested Callback"setup: callback => {//Ignore the `value` (a MouseEvent) for now. We could use it if neededsource.setup(value => {otherSource.setup(callback)})}
So we can bring it all together in a wrapper that weâll aptly name redirect
:
const redirect = source => otherSource => {return {setup: callback => {source.setup(value => {otherSource.setup(callback)})}}}redirect(clickStream)(timerStream).setup(i => console.log(i))
6. A Fluent API
To some functional programmers, the previous example looks totally normal, but many other JS devs prefer a fluent API. So letâs support them (this is totally optional, but common).
Weâll need to add a way for sources to pass themselves into our source wrapper functions. This common JS trick looks is implemented in this thru
method:
return {setup: cb => {target.addEventListener(eventType, cb)},thru(fn){ //not an arrow fn, we need `this`!return fn(this) //`this` is the source}}
Now using thru
will enable of our wrapper functions will automatically receive the source. Compare the examples and pick your favorite (itâs a personal preference, no wrong answer):
//old exampleredirect(clickStream)(timerStream).setup(i => console.log(i))//new example with `thru`clickStream.thru(redirect(timerStream)).setup(i => console.log(i))
Note, thru
receives the main source
last, so we do have to swap the order in our redirect
:
//beforeconst redirect = source => otherSource =>//afterconst redirect = otherSource => source =>
Sidenote: Many FP devs would argue I should have had the main source last in the first example (and I agree), but I thought it would have caused more confusion than necessaryâŚ
đ We now have a consistent API which enables async sources to interoperate. Yay consistency! Yay interoperability!
Hey, John! You Keep Wrapping Callbacks to Override Their BehaviorâŚ
Good catch, Rich! Thatâs exactly what weâre doing! In fact, the foundation of Streams is passing in a callback with your desired behavior, then overriding it with all the async stuff you need.
đĄ Pass in a callback with your desired behavior, then wrap it and override with your async functionality
All Good ThingsâŚ
Hey, John. Your API is sorely lacking a way to remove events and stop timers. Youâre a big dummy!
Rich, thatâs not very nice. I was just waiting for the right moment to bring it up.
Letâs add a consistent way of cleaning up our sources. The easiest way I can think of is to have our setup
return a function to teardown
:
1. Teardown Time
//fromEventsetup: callback => {target.addEventListener(eventType, callback)return () => { //returning a teardown functiontarget.removeEventListener(eventType, callback)}}
So now our api looks like the following:
const clickStream = fromEvent("click", document)const teardown = clickStream.setup(event => console.log(event))/*| ̄ ̄ ̄ ̄ ̄ || THREE || HOURS || LATER...|| ďźżďźżďźżďźżďźż|(\__/) ||(â˘ă â˘) ||/ ă ăĽ*/teardown() //haha, no clicky for you, Rich! That's what you get for calling me names.
đ Yay for Consisent APIs to clean up after ourselves
2. The Callback Bundle. Done
is Better Than Perfect.
If youâve seen a generator in JS, you know they emit {value, done}
. So far, weâre only emitted values.
I can think of plenty of use cases where I want to know when timers, events, etc., stop.
Itâs a super important feature, so letâs add it in by bundling a done
callback with our main callback
:
//BEHOLD! An object with named callbacksconst bundle = {callback: event => console.log(event),done: () => console.log("done")}clickStream.setup(bundle)//using our bundlesetup: bundle => {target.addEventListener(eventType, bundle.callback)return () => {target.removeEventListener(eventType, bundle.callback)}}
âŚ
âŚ
John, I donât see done
being used anywhere!
Rich, youâre sharp as a tack!
Weâre going to use done
by wrapping our setup function. That way, we can always teardown
whenever done
is called:
export const setupWrapper = setup => bundle => {const teardown = setup({ //grab a ref to teardowndone: () => {if (teardown) teardown() //invoke teardown whenever `done` is calledif (bundle.done) bundle.done()},callback: bundle.callback //No custom behavior})return teardown}
Now letâs see it in action:
setup: setupWrapper(bundle => {target.addEventListener(eventType, bundle.callback)return () => {target.removeEventListener(eventType, bundle.callback)}})
John, wait a minute⌠Youâre still not invoking done
anywhere. You just changed the behavior!
Dang, I was hoping you wouldnât notice. Fine, time for some sweet demo action.
Remember how we made a behavior that invoked the callback
twice? This time, weâll replace
the second call with a call to done
which will stop our stream, notify us, and tear it down!
//We'll pass our source thru this bad boyconst once = source => {return {setup: setupWrapper(bundle => {//implement our new behavior. Handle one click, then done.const newBundle = {callback: event => {bundle.callback(event)bundle.done()},done: bundle.done}return source.setup(newBundle)})}}const clickStream = fromEvent("click", document).thru(once)const bundle = {callback: event => console.log(event),done: () => console.log("done...")}clickStream.setup(bundle)
With Our Patterns in Place, itâs DEMO TIME!
(If you skipped to this section, shame on you! This wonât make any sense!)
When you look at these util functions, youâll realize why most libraries pull them out into packages so you donât have to look at their internals đ
Combining Two Sources
Click to Start Timer, Keypress to Stop
Synchronize Two Sources
While these demos are simple, the potential to combine, synchronize, start, stop (or all of the above!) through a consistent API with any variety of source is very appealing.
Sidenote: Things I Intentionally Left Out
I ignored âSubjectsâ because theyâre too often abused for simple use cases. They combine the source and bundle APIs, allow control over multiple bundles, and enable you to mess with callbacks that are often best used as internals. That being said, they definitely have their place.
Error handling works on the same concept as
done
. Catch your error and propogate is through the chain of bundle calls.Libraries always ship a
create
util for sources. I intentionally left this out to keep in the repitition for learning purposes.Sources can also be objects, arrays, etc. (I included one in the Synchronize demo). Theyâre very simple and they donât teardown. I didnât mention them to avoid confusion.
Scheduling is an advanced topic where you can manipulate timing. Incredibly powerful feature, yet too edge-case to dive into.
TL;DR
- Build a consistent API around a chaotic JS async ecosystem
- Enable sources to interoperate
- Wrap callbacks to override their behavior with new async abilities
- Manages Nested Callbacks
- Automatically handles cleanup when things complete
Every piece of your Stream framework follows simple rules
- Source - setup/teardown
- Bundles - a basic object with named callbacks/dones
- Wrappers - wrap sources and bundles with desired behavior
- SetupWrappers - Enforce teardown when done is called