erikras.com
HomeAbout

JavaScript Unsubscribe Sugar Proposal

I think it's fair to say that JavaScript is an "event-driven" language.

Posted in JavaScript
September 14, 2022 - 4 min read
JavaScript Unsubscribe Sugar Proposal

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 ons.

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 Promises 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.

Discuss on Twitter

© 2024 Erik Rasmussen