jotai
jotai copied to clipboard
Discussion: How to cancel derived async atom?
This question is very similar to this Recoil issue.
https://github.com/facebookexperimental/Recoil/issues/51
JS doesn't have a cancellation api, so let's see how AbortController works.
import { atom } from 'jotai'
const abortableAtom = () => {
const { signal, abort } = new AbortController()
return atom(async (get) => {
abort()
const url = get(urlAtom)
const response = await fetch(url, { signal })
cont data = await response.json()
return data
})
}
Assuming this works, the question is how to generalize the read function. I'm not also 100% sure if we can re-use AbortController. abort() let the previous fetch throw an error, so we may need to catch it.
Thanks, but how would I abort the request when the component unmounts?
Good point... If we used the atom directly we could do something like,
const abortMap = new WeakMap()
const abortableAtom = () => {
const { signal, abort } = new AbortController()
const anAtom = atom(async (get) => {
abort()
const url = get(urlAtom)
const response = await fetch(url, { signal })
cont data = await response.json()
return data
})
abortMap.set(anAtom, abort)
return anAtom
}
const useAbortableAtom = (anAtom) => {
useEffect(() => {
return () => {
const abort = abortMap.get(anAtom)
if (abort) abort()
}
}, [anAtom])
return useAtom(anAtom)
}
but, this would not be very nice because other components (and dependency atoms) could be still using the same atom.
Another idea:
const urlAtom = atom('https://...')
const abortableAtom = () => {
const { signal, abort } = new AbortController()
return atom(async (get) => {
abort()
const url = get(urlAtom)
if (!url) return null
try {
const response = await fetch(url, { signal })
cont data = await response.json()
return data
} catch (e) {
return null
}
})
}
const Component = () => {
const [, setUrl] = useAtom(urlAtom)
useEffect(() => {
return () => {
setUrl('') // clear url and it leads to abort (but we need to confirm, can be race condition)
}
}, [setUrl])
return ...
}
Hmm, this doesn't look very intuitive.
Actually, I have concerned such situation and would like to tackle the problem. https://github.com/pmndrs/jotai/blob/b6ada607bd928e54214ebb00c5ef0838f53dfe2e/src/core/Provider.ts#L432-L435
So, maybe we attach a cancel function to a promise, and call it before discarding it (or just discard the promise if no cancel)
if (atomState.readP) {
if (atomState.readP.cancel) {
atomState.readP.cancel()
}
atomStateCache.set(atom, { ...atomState, readP: undefined })
} else {
atomStateCache.set(atom, atomState)
}
(On second thought, this may discard promises too aggressively. We need to collect more use cases and test specs.)
I'm not also 100% sure if we can re-use AbortController
AbortController can't be reused or reset. Thanks for taking time to answer my question. This Recoil issue is still being discussed.
AbortController can't be reused or reset.
So, it has to be something like this:
const abortableAtom = () => {
let abort = () => {}
return atom(async (get) => {
abort()
const controller = new AbortController()
abort = controller.abort
const url = get(urlAtom)
const response = await fetch(url, { signal: controller.signal })
cont data = await response.json()
return data
})
}
There's a caveat though. We can technically reuse atoms for multiple Providers. So, the singleton for an atom might not work as expected. I think it's better to abort fetch outside of atoms.
For unmount use case, I think it's better not to cancel promises. The mental model of Jotai is a big WeakMap. So, as long as an atom exists, its value should be available. It means even if it's unmounted, it will be remounted keeping the value (it does not re-evaluate read.)
hi, i have a similar need but rather than aborting i was thinking if it's possible for a (get) => {} to provide the previous atom value so i can just return that, e.g.
const atom = atom(...)
const derived = atom((get, previous) => { if(someCondition) return previous return newValue })
although i dont know if this would prevent a rerender
@tarngerine Check this:
const previousAtom = atom(0);
const valueAtom = atom(0);
const derivedAtom = atom(
(get) => {
const previousValue = get(previousAtom);
const value = get(valueAtom);
if (value > previousValue) return previousValue;
return value;
},
(get, set, update: number) => {
set(previousAtom, get(valueAtom));
set(valueAtom, update);
}
);
I didn't check this, but maybe the idea solves your problem.
EDIT: My previous solution has some problems. Now looks good.
If the condition is only from the atom (previous) value, equalityFn in selectAtom might help.
I think it's mostly answered. Please open a new discussion if necessary.
More than one year passed, I've got an actual implementation. #1091
It's not about manual cancellation, but cancellation on overwriting and on unmount.
The previous discussion is not valid because we didn't have onUnmount back then, but the discussion context seems valid, so let's re-open.
I like this pattern which uses onMount and atoms in atom. This pattern is useful not only for abortable cases but also for unsubscribe-able cases (like Firestore) or any other cancelable cases.
I don’t think it’s possible to implement this pattern with Recoil, because Recoil Effect is different from Jotai’s onMount.
const urlAtom = atom('https://...');
function createAbortableAtom(url) {
const abortableAtom = atom(null);
abortableAtom.onMount = (setAtom) => {
const controller = new AbortController();
const data = fetch(url, { signal: controller.signal }).then(({ json }) => json());
setAtom(data);
return () => controller.abort();
};
return abortableAtom;
}
const wrapperAtom = atom((get) => {
const url = get(urlAtom);
if (!url) return atom(null);
return createAbortableAtom(url);
});
Looks nice.
Just a small nit: return () => controller.abort();
Oops. Thanks, I'll fix that.
@dai-shi Does it need some documentation ? If I understand the usage correctly, it looks like this:
const urlAtom = atom<string | null>('https://jsonplaceholder.typicode.com/posts');
const wrapperAtom = atom((get) => {
const url = get(urlAtom);
if (!url) return atom(null);
return createAbortableAtom(url);
});
const ComponentWithCancellableRequest = () => {
const result = useAtomValue(useAtomValue(wrapperAtom))
console.log(result) // "null" if you clicked fast enough, otherwise data from the request
return (null)
}
const App = () => {
const cancel = useSetAtom(urlAtom)
const press = () => cancel(null)
return (
<View>
<React.Suspense fallback={<Text>Loading...</Text>}>
<ComponentWithCancellableRequest />
</React.Suspense>
<Button text="Cancel me" onPress={press} /> {/* Click me fast */}
</View>
)
}
EDIT: By the way, in my tests, the createAbortableAtom function was failing at the json transformation step:
const data = fetch(url, { signal: controller.signal }).then(({ json }) => json());
I fixed it by moving it like this:
const data = fetch(url, { signal: controller.signal }).then(res => res.json());
EDIT2: I didn't test if it can be used in a componentWillUnmount (or React.useEffect equivalent), but I read from your discussion that it's useless, and even better not to. What is the state of this statement ?
I think it is too technical to document for now.
We should actually document more about onMount first and its typical use cases.
Then, createAbortableAtom is going to be one of various advanced use cases.
The reason I reopened this issue is that I'm working on #1091. This should going to be a recommended method.