Dan Abramov, in response to a React developer asking why Suspense was not responding to the fetch API:
From the legend Dan Abramov himself, we receive such gems as “There is [no data fetching solution compatible with React Suspense] that exists yet,” and “[React Cache] will be the first one,” and “Suspense is limited to code splitting.”
If I have one thing to tell Daniel “Abra Cadabra” Abramov, besides how impressed I am with his work, it’s this:
Let’s reveal the magic behind the curtain that is React Suspense. For educational purposes, I’ll cover how I created this package.
Shut Up and Give Me the Package! 💰
If you’re just here for solutions, I don’t blame you. You can find fetch-suspense
on NPM and the most extensive documentation of your life on the GitHub repository.
import useFetch from 'fetch-suspense';
const MyComponent = () => {
// "Look! In the example! It's a fetch() request! It's a hook!"
// "No! It's kind of like both at the same time."
const serverResponse = useFetch('/path/to/api', { method: 'POST' });
// The return value is the body of the server's response.
return <div>{serverResponse}</div>;
};
How Does Suspense Work? 🔮
A lot of the new React features are built into the React library, as opposed to being external packages, due to the performance benefits of being tightly coupled to the engine that powers React, known as React Fiber.
Due to React Fiber’s direct integration with features such as Suspense and hooks, you cannot create a verbatim copy of Suspense in React 16.5. However, you can probably make a less performant polyfill. I’ll use some polyfill examples so that you can conceptualize what is happening with Suspense.
class Suspense extends React.Component {
constructor(props) {
super(props);
this.state = {
error: null
};
}
componentDidCatch(e) {
this.setState({ error: e });
}
render() {
if (this.state.error) {
return this.props.fallback;
}
return this.props.children;
}
}
/*
<Suspense fallback={<Loading />}>
<ErrorThrower />
</Suspense>
*/
Here is ye olde class component: a fossil remnant of yonder days of React development. The componentDidCatch
method is a method that fires whenever a child component throws an error. This allows you to replace uncaught JavaScript errors with a nice UI for your users or otherwise implement important logic during application errors.
What the above does is mounts Suspense. Since there is no error in the local state, the children of Suspense are mounted too. In this case, the <ErrorThrower />
component is mounted, and it throws an error.
That error bubbles up to the Suspense instance, where the componentDidCatch
method receives it. It handles that error by saving it to its state, causing it to re-render.
Now that it has rendered with an error in its local state, it no longer renders its children prop, nor the <ErrorThrower />
devil-child as a result. Instead, it renders its fallback
prop, which we have set to a nice <Loading />
modal.
This is how Suspense works now, except instead of throwing errors, JavaScript Promises are thrown. When Suspense catches a Promise, it re-renders, displaying the fallback prop instead of the children that previous threw a Promise. When the Promise resolves, it re-renders again; this time no longer displaying the fallback
prop, and instead attempting to re-render the original children, under the presumption that the children are now ready to be rendered without throwing Promises around like they’re meaningless.
An implementation may look something like this:
class Suspense extends React.Component {
constructor(props) {
super(props);
this.state = {
promise: null
};
}
componentDidCatch(e) {
// Drake meme where he says no to errors here.
if (e instanceof Error) {
throw e;
}
// Drake meme where he says yes to promises here.
if (e instanceof Promise) {
this.setState({
promise: e
}, () => {
// When the promise finishes, go back to rendering the original children.
e.then(() => {
this.setState({ promise: null });
});
});
}
// This line isn't compatible with the Drake meme format.
else {
throw e;
}
}
render() {
if (this.state.promise) {
return this.props.fallback;
}
return this.props.children;
}
}
/*
<Suspense fallback={<Loading />}>
<PromiseThrower />
</Suspense>
*/
It’s important to note here that the original children attempted to render before the fallback occurred. It never succeeded.
How Does This Apply to Fetch Hooks? 🎣
What you should have gathered by now is that the fetch hook will need to throw Promises. So it does. That promise is conveniently the fetch request. When Suspense receives that thrown fetch request, it falls back to rendering its fallback
prop. When that fetch request completes, it attempts to render the component again.
There’s just one little tricky dicky problem with that — the component that threw the fetch request had only attempted to render, but did not succeed. In fact, it is not a part of the fallback
at all! It has no instance. It never mounted. It has no state (not even a React hook state); it has no component lifecycle or effects. So when it attempts to render again, how does it know the response of this fetch request? Suspense isn’t passing it, and it — not being instantiated — cannot have data attached to it.
Golly, How Do You Solve That Conundrum? 🤔
We solve it with memoization!
“Like that fancy new React.memo
feature?”
“Yes!” (in concept)
“No!” (more literally)
It does not use React.memo
, which memoizes React components based on their props. Instead, I use an array of infinite depth to memoize the parameters passed to fetch.
If a request comes in to fetch data that has been requested before (the second attempt to instantiate after the first attempt failed with a Promise), then it simply returns the data that eventually resolved from the first request’s Promise. If this is a fresh request, then we fetch it, cache it in the memoization array, and throw the fetch Promise. By comparing the current request to all entries in the memoization array, we know if we have dispatched this request before.
const deepEqual = require('deep-equal');
interface FetchCache {
fetch?: Promise<void>;
error?: any;
init: RequestInit | undefined;
input: RequestInfo;
response?: any;
}
const fetchCaches: FetchCache[] = [];
const useFetch = (input: RequestInfo, init?: RequestInit | undefined) => {
for (const fetchCache of fetchCaches) {
// The request hasn't changed since the last call.
if (
deepEqual(input, fetchCache.input) &&
deepEqual(init, fetchCache.init)
) {
// If we logged an error during this fetch request, THROW the error.
if (Object.prototype.hasOwnProperty.call(fetchCache, 'error')) {
throw fetchCache.error;
}
// If we received a response to this fetch request, RETURN it.
if (Object.prototype.hasOwnProperty.call(fetchCache, 'response')) {
return fetchCache.response;
}
// If we do not have a response or error, THROW the promise.
throw fetchCache.fetch;
}
}
// The request is new or has changed.
const fetchCache: FetchCache = {
fetch:
// Make the fetch request.
fetch(input, init)
// Parse the response.
.then(response => {
// Support JSON.
if (Object.prototype.hasOwnProperty.call(response.headers, 'Content-Type')) {
return response.json();
}
// Not JSON.
return response.text();
})
// Cache the response for when this component
// attempts to render again later.
.then(response => {
fetchCache.response = response;
})
// Cache the error for when this component
// attempts to render again later.
.catch(e => {
fetchCache.error = e;
}),
init,
input
};
// Add this metadata to the memoization array.
fetchCaches.push(fetchCache);
// Throw the Promise! Suspense to the rescue!
throw fetchCache.fetch;
};
That Sounds Like a Memory Leak 💧
It can be a feature or a bug!
But if you think it’s a bug in your project, you can invalidate the cache by providing a lifespan in milliseconds to the fetch request. Passing a third parameter (a number) to the useFetch
hook will tell it to remove the metadata from the memoization array after that many milliseconds. We implement it as easily as so:
// NEW: lifespan parameter
const useFetch = (
input: RequestInfo,
init?: RequestInit | undefined,
lifespan: number = 0
) => {
// ...
const fetchCache: FetchCache = {
fetch:
// Make the fetch request.
fetch(input, init)
.then( /* ... */ )
.then( /* ... */ )
.catch( /* ... */ )
// Invalidate the cache.
.then(() => {
// If the user defined a lifespan,
if (lifespan > 0) {
// Wait for the duration of the lifespan,
setTimeout(
() => {
// Find this fetch request and kill it
// from the memoization array.
const index = fetchCaches.indexOf(fetchCache);
if(index !== -1) {
fetchCaches.splice(index, 1);
}
},
lifespan
);
}
}),
// ...
};
// ...
};
// ...
When the fetch has completed, and we’ve updated the metadata, tick-tock. It’s important that the lifespan timer occurs after the catch
of the Promise, because we want it to set even if an error occurred.
Conclusion 🍬
When Dan Abramov tells you that you can’t do something, you do it.
If you liked this article, feel free to give it a heart or a unicorn. It’s quick, it’s easy, and it’s free! If you have any questions or relevant great advice, please leave them in the comments below.
To read more of my columns, you may follow me on LinkedIn, Medium, and Twitter, or check out my portfolio on CharlesStover.com.
Top comments (3)
Why do I feel like I've read this exact post a half dozen times before? Am I imagining it, or have you (or someone else) used the same post intro in the past? Maybe I read it somewhere else... I'm so confused!
I originally wrote this article last Halloween on Medium. Many readers have voiced that they are not comfortable using the Medium platform, so I am in the process of copying all of my articles from Medium to DEV Community. 😊
Ah, so I'm not losing my mind, at least! Thanks for clarifying. Good write up 😅