Cancelable async tasks and typed server errors with SolidJS and LazyPromise
Source: Dev.to
Cancelable async tasks
Why do we need to cancel async tasks?
If you’ve made a server request, it’s already out there, but there are cases where aborting it in the browser is essential.
Example: OTP code authentication flow
- User enters an email and requests a sign‑in code.
- The app shows a page where the user enters the received code.
- The code is sent to the server; the server responds with a
Set‑Cookieheader, authenticating the user and redirecting them to the dashboard.
If the request with the code is still in flight and the user clicks Go Back to sign in with a different email, the pending request will eventually set the wrong cookie. In this situation we must abort the request at the browser level.
LazyPromise and Solid
A while ago I created a primitive called LazyPromise. Its API looks like a native Promise, but it is cancelable. A LazyPromise provides a .subscribe(handleValue, handleError) method that returns a disposal handle.
When porting an app from React to Solid, I wrote a glue function useLazyPromise that passes the disposal handle to Solid’s onCleanup, guaranteeing that the async task is canceled as soon as the Solid scope is disposed:
useLazyPromise(myLazyPromise, handleValue, handleError);
Why not just use AbortSignal?
You could wrap a native Promise with an AbortSignal:
useAbortablePromise(async (abortSignal) => {
// fetch things
});
LazyPromise can be seen as such a wrapper. The utility lazy turns an async function into a LazyPromise:
useLazyPromise(
lazy(async (abortSignal) => {
// fetch things
}),
handleValue,
handleError
);
When you write functions like async (abortSignal) => …, you’re already dealing with lazy promises. Using actual LazyPromise objects adds a few extra benefits discussed below.
Typed server errors
In my projects the type system reflects the errors a server endpoint can throw. Server handlers use regular async/await; on error they return an object { __error: … }, so the result type is Data | { __error: Error } (alternatively { data: Data } | { error: Error }).
The client communicates via tRPC, and a wrapper converts a tRPC response into a LazyPromise (gist).
const lazyPromise = trpcLazyPromise(api.authn.checkOtpCode.mutate)({
/* params */
});
lazyPromise is generic over Data and Error. Keeping the return type as Data | { __error: Error } would let you capture the error type without a custom promise, but it would break utilities like Promise.all and make the code harder to read.
Libraries such as Effect (React) also provide typed errors, but they require a completely new API. LazyPromise retains the familiar Promise‑like API while adding cancelability and typed errors.
Using LazyPromise with Solid
Laziness, cancelability, and typed errors
We’ve covered two ways LazyPromise differs from a native Promise:
- Laziness / cancelability – you can abort the task when the Solid scope is disposed.
- Typed errors – the error type is part of the promise’s generic parameters.
Synchronous resolution
A third difference: LazyPromise resolves synchronously (in the same tick) instead of in a microtask. This meshes well with Solid’s fine‑grained reactivity.
useLazyPromiseValue
useLazyPromiseValue is a primitive version of Solid’s createResource. It returns an accessor that first yields a loading symbol, then the resolved value.
// `value` is an accessor that first returns a Symbol loadingSymbol,
// then the resolved value.
const value = useLazyPromiseValue(() => getLazyPromise(mySignal()));
When the lazy promise resolves synchronously, the behavior mirrors useMemo:
// `resolved` is like Promise.resolve.
useLazyPromiseValue(() => resolved(mySignal()));
// behaves the same as:
useMemo(() => mySignal());
Conclusion
If you’re interested in trying this out, the LazyPromise library is well‑tested (including for memory leaks) and stable (source, introductory article). The SolidJS bindings are experimental but available here. I’d love to hear your thoughts in the comments, especially regarding how this approach will interact with the upcoming async signals feature.