Invoking API calls directly in render?
Closed this issue · 2 comments
In relation to #8 and #7 the useAsyncResource hook now synchronously invokes the passed-in fn. This means that all the examples on the home page effectively invoke API calls directly, synchronously from the component render() fn.
Is this not discouraged by React? Shouldn't we trigger side effects like initiating API calls from a useEffect hook instead? Or are we now supposed to do that ourselves in each component?
The hook always invoked the api call immediately on first render. The PR you mentioned only fixed a bug where the hook would not do the same when the props changed. See the detailed explanation in #7
But to address your concern, please note that Suspense works differently. It's a paradigm shift.
First, keep in mind that React Function Components are just that: functions. They get called (executed) multiple times throughout their lifetime.
So for API calls, the following example will trigger multiple (unexpected) fetch requests, even if the params of the API function don't change, but every time the parent decides to re-render this component. What's more, every time the API call finishes, another one gets triggered immediately:
// first render
const [data, setData] = useState(); // initializes the state, `data` is empty
// starts fetching the user data
fetchUser(userId)
.then(r => r.json())
.then(setData); // later, sets the user data, causing a re-render
return !data ? "loading..." : (...)
// ...
// second render
const [data, setData] = useState(); // `data` now contains the user data object
// this (again) starts fetching the user data
fetchUser(userId)
.then(r => r.json())
.then(setData); // again, later, sets the user data, causing another re-render
return !data ? "loading..." : (...)
// etc
So an useEffect
solves this by treating the API call as a side effect
// first render
const [data, setData] = useState(); // initializes the state, `data` is empty
// runs only AFTER the initial render, and then for every change of `userId`
useEffect(() => {
// starts fetching the user data
fetchUser(userId)
.then(r => r.json())
.then(setData); // later, sets the user data, causing a re-render
}, [userId]);
return !data ? "loading..." : (...) // renders the loading message
// ...
// second render (for any other reason than the API call being complete)
const [data, setData] = useState(); // `data` is still empty
// this won't get triggered again, because `userId` hasn't changed; the API call is still in-flight
useEffect(() => {
...
}, [userId]);
return !data ? "loading..." : (...) // still renders the loading message
// ...
// third render (the API call is complete)
const [data, setData] = useState(); // `data` now contains the user object
// this still won't get triggered, because `userId` still hasn't changed
useEffect(() => {
...
}, [userId]);
return !data ? "loading..." : (...) // renders the JSX for the user object
// ...
// fourth render (`userId` changed)
const [data, setData] = useState(); // `data` contains the same previous user object
// this will get triggered AFTER the render, because `userId` changed
useEffect(() => {
// starts fetching the new user data
fetchUser(userId)
.then(r => r.json())
.then(setData); // later, sets the new user data, causing another re-render
}, [userId]);
return !data ? "loading..." : (...) // renders the JSX for the previous user object
// ...
// fifth render (new API call completed)
const [data, setData] = useState(); // `data` now contains new user object
// this will not get triggered again, because `userId` didn't change from last time
useEffect(() => {
...
}, [userId]);
return !data ? "loading..." : (...) // renders the JSX for new user object
With Suspense, however, the pattern is different. Suspense allows you to trigger API calls as early as possible, so you don't waste unnecessary renders. Here's how it works in contrast to the example above:
// first render
const [data] = useAsyncResource(fetchUser, userId); // starts the API call immediately and throws the unfulfilled promise
// this marks the component as being "suspended"
// the in-flight API request (the thrown unfulfilled promise) is cached
// all following code is not executed, because we got a throw command, which we're not catching here
render (...); // this will not be rendered by React
// ...
// second render (for any other reason than `userId` having changed)
const [data] = useAsyncResource(fetchUser, userId); // gets the unfulfilled promise from the cache and throws it again
// the component is still "suspended"
render (...); // still not rendered by React
// ...
// third render (the API call was successful)
const [data] = useAsyncResource(fetchUser, userId); // now the hook returns the data, not throwing anymore
// `data` contains the user object
render (...); // now React renders the JSX with the user object
// ...
// fourth render (`userId` changed)
const [data] = useAsyncResource(fetchUser, userId); // starts a new API call immediately and throws the new unfulfilled promise
// this again marks the component as being "suspended"
// the new in-flight API request (the new thrown unfulfilled promise) is also cached
// all following code is not executed, because we got a throw command, which we're not catching here
render (...); // this will again not be rendered by React
// ...
// fifth render (the new API call was successful)
const [data] = useAsyncResource(fetchUser, userId); // the hook returns the new data, not throwing anymore
// `data` now contains the new user object
render (...); // React renders the JSX with the new user object
So ultimately, the bug in #7 was that the API call was NOT triggered as soon as possible, and the useAsyncResource
hook would be, for a single render, out of sync with the actual props passed in. Because the change was from a useEffect
to a useMemo
, the API calls are still not triggered multiple times (so still treated as side effects), but now they are triggered much earlier (synchronously instead of after a wasted render).
Thank you for a very nice explanation, even I was able to understand it :) I wish smth like this was in the React docs.
Ivo