React's useEffect
forces you to implement a quite powerful pattern: the cleanup function pattern (or teardown function pattern).
Let's see how we could leverage this pattern to provide perfect encapsulation and make deceptive calls impossible.
The cleanup function pattern
The idea is quite simple: A function that triggers a long-lived side effect returns another function to teardown this effect.
The pattern goes as follows:
function effect() {
// trigger some long lived effect (listening to a websocket, registering DOM event listeners...)
return function cleanupEffect() {
// cleanup the long lived effect
};
}
React developers will immediately recognize the useEffect
pattern.
useEffect(() => {
// trigger some long lived effect
return () => {
// cleanup
};
});
This pattern is not so fancy nor new! It has been around in Reactive Programming libraries for a little while. RxJS' subscribe
method follows this pattern, for example.
The main difference between React and RxJS (or others), is that RxJS implements the cleanup function pattern while React requires you to implement the pattern.
Benefits of the cleanup function pattern
There are two main takeaways to this pattern:
- it brings perfect encapsulation;
- it makes deceptive calls impossible.
Let's take a very common API that could benefit from the cleanup function pattern: the famous (add|remove)EventListener
DOM API.
A traditional usage of this API would look something like this:
const sendPayload = () => {
/*...*/
};
const button = document.getElementById("the-button");
button.addEventListener("click", sendPayload);
// ... later on
button.removeEventListener("click", sendPayload);
How about we try to implement our own addListener
function that implements the cleanup function pattern?
function addListener(element, event, callback) {
element.addEventListener(event, callback);
return () => {
element.removeEventListener(event, callback);
};
}
Perfect encapsulation
One of the problems of the button.addEventListener
is that you need to keep track of the DOM element (button
), the event name (click
) and the callback (sendPayload
) in order to be able to remove this listener.
Lose one of those ingredients, and your click
listener is here to stay!
Thanks to our custom addListener
function that we implemented earlier, a single reference is needed in order to remove the listener:
const sendPayload = () => {
/*...*/
};
const button = document.getElementById("the-button");
const removeListener = addListener(button, "click", sendPayload);
// ^ removeListener is the only reference we need to keep
removeListener();
Now, the references to the button, the event name and the callback are encapsulated in the cleanup function. The only thing we need to keep track of is the removeListener
function.
So we could even inline everything
const removeListener = addListener(
document.getElementById("the-button"),
"click",
() => {}
);
⚠️ I would not suggest inlining code like the previous code sample. This example is there just to highlight that it is possible.
Preventing deceptive calls
Let's consider the following samples:
const sendPayload = () => {};
button.removeEventListener("click", sendPayload);
// ❌ Useless call, the listener has not been added yet (temporal coupling)
button.addEventListener("click", sendPayload);
button.removeEventListener("click", () => sendPayload());
// ❌ The listener is not removed because the reference to the function has changed
button.removeEventListener("auxclick", sendPayload);
// ❌ this is useless since we never added a `auxclick` listener
Some of those calls are not only useless, they are deceptive. You could feel like you have removed the listener, but in fact it's still there and active.
Those calls are made impossible with the cleanup function pattern!
If you do not have the reference to the cleanupFunction
that you first created by triggering the effect, there is nothing you can do!
We are preventing what is called a temporal coupling.
A concrete example: Listening to a WebSocket
Let's implement a naive module that subscribes to WebSocket events and forwards them to the consumer:
let ws;
export function listen(onEvent) {
ws = new WebSocket("wss://example.com");
ws.onmessage = onEvent;
}
export function close() {
if (ws) {
ws.close();
ws = undefined;
}
}
A few things to note:
- the
close
function can be called even if the connection is not active; - the module needs to be stateful and keep track of
ws
; - we need to perform some checks before we can safely close (
if (ws)
); - if we call
listen
multiple times there will be instances that cannot be killed anymore, the reference tows
is lost forever.
Now, with our newly discovered pattern
export function listen(onEvent) {
const ws = new WebSocket("wss://example.com");
ws.onmessage = onEvent;
return () => {
ws.close();
};
}
With the added benefit that the code is now more concise, everything is isolated, this module doesn't need to keep a local state, and you just cannot close
until you have actually opened the connection.
Conclusion
Next time you are dealing with a long-lived effect, try to play with the idea of returning the function that will kill the effect, and see how it works for you.
I lately implemented it for Wire's webapp connection to the WebSocket, and I am totally sold 🚀