The introduction of async/await to Javascript has made it easy to express complex workflows that string together multiple asynchronous tasks. Let's take a look at the following code, which is a generalized example of code I've seen in real projects:
const useClient = (user) => {
const [client, setClient] = useState(null);
useEffect(() => {
(async () => {
const clientAuthToken = await fetchClientToken(user);
const connection = await createWebsocketConnection();
const client = await createClient(connection, clientAuthToken);
setClient(client);
})();
}, [user]);
useEffect(() => {
return () => {
client.disconnect();
};
}, [client]);
return client;
};
It's easy to look at that and think it's all rosy. When we are passed a user, we create a client for them, and then whenever a client is disposed of through the user changing, or the component unmounting, we disconnect the client.
However, we have not considered that the asynchronous workflow in the first useEffect
is running concurrently to the rest of the application, which is independently responding to other effects and user actions. Any one of those other effects could unmount our component at any point! If the component is unmounted before, setClient
is called the client will still be created — Promises do not get cancelled just because their caller no longer exists — but without a component to manage the state setting or cleanup, it will never disconnect. This is usually quite bad.
So what do we do about it? Well, it's complicated. At first glance, it looks like we can do the following, and things will be OK:
const useClient = (user) => {
const [client, setClient] = useState(null);
useEffect(() => {
let client;
(async () => {
const clientAuthToken = await fetchClientToken(user);
const connection = await createWebsocketConnection();
client = await createClient(connection, clientAuthToken);
setClient(client);
})();
return () => {
client?.disconnect();
};
}, [user]);
return client;
};
Now, if the client has been created, it will disconnect without it needing to be saved to component state. Right?
Wrong, unfortunately. If the cleanup function runs before createClient
resolves, there will be no client to clean up. However, the promise is still resolving, and the client will be created, once again putting it outside of our reach!
If we really want to be able to safely use async workflows inside useEffect
, we need to make our workflow cancellable at any point. We also need to reason through what needs to be cleaned up, depending on what stage the workflow was in when the interruption arrived. Here is an example:
const useClient = (user) => {
const [client, setClient] = useState(null);
useEffect(() => {
let cancelled;
(async () => {
const clientAuthToken = await fetchClientToken(user);
// if cancelled before we get to creating resources
// it's ok, just don't create them
if (cancelled) return;
const connection = await createWebsocketConnection();
// if cancelled before we get to the client, we need
// to make sure our connection isn't left hanging
if (cancelled) {
connection.close();
return;
}
const client = await createClient(connection, clientAuthToken);
// if cancelled after the client has been created, we
// need to clean it up
if (cancelled) {
client.disconnect();
return;
}
setClient(client);
})();
return () => {
cancelled = true;
};
}, [user]);
useEffect(() => {
return () => {
client.disconnect();
};
}, [client]);
return client;
};
If you're struggling to understand where to put cancellation handlers, imagine you were writing this with promises instead of async/await. We have to handle cancellation at the beginning of every .then
callback:
const useClient = (user) => {
const [client, setClient] = useState(null);
useEffect(() => {
let cancelled;
fetchClientToken(user).then((clientAuthToken) => {
if (cancelled) return;
createWebsocketConnection().then((connection) => {
if (cancelled) {
connection.close();
return;
}
createClient(connection, clientAuthToken).then((client) => {
if (cancelled) {
client.disconnect();
return;
}
setClient(client);
});
});
});
return () => {
cancelled = true;
};
}, [user]);
useEffect(() => {
return () => {
client.disconnect();
};
}, [client]);
return client;
};
The above is why I sometimes shy away from async/await in UI code entirely. The async/await syntax blurs the line between synchronous (not interruptible) and asynchronous (interruptible) code. That's the point! It is very helpful in contexts where synchronous and asynchronous code should be treated similarly — like in a backend server executing a linear workflow — but dangerously misleading in contexts where interruptions are common, and handling them explicitly becomes necessary.
There are, of course, more sophisticated ways of dealing with the problem of resource management that make the implicit state machine above more explicit and controllable. I will leave an implementation in xstate as an exercise for the reader, but it's one example of a useful tool to reason through, and model these multi-step interruptible processes. However, it's good to have a barebones, just-React solution in your back pocket in case you find yourself unexpectedly facing a dangerous Promise in a foreign project.