├── .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 |
28 | 37 |
    38 | {postSummaries.map((p) => ( 39 |
  • { 42 | // only flash a loading screen if loading takes > 25ms 43 | setTimeout(() => { 44 | setLoading(p.id); 45 | }, 25); 46 | setLoading(null); 47 | setPostData(await Post.fetch(p.id)); 48 | }} 49 | > 50 | {p.title} 51 |
  • 52 | ))} 53 |
54 | {loading != null && postData?.post?.id != loading ? ( 55 |
Loading...
56 | ) : ( 57 | 58 | )} 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/Author.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import dataSource from "./dataSource"; 3 | 4 | export default function Author({ author }) { 5 | return ( 6 |
7 | by {author.name} 8 |
9 | ); 10 | } 11 | 12 | Author.fetch = async (authorId) => { 13 | return await dataSource.user(authorId); 14 | }; 15 | -------------------------------------------------------------------------------- /src/Comments.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import dataSource from "./dataSource"; 3 | import useGenerator from "./useGenerator"; 4 | 5 | export default function Comments({ comments }) { 6 | const allComments = useGenerator(comments.prefetch, comments.generator); 7 | return ( 8 |
9 | {allComments.map((c) => ( 10 |
11 | {c.time.toLocaleTimeString()} 12 |

{c.body}

13 |
14 | ))} 15 |
16 | ); 17 | } 18 | 19 | Comments.fetch = async function* (postId) { 20 | for await (const comments of dataSource.comments(postId)) { 21 | yield [...comments]; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/Post.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import dataSource from "./dataSource"; 3 | import Author from "./Author"; 4 | import Comments from "./Comments"; 5 | 6 | export default function Post({ data }) { 7 | if (data == null) { 8 | return null; 9 | } 10 | const post = data.post; 11 | 12 | return ( 13 |
14 |
15 |

{post.title}

16 | 17 |
{post.body}
18 |
19 | 20 |
21 | ); 22 | } 23 | 24 | Post.fetch = async (id) => { 25 | const commentsGen = Comments.fetch(id); 26 | const [post, comments] = await Promise.all([ 27 | dataSource.post(id), 28 | commentsGen.next(), 29 | ]); 30 | 31 | return { 32 | post, 33 | _Comments: { 34 | prefetch: comments.value, 35 | generator: commentsGen, 36 | }, 37 | _Author: await Author.fetch(post.authorId), 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/dataSource.js: -------------------------------------------------------------------------------- 1 | import randomWords from "random-words"; 2 | 3 | const posts = [ 4 | { 5 | id: 0, 6 | title: "Relay without the Relay", 7 | authorId: 1, 8 | body: randomWords({ exactly: 250, join: " " }), 9 | }, 10 | { 11 | id: 1, 12 | title: "Apollo without the Apollo", 13 | authorId: 1, 14 | body: randomWords({ exactly: 125, join: " " }), 15 | }, 16 | { 17 | id: 2, 18 | title: "Suspense without the Suespense", 19 | authorId: 1, 20 | body: randomWords({ exactly: 350, join: " " }), 21 | }, 22 | ]; 23 | let commentId = 0; 24 | 25 | 26 | export default { 27 | async post(id) { 28 | // simulate caching 29 | if (postCache[id]) { 30 | return postCache[id]; 31 | } 32 | // Simulate network fetch 33 | await new Promise((resolve) => setTimeout(resolve, 300)); 34 | postCache[id] = posts[id]; 35 | return posts[id]; 36 | }, 37 | 38 | async *comments(postId) { 39 | // put a cache in front to show that we can cache in the async data layer 40 | // and still prevent loading statuses from flashing 41 | if (commentsCache[postId]) { 42 | yield commentsCache[postId]; 43 | } 44 | 45 | while (true) { 46 | // Simulate network fetch 47 | await new Promise((resolve) => setTimeout(resolve, 750)); 48 | console.log("yield for " + postId); 49 | 50 | const comment = { 51 | id: commentId++, 52 | time: new Date(), 53 | body: randomWords({ 54 | exactly: 10 + Math.floor(Math.random() * 35), 55 | join: " ", 56 | }), 57 | }; 58 | 59 | let existing = commentsCache[postId]; 60 | if (!existing) { 61 | existing = []; 62 | commentsCache[postId] = existing; 63 | } 64 | existing.push(comment); 65 | if (existing.length > 10) { 66 | commentsCache[postId] = existing.slice( 67 | existing.length - 10, 68 | existing.length 69 | ); 70 | } 71 | yield existing; 72 | } 73 | }, 74 | 75 | async user(id) { 76 | const existing = userCache[id]; 77 | if (existing) { 78 | return existing; 79 | } 80 | await new Promise((resolve) => setTimeout(resolve, 100)); 81 | const ret = { 82 | name: "Tantaman", 83 | img: "", 84 | }; 85 | userCache[id] = ret; 86 | return ret; 87 | }, 88 | 89 | clearCaches() { 90 | postCache = {}; 91 | commentsCache = {}; 92 | userCache = {}; 93 | }, 94 | }; 95 | 96 | let postCache = {}; 97 | let commentsCache = {}; 98 | let userCache = {}; 99 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById("root")); 7 | root.render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/useGenerator.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function useGenerator(initialState, generator) { 4 | const [currentState, setCurrentState] = useState(initialState); 5 | useEffect(() => { 6 | let isMounted = true; 7 | setCurrentState(initialState); 8 | async function generate() { 9 | while (true) { 10 | const nextState = await generator.next(); 11 | if (!isMounted) { 12 | // while (true) break; lets us resume the generator. 13 | // `generator.return()` or `for (x of generator)` consumes the generator preventing resumption. 14 | break; 15 | } 16 | setCurrentState(nextState.value); 17 | } 18 | } 19 | generate(); 20 | return () => { 21 | isMounted = false; 22 | }; 23 | }, [initialState, generator]); 24 | 25 | return currentState; 26 | } 27 | --------------------------------------------------------------------------------