JavaScript Unsubscribe Sugar Proposal
I think it's fair to say that JavaScript is an "event-driven" language.
I think it's pretty fair to say that
JavaScript is an "event-driven" language.
As a JavaScript developer, I spend all day "subscribing" and "unsubscribing" to
events ("Follow" never caught
on), from the user/DOM, a database subscription, or literally any other actor in
the system. It is so easy, for even a veteran, senior, 10x developer to forget
to call unsubscribe()
. It's such an easy footgun. And so I ask myself,
How could we make it impossible to leak memory by forgetting to unsubscribe?
There are three primary ways that I see subscriptions happening in JavaScript.
addEventHandler / removeEventHandler
When you're down and dirty mucking about in the DOM, these are your tools. And
it's so awkward because of the instance-equality (===
) check on
removeEventHandler
.
div.addEventHandler("click", () => {alert("Nice click!");});// don't forget to unsubscribe!div.removeEventHandler("click", () => {alert("Nice click!");});
❌ WRONG! Those are different functions.
const onClick = () => {alert("Nice click!");};div.addEventHandler("click", onClick);// don't forget to unsubscribe!div.removeEventHandler("click", onClick);
✅ There ya go! You had to define the function before subscribing and unsubscribing!
on / off
In my experience, this feels more node-y to me, but I think the real culprit is
Our Holy Savior jQuery, that really popularized this syntax. It's less explicit
than addEventListener
/ removeEventListener
, but on
makes sense, and
"Gosh, what's the opposite of 'on'??"
const onClick = () => {alert("Nice click!");};div.on("click", onClick);// don't forget to unsubscribe!div.off("click", onClick);
It suffers from the same exact problem as addEventListener
/
removeEventListener
, namely that you've got to maintain the same instance...
and also remember to actually off
each of your on
s.
subscribe() returns unsubscribe()
This is the most elegant of the solutions, because it's so beautifully
functional, and you don't have to worry about instance-equality – the closures
do that for you. As a library developer, it's really comfortable to use. I first
noticed this with RxJS, and adopted it when I wrote
Final Form. Imagine my
delight when upgrading React Final Form to use React Hooks, and the API for
useEffect()
was exactly the same as my existing API!
However, the problem of remembering to unsubscribe is still ever-present!
(These examples are going to be React-y, but the issue remains throughout the JS ecosystem)
useEffect(() => {return subscribeToPosts((posts) => setPosts(posts));}, []);
It's not immediately obvious from this code that there's a function being returned there. And it's even more non-obvious if you eliminate the braces:
useEffect(() => subscribeToPosts((posts) => setPosts(posts)), []);
But what if you need multiple subscriptions in the same effect?
useEffect(() => {const unsubscribes = [];unsubscribes.push(subscribeToPosts((posts) => setPosts(posts)));unsubscribes.push(subscribeToUser((user) => setUser(user)));// ...return () => unsubscribes.forEach((unsubscribe) => unsubscribe());}, []);
I really wish there was a way for me to mark a function as:
I'm gonna subscribe to some stuff in here. Please return a function that unsubscribes!
Proposal
Remember what a pain working with Promise
s was before async
/await
? So
many .then()
s!!!
It occurred to me that it might be cool if we had something similar for the ubiquitous subscribe/unsubscribe pattern. I'm going to share the first thing that occurred to me, but I'd love for us, as a community, to iterate on it.
Best idea wins!
Sugar
sub
– marks a function as "imma subscribe to some stuff", sort of like
async
. In TypeScript land, it forces that the function return a () => void
unsub
– marks a line that returns an unsubscribe function
Examples
useEffect(sub () => {unsub subscribeToPosts((posts) => setPosts(posts))unsub subscribeToUser((user) => setUser(user))// Look, Ma! I'm returning nothing!}, [])
The syntax sugar will take care of establishing the array of unsubscribes
and
calling them all when the function completes, ideally inside of a finally
block.
I'd like it to special case the common handler systems of before.
useEffect(sub () => {document.addEventListener('keypress', () => { alert('Nice key!') })someThing.on('download', () => { alert('One file, coming right up!') })})
There must be a way for those of you, that understand AST better than I, to
transpile this into some JS that manages the requisite removeEventHandler
and
off
.
Conclusion
Of course, in the same way that your async
won't do you much good if you
forget the await
keyword, you still have to remember the unsub
keyword, you
can still shoot yourself in the foot, but I think a syntax like this might
remove a lot of boilerplate from typical JS code, and fix a lot of
yet-undetected bugs in our existing codebases.