Cancelable async tasks
Let's begin with the question of why do we at all need to cancel async tasks. One argument is that if you've made a server request, it's already out there, and there isn't much point in aborting it. Let's take a look at a real-life example.
Suppose you have a regular OTP code authentication flow. The user starts on a page like this:
Then they enter an email, hit "Email Sign In Code", and end up on this page:
The normal flow then is the user enters the code, we send a request with that code to the server, the server responds with a Set-Cookie header, the user is now authenticated and we redirect them client-side to the app's dashboard. But what happens if while the request with the code is still in flight, the user realizes they actually want to sign in under a different email, and hit the "Go Back" button? Well, we have here an example of when we definitely should actually abort the request at the browser level, else the user will receive the wrong cookie.
A little while ago I wrote a primitive called LazyPromise which has an api that's only superficially different from that of a native Promise, but which is cancelable. A LazyPromise has a method .subscribe(handleValue, handleError) that returns a disposal handle. Since I've recently had to port an app from React to Solid, I wrote the glue between LazyPromise and Solid, in particular a function useLazyPromise that passes the disposal handle to Solid's onCleanup, so you can write
// `pipe(x, foo)` is the same as `foo(x)`.
pipe(
myLazyPromise,
useLazyPromise(handleValue, handleError);
);
and be guaranteed that the async task will be canceled as soon as the Solid scope is disposed.
A good question to ask at this point is why would you do something as adventurous as a DIY promise, when you could just write a Solid-specific wrapper around the native AbortSignal that you would use like this:
useAbortablePromise(async (abortSignal) => {
// fetch things
});
Well, the thing is that LazyPromise can be seen as simply such wrapper. There is a utility lazy that turns an async function into a LazyPromise, and you can write
pipe(
lazy(async (abortSignal) => {
// fetch things
}),
useLazyPromise(handleValue, handleError),
);
If you are using functions like async (abortSignal) => ..., you are already essentially dealing in lazy promises, and if you use actual LazyPromise objects, you get composability and a couple of benefits that I'll cover in the rest of this article.
Typed errors
In my experience, it's been extremely useful to have the type system reflect errors that a server endpoint throws, because they all throw slightly different sets of errors and it's necessary to make sure that the right errors are handled on the client side. On the server I use the regular async-await, but if there is an error (user not authenticated, not authorized, 404 etc.), I return an object {__error: ...} so the result type is Data | {__error: Error} (if you prefer you could instead return {data: Data} | {error: Error}). The client talks to the server via TRPC, and there is a wrapper that turns a TRPC response into a LazyPromise<Data, Error> (gist), so you could write something like
pipe(
trpcLazyPromise(api.authn.checkOtpCode.mutate)(<params>),
useLazyPromise(undefined, (error) => {
// `error` is typed.
})
);
You could in theory keep the return type of the server endpoint as Data | {__error: Error} and this way capture the error type without resorting to a non-native promise, but it would break things like Promise.all, and more importantly, will make the code harder to read.
There's a library called Effect in React land which I haven't used, and I think typed errors is its major selling point. Here you get typed errors, but with a primitive-based approach where yes, you don't get the native Promise and async-await, but rather than a whole new API to learn, you get LazyPromise API which in spirit maps one-to-one to the native Promise.
Bonus: no microtaks
We've covered above two ways a LazyPromise differs from a native Promise (laziness/cancelability and typed errors), but there is a third and final one: LazyPromise fires synchronously instead of in a microtask. That works really well with SolidJS. We've looked above at the utility useLazyPromise that just subscribes to a LazyPromise and which you'd typically use with mutations, but there's also one called useLazyPromiseValue which is like a primitive version of Solid's createResource and which you'd use with queries:
// `value` is an accessor first returns a Symbol loadingSymbol, then
// the value that the lazy promise resolves to.
const value = useLazyPromiseValue(() => getLazyPromise(mySignal()));
The fun thing is what happens when the lazy promise resolves synchronously. This:
// `resolved` is like Promise.resolve.
useLazyPromiseValue(() => resolved(mySignal()))
behaves in exact same way diamond-problem-wise as this:
useMemo(() => mySignal())
Conclusion
If anyone actually reads this article, let me know your thoughts in the comments, in particular I'm curious how this will play with the new async signals - I didn't get a chance to watch Ryan Carniato's streams lately. LazyPromise is a very well unit-tested (including for memory leaks) and stable enough library (source, introductory article). The SolidJS bindings are completely experimental and are available here.


Top comments (0)