jotai icon indicating copy to clipboard operation
jotai copied to clipboard

Discussion: How to cancel derived async atom?

Open AjaxSolutions opened this issue 5 years ago • 16 comments

This question is very similar to this Recoil issue.

https://github.com/facebookexperimental/Recoil/issues/51

AjaxSolutions avatar Nov 15 '20 15:11 AjaxSolutions

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.

dai-shi avatar Nov 16 '20 00:11 dai-shi

Thanks, but how would I abort the request when the component unmounts?

AjaxSolutions avatar Nov 16 '20 00:11 AjaxSolutions

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.

dai-shi avatar Nov 16 '20 01:11 dai-shi

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.)

dai-shi avatar Nov 16 '20 02:11 dai-shi

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.

AjaxSolutions avatar Nov 16 '20 13:11 AjaxSolutions

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.)

dai-shi avatar Nov 16 '20 14:11 dai-shi

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 avatar Mar 10 '21 18:03 tarngerine

@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.

Aslemammad avatar Mar 10 '21 18:03 Aslemammad

If the condition is only from the atom (previous) value, equalityFn in selectAtom might help.

dai-shi avatar Mar 10 '21 23:03 dai-shi

I think it's mostly answered. Please open a new discussion if necessary.

dai-shi avatar May 03 '21 06:05 dai-shi

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.

dai-shi avatar Apr 12 '22 01:04 dai-shi

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);
});

hirokibeta avatar Apr 13 '22 19:04 hirokibeta

Looks nice.

Just a small nit: return () => controller.abort();

dai-shi avatar Apr 14 '22 02:04 dai-shi

Oops. Thanks, I'll fix that.

hirokibeta avatar Apr 14 '22 04:04 hirokibeta

@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 ?

TwistedMinda avatar May 20 '22 17:05 TwistedMinda

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.

dai-shi avatar May 20 '22 23:05 dai-shi