Something has been bothering me about React
for quite some time. The complexity of data fetching in React apps is off the charts. The frequent pattern for data fetching is "fetch-on-render" which leads to an awful waterfalling user experience. Next, race conditions when fetching in effects is a common problem. Finally, getting the result of a JavaScript
promise always enqueues a micro task even if that promise is already resolved, resulting in flickering UIs.
Note: Microtasks complete in the same browser frame but do not complete in the same React frame which causes these flickering issues. https://codesandbox.io/s/fast-fast-7zlfqt?file=/src/App.js Trying to modify text at the start of the input results in your cursor jumping to the end of the input.
The last one was the last straw for me. It means any async
data layer that does caching needs another cache atop but behind synchronous
methods. If not, your render cycle (for React apps) is interrupted and your UI flashes various loading states.
Note: React's weird and, imo, incorrect handling of microtasks is another reason to try and invest into WebComponents?
Relay
and Apollo
make all this a breeze. The way they pull fragments from components and craft a single query that can fulfill the data needs of an entire app is a true blessing. But the cost of adopting those can be prohibitive. Do I really need to GraphQL-ify my API just to get such a pleasent data fetching experience? What if I have local state that is behind an async API? E.g., a SQLite
connection, IndexedDB
or Origin Private Filesystem
storing data on-device for my app?
Suspense helps a lot with race conditions and gets us a bit closer to fixing "fetch-on-render". It doesn't, however, solve the problem of how to express all the data needs of a tree of components.
Suspense also has some warts. It requires a cache atop your existing caches.
We don't intend to provide support for refreshing specific entries. The idea is that you refresh everything, and rely on an additional, backing cache layer — the browser request cache, a mutable data store, etc — to deduplicate requests - reactwg/react-18#25
Cache on a cache? What could go wrong.
I started my career developing thick clients in Java
and C++
. Yea, Java
. I'll probably be flamed for being a Java
dev 🤷♂️. The Java
culture is... over abstracted for sure. Swing
and AWT
and over-use of listeners and all that were totally convoluted.
But one thing we never had a problem with was data fetching. We relied strictly on language primitives to get all the data needed by the UI and it was always rather simple -- even if that data was across the network and/or we had to spawn new threads to get it.
Can't we go back to using language primitives for data fetching in JS
?
- Can it be simple?
- Can it express the data needs for an entire tree of components?
- Can we kick off fetching before we kick off rendering while still localizing data fetching concerns with the components that need the data?
- Finally, can we allow our async APIs, which may have caching in them already, to keep the responsibility of caching and not duplicate it or move it?
The answer seems to be YES! We can do it all, and keep it all pretty simple, with vanilla JS
.
(view the complete demo: https://tantaman.com/vanilla-fetch/)
Each React component has a sibling fetch
function.
function Post() {
...
}
Post.fetch = async function(id) {
...
}
These sibling functions are responsible for fetching the data for the component and invoking the fetchers for child components. They are very similar in spirit to Relay
or Apollo
fragments but, rather than being written in GraphQL
, they're just regular JS
.
fetching the data for that component and invoking the fetchers for child components
Lets see an example of this (Post.js):
Post.fetch = async (id) => {
const commentsGen = Comments.fetch(id);
let [post, comments] = await Promise.all([
dataSource.post(id),
commentsGen.next(),
]);
return {
post,
_Comments: {
prefetch: comments.value,
generator: commentsGen,
},
_Author: await Author.fetch(id),
};
};
would gather data for:
function Post({ data }) {
const post = data.post;
return (
<main>
<article>
<h1>{post.title}</h1>
<Author author={data._Author} />
<div>{post.body}</div>
</article>
<Comments comments={data._Comments} />
</main>
);
}
Of course not all data sources are done as soon as we're done fetching from them. Some data sources may stream results back to us over time.
To support that, we can define our fetch function as an async generator
. You saw a preview of this above where Post.fetch
referred to generator: commentsGen
.
The following example (Comments.js) fetches and streams the latest comments on a post, in realtime.
function Comments({ comments }) {
const allComments = useGenerator(comments.prefetch, comments.generator);
return (
<section>
{allComments.map((c) => (
<div key={c.id}>
<span>{c.time.toLocaleTimeString()}</span>
<p>{c.body}</p>
</div>
))}
</section>
);
}
Comments.fetch = async function* (postId) {
for await (const comments of dataSource.comments(postId)) {
yield [...comments];
}
};
Doing this is pretty simple.
If you want to fetch some data for a component in response to some event (like a click), call that component's fetch
function in the event.
Example (App.js):
function App() {
const [postData, setPostData] = useState();
...
<a onClick={async () => {setPostData(await Post.fetch(p.id));}}>Post Title</a>
{postData ? <Post data={postData}> : null}
...
}
This begs the question, however, of how to show a loading state between the time the user clicks and the time the data arrives.
You could do the following:
function App() {
const [postData, setPostData] = useState();
...
<a onClick={async () => {setLoading(true); setPostData(await Post.fetch(p.id)); setLoading(false)}}>Post Title</a>
{loading ? 'loading...' : null}
{!loading && postData ? <Post data={postData}> : null}
...
}
But.. what if the data is cached by Post.fetch
already because you fetched it before? The above pattern will flash a loading indicator right before showing the post. This is because await
always enqueues a micro task -- even if the thing awaited is done.
This one might be controversial but I think a valid approach is to not show a loading indicator until the data being loaded has taken more than a specific amount of time.
If we wait ~25ms to show the loading indicator then it will never be shown when we fetch cached data from our data source.
This is done in App.js.
From the generator example, hopefully its pretty straightforward to see how to do defer fetching. Either return a promise or return a geneartor with no "initial" state.
Render-as-you-fetch requires suspense to handle it well. todo.
- I've never used
Vue
but thisfetch as sibling
makes the view much "dumber" and much more akin to templates that were used back in the day. Seems like a good fit forVue
. - This little repository is an exploration of those questions before making data fetching pattern recommendations for https://aphrodite.sh users.
Completed demo: https://tantaman.com/vanilla-fetch/