├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── public
└── index.html
└── src
├── App.css
├── App.js
├── Author.js
├── Comments.js
├── Post.js
├── dataSource.js
├── index.css
├── index.js
└── useGenerator.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Apollo without the Apollo, Realy without the Relay
2 |
3 | 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.
4 |
5 | > 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.
6 |
7 | 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.
8 |
9 | > Note: React's weird and, imo, incorrect handling of microtasks is another reason to try and invest into WebComponents?
10 |
11 | # Relay, Apollo
12 |
13 | `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?
14 |
15 | # Suspense
16 |
17 | 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.
18 |
19 | Suspense also has some warts. It requires a cache atop your existing caches.
20 |
21 | > 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 - https://github.com/reactwg/react-18/discussions/25
22 |
23 | Cache on a cache? What could go wrong.
24 |
25 | # Vanilla JS
26 |
27 | 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.
28 |
29 | 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.
30 |
31 | Can't we go back to using language primitives for data fetching in `JS`?
32 | - Can it be simple?
33 | - Can it express the data needs for an entire tree of components?
34 | - Can we kick off fetching before we kick off rendering while still localizing data fetching concerns with the components that need the data?
35 | - 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?
36 |
37 | The answer seems to be YES! We can do it all, and keep it all pretty simple, with vanilla `JS`.
38 |
39 | # How It's Done
40 |
41 | (view the complete demo: https://tantaman.com/vanilla-fetch/)
42 |
43 | Each React component has a sibling `fetch` function.
44 |
45 | ```js
46 | function Post() {
47 | ...
48 | }
49 |
50 | Post.fetch = async function(id) {
51 | ...
52 | }
53 | ```
54 |
55 | 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`.
56 |
57 | > fetching the data for that component and invoking the fetchers for child components
58 |
59 | Lets see an example of this ([Post.js](https://github.com/tantaman/vanilla-fetch/blob/main/src/Post.js)):
60 |
61 | ```js
62 | Post.fetch = async (id) => {
63 | const commentsGen = Comments.fetch(id);
64 | let [post, comments] = await Promise.all([
65 | dataSource.post(id),
66 | commentsGen.next(),
67 | ]);
68 |
69 | return {
70 | post,
71 | _Comments: {
72 | prefetch: comments.value,
73 | generator: commentsGen,
74 | },
75 | _Author: await Author.fetch(id),
76 | };
77 | };
78 | ```
79 |
80 | would gather data for:
81 |
82 | ```js
83 | function Post({ data }) {
84 | const post = data.post;
85 |
86 | return (
87 |
88 |
89 |
{post.title}
90 |
91 |
{post.body}
92 |
93 |
94 |
95 | );
96 | }
97 | ```
98 |
99 | # Streaming & Changing Data
100 |
101 | 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.
102 |
103 | 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`.
104 |
105 | The following example ([Comments.js](https://github.com/tantaman/vanilla-fetch/blob/main/src/Comments.js)) fetches and streams the latest comments on a post, in realtime.
106 |
107 | ```js
108 | function Comments({ comments }) {
109 | const allComments = useGenerator(comments.prefetch, comments.generator);
110 | return (
111 |
112 | {allComments.map((c) => (
113 |
114 | {c.time.toLocaleTimeString()}
115 |
{c.body}
116 |
117 | ))}
118 |
119 | );
120 | }
121 |
122 | Comments.fetch = async function* (postId) {
123 | for await (const comments of dataSource.comments(postId)) {
124 | yield [...comments];
125 | }
126 | };
127 | ```
128 |
129 | # Fetch then Render
130 |
131 | Doing this is pretty simple.
132 |
133 | 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.
134 |
135 | Example ([App.js](https://github.com/tantaman/vanilla-fetch/blob/main/src/App.js)):
136 |
137 | ```js
138 | function App() {
139 | const [postData, setPostData] = useState();
140 | ...
141 | {setPostData(await Post.fetch(p.id));}}>Post Title
142 | {postData ? : null}
143 | ...
144 | }
145 | ```
146 |
147 | This begs the question, however, of how to show a loading state between the time the user clicks and the time the data arrives.
148 |
149 | You could do the following:
150 |
151 | ```js
152 | function App() {
153 | const [postData, setPostData] = useState();
154 | ...
155 | {setLoading(true); setPostData(await Post.fetch(p.id)); setLoading(false)}}>Post Title
156 | {loading ? 'loading...' : null}
157 | {!loading && postData ? : null}
158 | ...
159 | }
160 | ```
161 |
162 | 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.
163 |
164 | # Stop the Flicker
165 |
166 | 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.
167 |
168 | If we wait ~25ms to show the loading indicator then it will never be shown when we fetch cached data from our data source.
169 |
170 | This is done in [App.js](https://github.com/tantaman/vanilla-fetch/blob/main/src/App.js).
171 |
172 | # Deferred Fetching & Render-as-you-fetch
173 |
174 | 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.
175 |
176 | Render-as-you-fetch requires suspense to handle it well. todo.
177 |
178 | # Other
179 |
180 | - I've never used `Vue` but this `fetch 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 for `Vue`.
181 | - This little repository is an exploration of those questions before making data fetching pattern recommendations for https://aphrodite.sh users.
182 |
183 | Completed demo: https://tantaman.com/vanilla-fetch/
184 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fetch-then-render",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "./",
6 | "dependencies": {
7 | "@testing-library/jest-dom": "^5.16.5",
8 | "@testing-library/react": "^13.3.0",
9 | "@testing-library/user-event": "^13.5.0",
10 | "random-words": "^1.2.0",
11 | "react": "^18.2.0",
12 | "react-dom": "^18.2.0",
13 | "react-scripts": "5.0.1",
14 | "web-vitals": "^2.1.4"
15 | },
16 | "scripts": {
17 | "start": "react-scripts start",
18 | "build": "react-scripts build",
19 | "test": "react-scripts test",
20 | "eject": "react-scripts eject"
21 | },
22 | "eslintConfig": {
23 | "extends": [
24 | "react-app",
25 | "react-app/jest"
26 | ]
27 | },
28 | "browserslist": {
29 | "production": [
30 | ">0.2%",
31 | "not dead",
32 | "not op_mini all"
33 | ],
34 | "development": [
35 | "last 1 chrome version",
36 | "last 1 firefox version",
37 | "last 1 safari version"
38 | ]
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Fetch Then Render
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | width: 720px;
3 | margin-left: auto;
4 | margin-right: auto;
5 | }
6 |
7 | section {
8 | margin-top: 40px;
9 | }
10 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import "./App.css";
2 | import { useState } from "react";
3 | import React from "react";
4 | import Post from "./Post";
5 | import dataSource from "./dataSource";
6 |
7 | export default function App() {
8 | const postSummaries = [
9 | {
10 | id: 0,
11 | title: "Relay without the Relay",
12 | },
13 | {
14 | id: 1,
15 | title: "Apollo without the Apollo",
16 | },
17 | {
18 | id: 2,
19 | title: "Suspense without... the Suspense",
20 | },
21 | ];
22 |
23 | const [postData, setPostData] = useState();
24 | const [loading, setLoading] = useState();
25 |
26 | return (
27 |