Thanks for sharing Recoil with the React community, it looks great. Having worked previously with libraries like RxJs one of the first things I look for with async state is the ability to cancel tasks. From what I can tell, and please do correct me if I am wrong, is that this is not currently supported. Raising a ticket to discuss if it could be part of a future version of Recoil 馃榾
Use case:
An asynchronous selector makes a network request using the Fetch API. When a selector's dependancies changes and a new request is made then the previous request could ideally be aborted to avoid unnecessary work.
const currentUserInfo = selector({
key: 'CurrentUserInfo',
get: async ({get}) => {
// If the 'currentUserIDState' atom changes this request is not canceled
const req = await fetch(`/user?id=${get(currentUserIDState)}`);
return await req.json();
},
});
Related APIs:
The function passed to React.useEffect may return a clean-up function.
Note: Recoil can't follow this exact same pattern right now because the current API expects a promise to be returned.
Possible approach:
Pass a cleanup function to the selector's get. Passing a function to cleanup would register it ready to be called by recoil when the selector is _invalidated_.
const currentUserInfo = selector({
key: 'CurrentUserInfo',
get: async ({get, cleanup}) => {
const controller = new AbortController();
const signal = controller.signal;
cleanup(() => controller.abort());
const req = await fetch(`/user?id=${get(currentUserIDState)}`, {signal});
return await req.json();
},
});
Alternatives:
Instead of having built-in support for cancelation it might be possible to implement cancelation in user space. This would stop the get being a pure function so is possibly breaking a "rule of recoil" and therefore not safe.
EDIT: This approach is not safe https://github.com/facebookexperimental/Recoil/issues/51#issuecomment-629747819 馃敟
const currentUserInfo = (() => {
let cleanup = null;
return selector({
key: 'CurrentUserInfo',
get: async ({get}) => {
cleanup?.();
const controller = new AbortController();
const signal = controller.signal;
cleanup = () => controller.abort();
const req = await fetch(`/user?id=${get(currentUserIDState)}`, {signal});
return await req.json();
},
});
})();
Thanks for reading!
Yes, you're right that cancelation could be implemented in the user code. Recoil tries to be minimal and not too opinionated on how the underlying queries are implemented, just working directly with simple Promises.
But, a potential concern with your particular example, though, is that it is depending on state outside of Recoil. The Recoil state is associated with the containing <RecoilRoot> for the particular React rendering tree that it's executing for. With React concurrent rendering there may be multiple trees executing that have different state values for the currentUserIDState atom.
Another problem is that the selector evaluation function may execute multiple times until it reaches completion. When it gets to a get that is still pending it may execute the beginning of the evaluation function again when it thinks the dependency may be available. In your case, if the current user ID state was itself asynchronous then the construction of the AbortController may happen multiple times. In this case it happens before the request, but if it happened after the request then re-execution would end up incorrectly canceling the first request.
So, yes, not being pure is breaking a rule. Though, we do make allowances for "caching". There are similar complications with trying to register cleanup handlers as part of the selector API. State consistency and, well, if the point is side-effects, then the selector isn't pure.
Query cancelation is likely an important use-case for many users, though, and deserves more thought on how to cleanly support it.
Query cancelation is likely an important use-case for many users, though, and deserves more thought on how to cleanly support it.
It certainly is! It would be a bit surprising if this became the recommended state management solution for React without fitting React like a glove, with respect to async cancellation. Obviously you鈥檙e not allowing async selectors to set state on unmounted components. Instead, pending Promises are being orphaned and swept under the rug, potentially leading to unnecessary resource exhaustion.
Overall, this is a great initiative. State management certainly needs a shake-up.
Thank you for your detailed reply @drarmstr, very insightful.
If I am following correctly, the selector's get follows the same pattern seen in Suspense. Nested gets to other Selectors may throw if they are still pending just like useRecoilValue would. With this in mind I can see how user-implemented cancelation would not work. I'll edit my OP to make this clear to any future readers.
I think the cancelation of an asynchronous selector isn't Recoil's task.
because 1- it doesn't know what kind of operation is doing in its async selector (and shouldn't know).
and 2- a Recoil-Official-Async-Cancelation is not necessary when we can do this inside fetch/axion and so on.
Recoil maybe just do something stuff when it's resolved async data is on the air.
I suspect the long-term answer for this is to tie in with our caching strategy. Basically, you want to cancel a request when it's still ongoing and you would otherwise expunge it.
In our apps, we haven't wanted to cancel requests. This enables nice behaviors: For example, you can navigate to some state, which causes a request to begin, then while you're waiting you can go somewhere else in the app. When you return, your request will have finished and you'll be able to see the data immediately. Since Recoil was created for a desktop app that issued lots of slow queries, this was a good default.
The reason to cancel requests is because of resource constraints, right? Then you'll also want to be expunging unneeded data from cache. I know that in Relay, they expunge query results immediately when the QueryRenderer component unmounts.
This is when you'd want to cancel the request, right? When something is no longer needed, you expunge it from cache (if already finished) or cancel it (if ongoing).
Search-as-you-type is an example of where requests should be cancelled, even after debouncing. And you鈥檙e unlikely to get a lot of cache hits.
1- it doesn't know what kind of operation is doing in its async selector (and shouldn't know).
There鈥檚 prior art in React: useEffect. The callback passed to useEffect can return an unsubscribe function, which can cancel pending requests and perform other resource cleanup. This standard is also used in Svelte and Bacon.js, for example. The difference is that the callback would need to take a sink function for providing the asynchronous result. It can be adapted to any async abstractions, such as promises, observables and callbacks.
Hi @davidmccabe,
I suspect the long-term answer for this is to tie in with our caching strategy
This sounds very sensible. The requirements for the two are likely to be highly aligned.
The reason to cancel requests is because of resource constraints, right?
Yep, purely for resource constraints in mobile/embedded apps is what I was imagining.
It feels like Recoil's visibility of which selectors are currently in-use _could_ be a good source of information to feed into request/cache garbage collection.
So my current understanding is that there's no safe way to "clean up" asynchronous selectors? Or is there a valid work around until it's officially supported?
Namely I need to be able to abort fetches.
We have an idea for an API, but that is pending more work on memory management and cleanup.
I also like how react-query allows us to pass a cancel fn on the promise object before returning the promise and when the result is stale it will call the cancel function on promise object.
Ref: https://react-query.tanstack.com/docs/guides/query-cancellation
[Edit]
I just realized this won't work when we call Promise.resolve(promiseWithCancelProperty)
So my current understanding is that there's no safe way to "clean up" asynchronous selectors? Or is there a valid work around until it's officially supported?
Namely I need to be able to abort fetches.
Is there any decent work around, or way to clean up async selectors then? and does anyone have an example of how they would do this? Is the best approach for now, to use useEffect on recoil state changes, and update state accordingly. This will kinda render selectors useless for me in some cases.
I need to be able to abort fetches as well.
You are re-inventing rxjs's switchMap, why not just use rxjs and stop using this lib once and for all? And why this library not choose observable which can carry more information (like subscription) than Promise? There's no need to re-invent observable.
Most helpful comment
Search-as-you-type is an example of where requests should be cancelled, even after debouncing. And you鈥檙e unlikely to get a lot of cache hits.