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?
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
}
@acutmore How would you abort the fetch on unmount in this example?
@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>;
}
@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 's example is exactly what I was going to suggest. Good work!
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?
Hi @atulmy. The warning you are seeing is a known issue, more details here: #12. For now it should be safe to ignore it.
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. Callingresetsets an Atom's value back to thedefault: ...value in the Atom's config, or calls a selector'ssetpassing in Recoil'sDefaultValue. 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?
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.
Are you using selectors here vs atoms because you want the automatic use of React Suspense for the pending state? If so, then would being able to set an atom to a pending Promise be seen as a cleaner solution for this pattern?
@drarmstr, your words are sweet as honey! But I can't use Promise as default now, isn't it?
In my (imaginary) application, atom gets data from the server. This state is used in many components, but user can refetch the default state. Right now, I see no way to do this other than creating a fetch counter. But I don't need a fetch counter in my application!
Are you using selectors here vs atoms because you want the automatic use of React Suspense for the pending state? If so, then would being able to set an atom to a pending Promise be seen as a cleaner solution for this pattern?
Yes, Suspense was the driving factor for using Recoil not only for State Management, but also for loading remote resources in our case.
Using the default atom value sounds like a good idea, but I can't get it working with useResetRecoilState: Example
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.
The ForceReload feels like a workaround but not an ugly solution. I've tried it with Suspense but the code didn't work. I can't figure out what the problem is.
const ForceReload = atom({
key: 'forcereload',
default: 0
})
const UserData = selector({
key: 'userdata',
get: async ({ get }) => {
get(ForceReload); // make UserData selector dependent
return await fetchAsyncData('http://example.com');
}
})
const Screen1 = props => {
const reload = useSetRecoilState(ForceReload);
const UserInfo = () => {
const user = useRecoilValue(UserData);
return <Text>{user.name}</Text>
}
return <>
<Suspense fallback={<Spinner />}>
<UserInfo />
</Suspense>
<Button onPress={() => reload(Math.random())}>Reload</Button>
</>
}
It renders the first time correctly, but when you press the Reload button, it throws the error
Screen1 suspended while rendering, but no fallback UI was specified.
Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.
One more thing, I know that Recoil doesn't support React Native yet, but I managed to get it to work perfectly, except the forcereload workaround. I'm not sure this is because of the way React Native renders or because I did it the wrong way.
@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?
@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.
My other way to workaround. I'm sure that doesn't cover whole cases (like using with Suspens, overflowing memory), but this is more natural, easier to maintain for me and reusable:
export const asyncSelectorFamily = ({ get: getFn, key }) =>
recoilSelectorFamily({
key,
get: ({ __date, obj }) => ({ get }) => {
const getWithDate = (selector, obj) => {
const curriedSelector =
typeof selector === 'function'
? selector({ __date, ...obj })
: selector;
return get(curriedSelector);
};
return getFn(obj, __date)({ get: getWithDate });
},
});
const lastKeys = {};
export const useAsyncRootState = (key, selector, obj) => {
lastKeys[key] = lastKeys[key] || key;
const [__date, setDate] = useState(lastKeys[key]);
const rerun = () => {
const newKey = key + Date.now();
setDate(newKey);
lastKeys[key] = newKey;
};
const [loadable] = useRecoilStateLoadable(selector({ obj, __date }));
return [loadable, rerun];
};
(...)
export const threadsState = asyncSelectorFamily({
key: 'threads',
get: () => async () => {
return fetch('/threads');
},
});
(...)
const [{ state, contents: threads }, rerun] = useAsyncRootState(
'allThreads',
threadsState
);
Feel free to use!
Duplicate of #422
Document query refresh pattern in #676
Most helpful comment
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.