Recoil: How to refresh/invalidate an asynchronous selector?

Selectors can be used to asynchronously fetch data from an API. But how is it possible to trigger a re-fetch of this data?

Given this selector:

const todosState = selector({
  key: "todosState",
  get: async () => {
    const result = await fetch("https://example.com/todos");
    const todos = await result.json();
    return todos;
  },
});

In a scenario where a user wanted to reload his todos, because he knows his coworker added a new todo to the list. How would he trigger this selector to re-fetch the todos from the API?

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 16
  • Comments: 28 (7 by maintainers)

Most upvoted comments

@acutmore Thanks for your suggestion. Although your code works as intended, it feels more like a workaround instead of an official solution. I genuinely thought this update could be triggered using the useResetRecoilState() function.

An obvious way to make this less of a workaround would be to simply use an atom and handle the update manually. However, I really like the concept of the Data Flow Graph mentioned in the doc, which makes data fetching really declarative and implicit for the consumer of the selector.

How about adding a re-evaluate feature to the API, either with useResetRecoilState() or with a new function in the selector options?

Hi @philippta. I agree, does feels more like a workaround. Maybe someone from the Recoil team will have a more official solution.

I had another go using useResetRecoilState. Calling reset sets an Atom’s value back to the default: ... value in the Atom’s config, or calls a selector’s set passing in Recoil’s DefaultValue. I had a go and selectors do not seem to support async set. So this gets back to synchronously updating an atom to force a selector’s async get to re-run.

const todosTrigger = atom({
  key: "todosTrigger",
  default: 0
});

const todosSelector = selector({
  key: "todosSelector",
  get: async ({ get }) => {
    get(todosTrigger);
    return await getTodos();
  },
  set: ({ set }, value) => {
    if (value instanceof Recoil.DefaultValue) {
      set(todosTrigger, v => v + 1);
    }
  }
});

function Todos() {
  const todos = useRecoilValue(todosSelector);
  const reset = useResetRecoilState(todosSelector);
  console.log(todos);
  return <button onClick={() => reset()}>Reset</button>;
}

Hi @philippta. Selectors are re-run when an atom/selector it uses (depends on) changes. To force a selector to update you could do something like this:

const forceTodoUpdate = Recoil.atom({
  key: "forceTODO",
  default: 0,
});

const todosState = selector({
  key: "todosState",
  get: async ({ get }) => {
    get(forceTodoUpdate); // 'register' forceTodoUpdate as a dependency
    const result = await fetch("https://example.com/todos");
    const todos = await result.json();
    return todos;
  },
});

function Component() {
  const todoUpdates = useSetRecoilState(forceTodoUpdate);
  const forceUpdate = () => todoUpdates((n) => n + 1);
  const todos = useRecoilValue(todosState);

  // rest of component here
}

Hi @philippta. I agree, does feels more like a workaround. Maybe someone from the Recoil team will have a more official solution.

I had another go using useResetRecoilState. Calling reset sets an Atom’s value back to the default: ... value in the Atom’s config, or calls a selector’s set passing in Recoil’s DefaultValue. I had a go and selectors do not seem to support async set. So this gets back to synchronously updating an atom to force a selector’s async get to re-run.

const todosTrigger = atom({
  key: "todosTrigger",
  default: 0
});

const todosSelector = selector({
  key: "todosSelector",
  get: async ({ get }) => {
    get(todosTrigger);
    return await getTodos();
  },
  set: ({ set }, value) => {
    if (value instanceof Recoil.DefaultValue) {
      set(todosTrigger, v => v + 1);
    }
  }
});

function Todos() {
  const todos = useRecoilValue(todosSelector);
  const reset = useResetRecoilState(todosSelector);
  console.log(todos);
  return <button onClick={() => reset()}>Reset</button>;
}

Is this still the recommended approach? Still feels like a workaround. 😕We’re just trying out Recoil with a new project at the moment and we already have 3 of those triggers, which are becoming kind of unwieldy (even with calling them in the setter).

Having an API to re-run a selector would make more sense in my opinion. Something like an extension of the previously mentioned useResetRecoilState. That way the operation would be tied to a specific selector directly, like useResetRecoilState(mySelector), and not indirectly via a useSetRecoilState(mySelectorTrigger) or an implementation detail we need to add to the setter ourselves.

Since the library is pretty new and it’s our first time using it, maybe we’re also just using it incorrectly… Let’s imagine we have a table of entries, to which we like to apply filters. Our filters are atoms. We fetch the entries with an async selector and then get the entries and the filters in a separate selector, where we apply the filters to the entries. Then we use this filteredEntriesSelector in our component. This works perfect, until the user navigates away from the page (we use react-router, the page is not refreshed) and then back to the page again. Now he is being shown stale data, since the selector is not re-run again. Currently we solve this by running the trigger workaround, when the user clicks on the button that navigates to the page.

Are we doing this correctly and recoil is limited in that regard or are we using it incorrectly?

Atoms can currently have a default value that is an async Promise, however Promises are a one-shot concept for resolving to a value or error. You can reset an atom and it will revert to the value of that original default promise, either pending, resolved, or error. One thing I’m working on is working through a proposal to be able to subsequently set atoms to a new promise, so they could be used to store the results of a new query refresh by explicitly setting them to a new promise. So, instead of using “reset” you would set it to a promise for the new query.

Totally agree with @tobias-tengler! useSetRecoilState for selectors is what recoil lacks to be most perfect state management library. Recoil is already a beauty silver bullet, but using a trigger atom looks like a weird safety catch.

Selector refresher is now part of unstable API.

Maybe recoil can take some inspiration from ReactQuery, I’m not familiar with the details under the hood of both libraries but both Recoil and ReactQuery have “keys” ReactQuery uses them to invalidate queries and they get re-fetched automatically, maybe we can have something like this in recoil invalidateAtoms('Key') simlar to the ReactQuery invalidateQueries('Key')

@anhnch

The force-reload-atom pattern is working for me with Suspense in this sandbox:

https://codesandbox.io/s/stoic-star-l5eer?file=/src/App.js

Are you able to post a sandbox that replicates the error?

Yes I’ve double-checked. It works on React Native too. The problem is I have one redundant line const userData = useRecoilState(UserData) outside of the UserInfo component on the very top of the messy code. It works on the first rendering because userData already has a value that was set by the parent screen. When reloading, the const userData = useRecoilState(UserData) is really outside of Suspense. Thanks for your time.

Hi @atulmy. The warning you are seeing is a known issue, more details here: #12. For now it should be safe to ignore it.

I’m facing following warning when using above described method to force cache update:

Warning: Cannot update a component (`he`) while rendering a different component (`List`). To locate the bad setState() call inside `List`, follow the stack trace as described in https://fb.me/setstate-in-render
    in List (created by Context.Consumer)
    in Route (at Layout/index.js:43)
    in Switch (at Layout/index.js:32)
    in main (at Layout/index.js:27)
    in Router (created by BrowserRouter)
    in BrowserRouter (at Layout/index.js:20)
    in Layout (at src/index.js:13)
    in RecoilRoot (at src/index.js:12)
    in StrictMode (at src/index.js:11)
// auth
export const userAuth = atom({
  key: 'userAuth',
  default: userAuthDefault
})
// note list
export const noteList = selector({
  key: 'noteList',
  get: async ({ get }) => {
    // force update cached data based on user
    get(userAuth)

    let notes = []

    try {
      const { data } = await list()

      if(data && data.success) {
        notes = data.list
      }
    } catch (e) {
      console.log(e.message)
    }

    return notes
  }
})

Any idea what may be going wrong?

@acutmore 's example is exactly what I was going to suggest. Good work!

@acutmore How would you abort the fetch on unmount in this example? @AjaxSolutions

Good question. I do not know. I raised #51 to ask about how cancelation might work.

@acutmore How would you abort the fetch on unmount in this example?