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

async set atom remains cached #521

Closed
fkocovski opened this issue Jun 15, 2021 · 23 comments · Fixed by #537
Closed

async set atom remains cached #521

fkocovski opened this issue Jun 15, 2021 · 23 comments · Fixed by #537

Comments

@fkocovski
Copy link

I have an async atom that looks like follows

export const someAsyncAtom = atom(
    async (get) => {
        const {data} = await getData();
        return data;
    },
    async (get, set, update) => {
        if (update) {
            set(someAsyncAtom, update);
        } else {
            const {data} = await getData();
            set(someAsyncAtom, data);
        }
    }
);

Now up until v0.16.8 if I wanted to imperatively refresh my data I could use something like

const updateData = useUpdateAtom(someAsyncAtom)

However starting with v.0.16.9 after calling updateData every component that has

const data = useAtomValue(someAsyncAtom)

has now stale data that dates back to before calling the imperative refresh. Any ideas? This is a breaking change for our application.

@dai-shi
Copy link
Member

dai-shi commented Jun 15, 2021

Sorry for the confusion.

jotai/src/core/vanilla.ts

Lines 440 to 441 in 39bc8b3

// NOTE technically possible but restricted as it may cause bugs
throw new Error('no atom init')

It's prohibited now. You don't get this error?

The canonical pattern is to use two atoms. Or, can you try atomWithDefault?

@fkocovski
Copy link
Author

Ouch :(

No such warning on my side. If this really is prohibited now it would require a tremendous refactoring on our end. Do you think there might be a workaround?

What would be the actual pattern with atomWithDefault? Sadly going with 2 atoms might prove difficult in our case, since the data contained by that async atom is really central for many components.

Thank you for responding so quickly!

@dai-shi
Copy link
Member

dai-shi commented Jun 15, 2021

Actually, atomWithDefault is implemented with 2 atoms internally.

import { atomWithDefault } from 'jotai/utils';

export const someAsyncAtom = atomWithDefault(
    async (get) => {
        const {data} = await getData();
        return data;
    },
    async (get, set, update) => {
        if (update) {
            set(someAsyncAtom, update);
        } else {
            const {data} = await getData();
            set(someAsyncAtom, data);
        }
    }
);

this is supposed to work.

@fkocovski
Copy link
Author

How stable is the atomWithDefault API? Do you have plans for a short term refactoring or breaking changes in this regard? I'm trying to evaluate the required effort for refactoring since it would involve quite some components that relied on this approach.

Moreover I am still trying to understand the actual logic of atomWithDefault. Would you care to elaborate a little bit? I'd love to understand better how this works before implementing it.

Thank you so much for the amazing work and congrats on v1.0.0

@dai-shi
Copy link
Member

dai-shi commented Jun 16, 2021

I think it's complete. Please check the source code if you are interested in how it's implemented.

It works like PrimitiveAtom, but the initial value is specified by read which tracks dependencies.
Once it's overwritten, it behaves like a normal primitive atom, and no more tracking.

Yeah, I'd rather encourage reading the code, and you might have an idea for some variants for your purpose.

@fkocovski
Copy link
Author

Ok I will have a look at the implementation of atomWithDefault. The thing I don't really get is why is such a behaviour dangerous for a primitive atom?

@fkocovski
Copy link
Author

Actually, atomWithDefault is implemented with 2 atoms internally.

import { atomWithDefault } from 'jotai/utils';

export const someAsyncAtom = atomWithDefault(
    async (get) => {
        const {data} = await getData();
        return data;
    },
    async (get, set, update) => {
        if (update) {
            set(someAsyncAtom, update);
        } else {
            const {data} = await getData();
            set(someAsyncAtom, data);
        }
    }
);

this is supposed to work.

When I try to implement it like this I first get a warning in my IDE that atomWithDefault only expects 1 argument, i.e. no write part, and then when I call the update function with useUpdateAtom it never gets invoked. Am I doing something wrong there?

@dai-shi
Copy link
Member

dai-shi commented Jun 16, 2021

Oops, that was my bad. So, it has to be something like this.

const baseAtom = atomWithDefault(
    async (get) => {
        const {data} = await getData();
        return data;
    },
)
export const someAsyncAtom = atomWithDefault(
    (get) => get(baseAtom),
    async (get, set, update) => {
        if (update) {
            set(baseAtom, update);
        } else {
            const {data} = await getData();
            set(baseAtom, data);
        }
    }
);

It may not seem ideal, but does this at least work?

@fkocovski
Copy link
Author

Don't you mean to use a normal atom for the const someAsyncAtom? Otherwise the problem still remains the same.

@fkocovski
Copy link
Author

Ok so I tested it with v1.0.0 and the following code and it appears to be working

const baseAtom = atomWithDefault(
    async (get) => {
        const {data} = await getData();
        return data;
    },
)
export const someAsyncAtom = atom(
    (get) => get(baseAtom),
    async (get, set, update) => {
        if (update) {
            set(baseAtom, update);
        } else {
            const {data} = await getData();
            set(baseAtom, data);
        }
    }
);

However we have 30+ such cases scattered across our code base, I really can't have a duplication of all those atoms :( Is there any other more elegant way that we could keep it backwards compatible?

@dai-shi
Copy link
Member

dai-shi commented Jun 16, 2021

Well, I mean it's composable. You can create your own util on your end.

const atomWithDefaultAndWrite = (read, write) => {
  const baseAtom = atomWithDefault(read)
  const derivedAtom = atom((get) => get(baseAtom), write)
  return derivedAtom
}

Oh, this doesn't work...


const atomWithGetData = (getData) => {
  const baseAtom = atomWithDefault(
    async (get) => {
      const {data} = await getData()
      return data
    },
  )
  const derivedAtom = atom(
    (get) => get(baseAtom),
    async (get, set, update) => {
      if (update) {
        set(baseAtom, update)
      } else {
        const {data} = await getData()
        set(baseAtom, data)
      }
    },
  )
  return derivedAtom
}

I hope this works for you with this pattern.
(And, if so, we can improve it by embedding atomWithDefault impl.)

@fkocovski
Copy link
Author

Ok I see your point. I think however that it would be nice if there was a kind of "out-of-the-box" solution for this use case, as I reckon it might be quite frequent. Just my 2C ;) I will try your approach with the custom solution and report back. Many thanks!

@dai-shi
Copy link
Member

dai-shi commented Jun 17, 2021

Yeah, after writing the last snippet, I felt the same. This use case is slightly different from atomWithDefault, so a dedicated util would be nice. Your feedback is appreciated. Would love to hear how your solution goes.

@dai-shi
Copy link
Member

dai-shi commented Jun 17, 2021

cc @aulneau

@fkocovski
Copy link
Author

Ok so I took a look at our current atoms and the most frequent use case is as follows:

  1. Initially get data in async fashion by accessing other atoms' data.
  2. The write part can be divided in 2 parts:
  • Imperatively call function that refreshes the data. This is basically the same call as done in the initial get part
  • Manually pass data that was queried from somewhere else and just set data passed to atom

Basically this is very similar to the functions offered by useRequest of ahooks, specifically the useRequest hook which exposes different methods.

I think it would be very nice to have something like atomWithAsync and an accessor like useAsyncAtom that gives you 3 references back, 1 for the data, 1 to mutate the data manually and 1 to refresh the data by calling the same definition of the get part.

const [data,mutate,refresh] = useAsyncAtom(someAsyncAtom)

// here you have access to the data
data.map(d=>d.title)

// here you can manually mutate the data if needed. This wouldn't trigger suspense for example
mutate([{name:1,title:1},{name:2,title:2},{name:3,title:3}])

// here you can manually invoke a refresh. I would expose this part explicitly in the definition of the atom so that the refresh part can be customized
refresh()

What do you think about this?

@dai-shi
Copy link
Member

dai-shi commented Jun 17, 2021

  1. this doesn't have to be async
  2. i prefer not to depend on hooks

I think the implementation will be the combination of atomWithReset and atomWithDefault.

I'm not sure what it should be called but the implementation is something like this:

import { atom } from 'jotai'
import type { Atom, PrimitiveAtom, WritableAtom, SetStateAction } from 'jotai'

type Read<Value> = Atom<Value>['read']

export const REFRESH = Symbol()

export function atomWithRefresh<Value>(getValue: Read<Value>) {
  type Update = SetStateAction<Value> | typeof REFRESH
  const EMPTY = Symbol()
  const overwrittenAtom = atom<Value | typeof EMPTY>(EMPTY)
  const anAtom = atom<Value, Update>(
    (get) => {
      const overwritten = get(overwrittenAtom)
      if (overwritten !== EMPTY) {
        return overwritten
      }
      return getValue(get)
    },
    (get, set, update) => {
      if (update === REFRESH) {
        set(anAtom, getValue(get))
      } else {
        set(
          anAtom,
          typeof update === 'function'
            ? (update as (prev: Value) => Value)(get(anAtom))
            : update
        )
      }
    }
  )
  return anAtom as WritableAtom<Value, Update>
}

@dai-shi
Copy link
Member

dai-shi commented Jun 17, 2021

Do you want to make the value change if dependencies are changed after refresh? Or you need control on it?

@dai-shi
Copy link
Member

dai-shi commented Jun 20, 2021

The previous one is violating a rule. Here's the fixed one.

import { atom } from 'jotai'
import type { Atom, PrimitiveAtom, WritableAtom, SetStateAction } from 'jotai'

type Read<Value> = Atom<Value>['read']

export const REFRESH = Symbol()

export function atomWithRefresh<Value>(getValue: Read<Value>) {
  type Update = SetStateAction<Value> | typeof REFRESH
  const EMPTY = Symbol()
  const overwrittenAtom = atom<Value | typeof EMPTY>(EMPTY)
  const anAtom = atom<Value, Update>(
    (get) => {
      const overwritten = get(overwrittenAtom)
      if (overwritten !== EMPTY) {
        return overwritten
      }
      return getValue(get)
    },
    (get, set, update) => {
      if (update === REFRESH) {
        set(overwrittenAtom, EMPTY)
      } else {
        set(
          anAtom,
          typeof update === 'function'
            ? (update as (prev: Value) => Value)(get(anAtom))
            : update
        )
      }
    }
  )
  return anAtom as WritableAtom<Value, Update>
}

Now, I think this is just an extension to atomWithDefault.

@LucaColonnello
Copy link
Contributor

@dai-shi amazing work here!!
I have exactly the same issue on a project of mine, having to refresh an async atom after a mutation to the same data in a data source (in my case IndexedDB).

Although I have a dependency, an ID basically, which sits in another atom, which I use to grab from my IndexedDB key value store the data in an async fashion. I need to refresh the data when I write to the store, although the ID hasn't changed.

Will the above utility react to dependencies tracked in the get function of the async readable atom?

@dai-shi
Copy link
Member

dai-shi commented Jun 21, 2021

@LucaColonnello
#537 fix is to refresh the atom after overridden. Yes, it does track the get function.
If you need to imperatively update the value, you want to simply override it.
Please give it a try. If you need a further help, a small code snippet would help.

@LucaColonnello
Copy link
Contributor

LucaColonnello commented Jun 21, 2021

@dai-shi sorry to SPAM the issue here, but this is soooo powerful!
https://codesandbox.io/s/jotai-overwrites-async-atoms-7nn3v?file=/src/App.js

Question is, am I doing it right? XD
(I'll open a new issue if required)

@dai-shi
Copy link
Member

dai-shi commented Jun 21, 2021

It looks good. You are not really using the power of atomWithDefault in the example, are you?
Yeah, you may want to open a new issue or discussion.

@Ding-Fan
Copy link

I'll link the document here to save others time 😃

https://jotai.org/docs/advanced-recipes/atom-creators#atom-with-refresh

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

Successfully merging a pull request may close this issue.

4 participants