Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

loadable API for using async atoms without suspense #672

Closed
Pinpickle opened this issue Aug 22, 2021 · 32 comments · Fixed by #698
Closed

loadable API for using async atoms without suspense #672

Pinpickle opened this issue Aug 22, 2021 · 32 comments · Fixed by #698
Labels
enhancement New feature or request

Comments

@Pinpickle
Copy link
Contributor

Pinpickle commented Aug 22, 2021

Prior discussion: #269

Recoil has a useRecoilValueLoadable for bypassing suspense and returning a "loadable" object. This is useful in some cirumstances when suspense is not desired, and is also much easier than implementing it via the method recommended in the Jotai docs.

No-suspense code example
const fetchResultAtom = atom({ loading: true, error: null, data: null })
const runFetchAtom = atom(
  (get) => get(fetchResultAtom),
  (_get, set, url) => {
    const fetchData = async () => {
      set(fetchResultAtom, (prev) => ({ ...prev, loading: true }))
      try {
        const response = await fetch(url)
        const data = await response.json()
        set(fetchResultAtom, { loading: false, error: null, data })
      } catch (error) {
        set(fetchResultAtom, { loading: false, error, data: null })
      }
    }
    fetchData()
  }
)
runFetchAtom.onMount = (runFetch) => {
  runFetch('https://json.host.com')
}

const Component = () => {
  const [result] = useAtom(runFetchAtom)

  console.log(result) // { loading: ..., error: ..., data: ... }
  return <div>...</div>
}

I'm proposing a read-only loadable util which returns an atom that converts an async atom into a sync atom, just like Recoil's loadable utils.

Depending on context, consumers may want errors to still be thrown or captured into the loadable object. There are two ways of handling this.

1. Separate suspendable/loadable APIs

const SUSPENDED = Symbol("jotai/atomSuspended");

type Loadable<T, TError = any> = { type: "loading" } | { type: "value", value: T } | { type: "error", value: TError };

// will still throw on error
const suspendable = <T>(atom<Promise<T> | T>) => T | SUSPENDED;

// will not throw on error
const loadable = <T>(atom<Promise<T> | T>) => Loadable<T>;

This has the advantage of calling a single function without parameters whether you want errors to be thrown or not. The suspendable type signature is also much simpler, as we don't need to be able to disambiguate between value and error types.

2. Param in the loadable function

function loadable<Value, Update>(
  atom: WritableAtom<Value, Update>
): WritableAtom<{ loading: true; data?: Value } | { data: Value } | { error: unknown }, Update>

function loadable<Value, Update>(
  atom: WritableAtom<Value, Update>,
  throwsError: boolean
): WritableAtom<{ loading: true; data?: Value } | { data: Value }, Update>

function loadable<Value>(
  atom: Atom<Value>
): Atom<{ loading: true; data?: Value } | { data: Value } | { error: unknown }>

function loadable<Value, Update>(
  atom: Atom<Value, Update>,
  throwsError: boolean
): Atom<{ loading: true; data?: Value } | { data: Value }>

This has the advantage of there only being one function. However, a flag with a default value means one option will be more convenient than another.


Outstanding questions:

  • Which API to choose?
  • Should this support writable atoms in the first implementation? Or can that wait?
@dai-shi
Copy link
Member

dai-shi commented Aug 22, 2021

Thanks for opening this up.

  • Which API to choose?

I still believe most people want to avoid ErrorBoundary if they are not ready for Suspense.

I generally like symbols, so suspendable signature looks neat. But, what it gives for typing seems trivial.

// with symbol
if (value === LoadingSymbol) {
 return <>Loading</>
}
return <>{value.text}</>

// with loadable object
if (value.loading) {
  return <>loading</>
}
return <>{value.data.text}</>

Or, am I missing something?

  • Should this support writable atoms in the first implementation? Or can that wait?

I thought it would be easy to implement for writable atoms, because it's just delegating.
However, I can actually change my mind. Because we can still use the original value for writing, we don't need to create writable atoms. It's cleaner in the implementation and the usage.
If one wants to create a custom hook, it requires an extra step.

// with writable atom support
const useLoadableAtom = (anAtom) => useAtom(lodable(anAtom))

// without writable atom support
const useLodableAtom = (anAtom) => [
  useAtomValue(lodable(anAtom))
  useUpdateAtom(anAtom)
]

Not a big deal? I'm fine with only supporting Atom<Value>.

@dai-shi
Copy link
Member

dai-shi commented Aug 22, 2021

I still believe most people want to avoid ErrorBoundary if they are not ready for Suspense.

Otherwise, we make suspendable() with catchesErrorToo option? Or, catchable()?

I wish we get some more feedback from someone.
Does recoil have suspendable() like feature?

@Pinpickle
Copy link
Contributor Author

Or, am I missing something?

I don't think you are, there really isn't that much more typing with a loadable type API. It's just that in TS you'd need to do a check for errors even if you know your promise won't throw.

I still believe most people want to avoid ErrorBoundary if they are not ready for Suspense.

The way I see it, if an atom throws (which may be due to a bug, not just a fetch failing for example), it'd be weird that the error wouldn't propagate to the component in the same way useSelector does in react-redux for example. Basically, as a developer, I'd expect errors to propagate all the way up the stack unless I put an explicit catch in.

Loadables make sense when the expected error conditions are expected, like fetching data. Having them return errors in the event of bugs feels weird to me.

Furthermore, error boundaries are an officially supported API recommended by React, suspense is still communicated as experimental by the React team.

Does recoil have suspendable() like feature?

It doesn't, true!

@Pinpickle
Copy link
Contributor Author

@dai-shi if you're still not convinced, I'm happy to go with a pure loadable approach.

It'd be pretty trivial to write my own util using loadable:

const suspendable = (anAtom) => {
  const loadableAtom = loadable(anAtom);

  if (anAtom.loading) {
    return SUSPENDED;
  }

  if (anAtom.error) {
    throw anAtom.error;
  }

  return anAtom.value;
}

@dai-shi
Copy link
Member

dai-shi commented Aug 22, 2021

Furthermore, error boundaries are an officially supported API recommended by React, suspense is still communicated as experimental by the React team.

Yes, this is true.

if you're still not convinced, I'm happy to go with a pure loadable approach.

Yeah, I feel like we should go with the pure loadable.

It'd be pretty trivial to write my own util using loadable:

This looks nice! And, it's actually convincing that loadable is a good primitive. We can show this in the docs.


So, the signature will be:

function loadable<Value>(
  atom: Atom<Value>
): Atom<{ loading: true } | { loading: false; hasError: true; error: unknown } | { loading: false; hasError: false; data: Data }>

typescript playgroud

Hm, we could follow reocil's api: https://recoiljs.org/docs/api-reference/core/useRecoilValueLoadable

function loadable<Value>(
  atom: Atom<Value>
): Atom<{ state: 'loading' } | { state: 'hasError'; error: unknown } | { state: 'hasData'; data: Data }>

what do you think?

@Pinpickle
Copy link
Contributor Author

Hm, we could follow reocil's api: https://recoiljs.org/docs/api-reference/core/useRecoilValueLoadable

I think following Recoil's API here is a good idea, personally.

@dai-shi
Copy link
Member

dai-shi commented Aug 22, 2021

I think following Recoil's API here is a good idea, personally.

cool.

We could make it closer, but it can't be really the same, so { state: 'loading' } | { state: 'hasError'; error: unknown } | { state: 'hasData'; data: Data } would be good.

@aulneau
Copy link
Collaborator

aulneau commented Aug 22, 2021

This makes me so happy to see! I think this will enable a lot more usage from folks who maybe were hesitant due to suspense only support.

@etodanik
Copy link
Contributor

etodanik commented Aug 26, 2021

This is extremely exciting to see! I totally second @aulneau's sentiments. This is massively useful even for apps where Suspense is fully embraced, but a few use-cases don't fit any existing design pattern for Suspense.

As for readable/writeable - for now all the real use cases I had encountered in my production app only involve readable atoms (e.g suspending something on initial cache load but then refreshing the cache in the background without suspending, feed background refetching from graphql, pull-to-refresh in React Native and a few more similar tasks) - so there's real utility even in a readable only solution.

Right now, because of the complexity that a non-suspense atom introduces, I opted out of Jotai for those parts of my code, and I do something similar to this (in this use-case I am caching JWK files for oAuth JWT token validation to allow my app to have the authentication public key tokens be rotated safely without incurring the cost of waiting for an HTTP request every time I launch the app):

/**
 * This will fetch a new copy of the JWKS and put it into AsyncStorage for the next launch. If keys are rotated
 * this will automatically make the auth token fail signature verification.
 */
export const useToken = (): Auth0Token | null => {
  const [auth] = useAtom(authAtom);
  const [cachedJwks, setCachedJwks] = useAtom(cachedJwksAtom);
  const { data } = useQuery("freshJwks", () =>
    fetch(jwkUrl).then(async (res) => {
      const json = await res.json();
      setCachedJwks(json.keys);
      return json.keys;
    })
  );
  const jwks: Auth0Jwk[] = data ? data : cachedJwks;
  // ... the rest of the code

The main drawback of this approach of course is that it makes it harder to derive anything further down the line from this kind of hook. But if there was an easy way to make some atoms opt out of suspense, it would make this kind of use-case possible.

@dai-shi
Copy link
Member

dai-shi commented Aug 26, 2021

cool.

Seems like I can get some feedback on a subtle design issue.

Option A (which I believe we reached an agreement with)

function loadable<Value>(
  atom: Atom<Value>
): Atom<{ state: 'loading' } | { state: 'hasError'; error: unknown } | { state: 'hasData'; data: Data }>

Option B (supporting stale value)

function loadable<Value>(
  atom: Atom<Value>
): Atom<{ state: 'loading', data?: Data } | { state: 'hasError'; error: unknown } | { state: 'hasData'; data: Data }>

At initial fetch, it's { state: 'loading' }, and when refetching, { state: 'loading', data: <stale data> }.
I'm not 100% sure if it's possible to implement this.

@aulneau
Copy link
Collaborator

aulneau commented Aug 26, 2021

Would we be open to a third option?

function loadable<Value>(
  atom: Atom<Value>
): Atom<{ state: 'loading', data?: Data; error?: unknown } | { state: 'hasError'; error: unknown; data?: Data } | { state: 'hasData'; data: Data }>

This is most helpful in that it supports the "Stale while revalidate" pattern that is becoming more popular (and very useful). If we went this route, applications would be able to choose if they wanted to show any of the states:

  • loading no data
  • loading in background with stale data
  • loading in background with error
  • etc

This would be very similar to what react-query does (they keep the data key filled while doing other operations), and I believe useSWR too.

@dai-shi
Copy link
Member

dai-shi commented Aug 26, 2021

I'm open for that. (I may change my mind when I see the implementation, though.)

@Pinpickle
Copy link
Contributor Author

@dai-shi @aulneau My understanding of Jotai internals is nascent, but I don't think "stale while revalidate" is possible with an implementation as simple as in #269.

At least, the answer in #199 here seems to indicate that.

We'd need atom getters to support retrieving stale data, which sounds like a pretty big feature.

That being said, I'd love for this to be possible.

@dai-shi
Copy link
Member

dai-shi commented Aug 26, 2021

@Pinpickle
I don't mean to change any of jotai core for this. This should be 100% outside.
I thought it might be possible with a mutable variable (it's really a hack. undocumented usage).
Why won't you start implementing without "stale while revalidate" feature? Then we'll see what we can do or if it's impossible.

@dai-shi
Copy link
Member

dai-shi commented Sep 9, 2021

@Pinpickle Just checking if you are still around.

@dai-shi dai-shi added the enhancement New feature or request label Sep 9, 2021
@Pinpickle
Copy link
Contributor Author

@dai-shi I finally got a chance to take a crack at this, but ran into an issue with error edge cases. Could you take a look at #698 ?

@neckaros
Copy link

neckaros commented Dec 6, 2021

found this by chance but it's so great!! 💝
It so needs to be in the official doc !

@dai-shi
Copy link
Member

dai-shi commented Dec 6, 2021

@Pinpickle Hope you get time for it. We have now docs in the same repo.

@Pinpickle
Copy link
Contributor Author

@dai-shi I haven't forgotten this, just super busy at the moment. If this isn't done by the time I get some time to do so, I certainly will!

@jahglow
Copy link
Contributor

jahglow commented Mar 18, 2022

@Pinpickle Maybe anytime soon???

@TwistedMinda
Copy link
Collaborator

TwistedMinda commented May 21, 2022

@dai-shi Can't find any docs on loadable! It's available right?
EDIT: Yeah, works like a charm.

It currently feels weird to have "No Suspense" separated from the "Async part", especially given the tricks inside it :D
I guess it's time to use the "Async" section of the docs to replace the good old "No Suspense" by a fresh suspense/loadable experience.

Async section would look like:

  • kick-off: atomWithPending (async then normal, as many times as you want)

    • quick example with suspense
    • quick example with loadable, mention that it can be used the same way for any async atom
    • quick mention (+ link) to the atomWithAsyncStorage that uses the same concept
  • full async methods

    • async get example (use word "async selector" somehow so reader can relate)
    • async write example (use word "action" somehow so reader can relate)
    • async get-write example (i can't even relate to this)
  • common practices

    • suspense-everywhere
    • preloading (mention it fits well with atomWithAsyncStorage)

What u think?

@dai-shi
Copy link
Member

dai-shi commented May 21, 2022

It's available right?

It's not yet available.

It currently feels weird to have "No Suspense" separated from the "Async part"

I think it was written before we had loadable. So, I agree that it's (already) time to revisit.

kick-off: atomWithPending

This is not what I would expect.
We want to educate async patterns in this order.

  1. atom(async (get) => ...)
  2. atom(..., async (get, set, arg) => ...)
  3. atom(new Promise(...)) and set(new Promise(...))
  4. and finally ,atom(infinitePending) (infinitePending is new Promise(() => {}))

btw, I really appreciate your work on these.

@TwistedMinda
Copy link
Collaborator

Great, I'm totally okay with this order!
However, when will it be available officially ? Are there still problems with it ?

When I tried it seemed to work like a charm!

Happy be able to help!

@dai-shi
Copy link
Member

dai-shi commented May 21, 2022

However, when will it be available officially ?

Oops, I got it wrong. The loadable is available. It's just the docs that are not available.

@Bournegit
Copy link

Can loadable be used with an async writable atom?

@dai-shi
Copy link
Member

dai-shi commented Jun 15, 2022

loadable returns a read-only atom, so you need to derive it again.

const baseAsyncWritableAtom = atom(...)
const readOnlyLoadableAtom = loadable(baseAsyncWritableAtom)
export const writableLoadableAtom = atom(
  (get) => get(readOnlyLoadableAtom),
  (_get, set, arg) => set(baseAsyncWritableAtom, arg)
)

@Bournegit
Copy link

Bournegit commented Jun 15, 2022

My use case is I am loading async data and need to update it later on also and use loadable not suspense. Not sure if it is meant to be used that way. The problem here is I am not able to update the data again .For Example - If i update the data using RefreshControl.
I am getting this error - atom.write' is undefined

import { atom,useAtom } from 'jotai';
import axios from 'axios';
import {loadable} from 'jotai/utils'


const fetchGroup = async () => {
return await axios({
        method: 'get',
        url: `/v1/groups`,
    }).then((response) => {
        return response.data;
    }).catch(function (error) {
     
        return {error:error}
    });;
}


const fetchGroupAtom= atom(
async (get) => {
    const response = await fetchGroup();
   return response;
  
});

const readOnlyLoadableAtom = loadable(fetchGroupAtom)
const writableLoadableAtom = atom(
  (get) => get(readOnlyLoadableAtom),
  async (_get, set, arg) =>{ 
      const response = await fetchGroup();
    set(fetchGroupAtom, response)

}
)

export const useGroup = () => {
    
    const [groups,updateGroups] = useAtom(writableLoadableAtom);
    
    return [groups,updateGroups];
  };

@dai-shi
Copy link
Member

dai-shi commented Jun 15, 2022

atomWithDefault is to rescue.

const fetchGroupAtom= atomWithDefault(async (get) => {
  const response = await fetchGroup();
  return response;
});

@Bournegit
Copy link

This worked. Yet to explore all the utils and use cases. Also explored how ### atomWithDefault works.
Thank you.

@0xtiki
Copy link

0xtiki commented Apr 21, 2023

atomWithDefault is to rescue.

const fetchGroupAtom= atomWithDefault(async (get) => {
  const response = await fetchGroup();
  return response;
});

I am using this with an axios fetch function similar to the fetchGroup() above. When I update the writableLoadableAtom and the fetch fails it throws an error instead of setting the atom to hasError. If I catch the error within the fetch function (as in fetchGroup() above), writableLoadableAtom ends up with 'hasData' but data is undefined. Is there a way to make it update in a way that it doesn't throw but sets hasError?

@dai-shi
Copy link
Member

dai-shi commented Apr 21, 2023

Not sure if I follow 100%, but in general there would be two alternative approaches:

  • store a promise itself in an atom instead of the resolved value
  • do not use loadable and do similar things on your end

Hope it helps.

@0xtiki
Copy link

0xtiki commented Apr 22, 2023

Not sure if I follow 100%, but in general there would be two alternative approaches:

  • store a promise itself in an atom instead of the resolved value
  • do not use loadable and do similar things on your end

Hope it helps.

Ok I found a workaround. Thanks for the prompt response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

9 participants