├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── README.md ├── docs ├── .vitepress │ ├── config.mts │ ├── en.mts │ ├── ko.mts │ └── theme │ │ ├── custom.css │ │ └── index.js ├── en │ ├── core.md │ ├── devtools.md │ ├── guide.md │ ├── index.md │ ├── intro.md │ ├── react.md │ └── window-focus-refetching.md ├── ko │ ├── core.md │ ├── devtools.md │ ├── guide.md │ ├── index.md │ ├── intro.md │ ├── react.md │ └── window-focus-refetching.md └── public │ ├── architecture.png │ ├── demo.mov │ └── tanstack-query-logo.png ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── index.css ├── main.jsx ├── queries │ ├── usePostDetailQuery │ │ └── index.js │ └── usePostListQuery │ │ └── index.js ├── routeTree.gen.ts ├── router.js ├── routes │ ├── __root.jsx │ ├── index.jsx │ └── posts │ │ └── $postId.jsx └── vite-env.d.ts ├── tailwind.config.js ├── tanstack-query-lite ├── core │ ├── Query.js │ ├── QueryCache.js │ ├── QueryClient.js │ ├── QueryObserver.js │ └── util.js └── react │ ├── QueryClientProvider.jsx │ ├── ReactQueryDevtools.jsx │ └── useQuery.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy site to Github Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: pages 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - name: Setup Node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20 30 | cache: npm 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v4 33 | - name: Install dependencies 34 | run: npm install 35 | - name: Build with VitePress 36 | run: npm run docs:build 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: docs/.vitepress/dist 41 | 42 | deploy: 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | needs: build 47 | runs-on: ubuntu-latest 48 | name: Deploy 49 | steps: 50 | - name: Deploy to GitHub Pages 51 | id: deployment 52 | uses: actions/deploy-pages@v4 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | cache 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "trailingComma": "none", 7 | "bracketSpacing": true, 8 | "jsxBracketSameLine": false, 9 | "arrowParens": "always" 10 | } 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome contribution from everyone. 4 | 5 | ## Issues 6 | 7 | You can contribute to this repository 8 | 9 | - Improving our [docs](https://mugglim.github.io/build-your-own-tanstack-query/). 10 | - Improving our [tanstack-query-lite](https://github.com/mugglim/build-your-own-tanstack-query/tree/chore/add-contributing/tanstack-query-lite) features. 11 | 12 | ## Get started 13 | 14 | ### Installation 15 | 16 | Please follow these steps to get started: 17 | 18 | - Fork and clone this repository 19 | 20 | ``` 21 | cd build-your-own-tanstack-query 22 | ``` 23 | 24 | - Install package 25 | ``` 26 | npm install 27 | ``` 28 | 29 | ### Run docs dev server 30 | 31 | ``` 32 | npm run docs:dev 33 | ``` 34 | 35 | ### Run dev server 36 | 37 | ``` 38 | npm run dev 39 | ``` 40 | 41 | ## Pull Requests 42 | 43 | We merge pull requests by squashing all commits. 44 | Please ensure your PR template titles match the example below. 45 | 46 | ``` 47 | : 48 | ``` 49 | 50 | `type` must be one of the following: 51 | 52 | - `feat`: A new tanstack-query-lite feature. 53 | - `docs`: Documentation only changes. 54 | - `fix`: A bug fix 55 | - `chore`: Anything else 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build Your Own TanStack Query 2 | 3 | English | 한국어 4 | 5 | ## Introduction 6 | 7 | We're going to rewrite TanStack Query and useQuery from scratch. We'll be using the ideas and code from [Let's Build React Query in 150 Lines of Code!](https://www.youtube.com/watch?v=9SrIirrnwk0) and TanStack Query v5. 8 | 9 | ## Demo 10 | 11 | https://github.com/user-attachments/assets/11454b80-034a-4205-b051-5a3c78f1b9d0 12 | 13 | ## Contributing 14 | 15 | We welcome contribution from everyone. Read below for contributing guidelines. 16 | 17 | [CONTRIBUTING](/CONTRIBUTING.md) 18 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | import koConfig from "./ko.mts"; 3 | import enConfig from "./en.mts"; 4 | 5 | export default defineConfig({ 6 | base: "/build-your-own-tanstack-query/", 7 | title: "Build your own TanStack Query", 8 | description: "Build your own TanStack Query", 9 | rewrites: { 10 | "en/:doc.md": ":doc.md" 11 | }, 12 | head: [ 13 | ["link", { rel: "icon", href: "/build-your-own-tanstack-query/tanstack-query-logo.png" }], 14 | ["meta", { property: "og:title", content: "Build your own TanStack Query" }], 15 | ["meta", { property: "og:description", content: "Build your own TanStack Query" }], 16 | ["meta", { property: "og:image", content: "/build-your-own-tanstack-query/tanstack-query-logo.png" }], 17 | ["meta", { name: "twitter:image", content: "/build-your-own-tanstack-query/tanstack-query-logo.png" }] 18 | ], 19 | locales: { 20 | root: { label: "English", ...enConfig }, 21 | ko: { label: "한국어", ...koConfig } 22 | }, 23 | themeConfig: { 24 | siteTitle: "Home", 25 | outline: { level: "deep" }, 26 | socialLinks: [{ icon: "github", link: "https://github.com/mugglim/build-your-own-tanstack-query" }] 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /docs/.vitepress/en.mts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme, defineConfig } from "vitepress"; 2 | 3 | const sidebarList: DefaultTheme.SidebarItem[] = [ 4 | { text: "Introduction", items: [{ text: "Overview", link: "/intro.html" }] }, 5 | { 6 | text: "Guide", 7 | items: [ 8 | { text: "Overview", link: "/guide.html" }, 9 | { text: "Core", link: "/core.html" }, 10 | { text: "React", link: "/react.html" }, 11 | { 12 | text: "Learn More", 13 | items: [ 14 | { text: "Window Focus Refetching", link: "/window-focus-refetching.html" }, 15 | { text: "Devtools", link: "/devtools.html" } 16 | ] 17 | } 18 | ] 19 | } 20 | ]; 21 | 22 | const enConfig = defineConfig({ 23 | lang: "en-US", 24 | themeConfig: { 25 | sidebar: sidebarList 26 | } 27 | }); 28 | 29 | export default enConfig; 30 | -------------------------------------------------------------------------------- /docs/.vitepress/ko.mts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme, defineConfig } from "vitepress"; 2 | 3 | const sidebarList: DefaultTheme.SidebarItem[] = [ 4 | { text: "소개", items: [{ text: "개요", link: "/ko/intro.html" }] }, 5 | { 6 | text: "가이드", 7 | items: [ 8 | { text: "개요", link: "/ko/guide.html" }, 9 | { text: "코어 영역", link: "/ko/core.html" }, 10 | { text: "React 영역", link: "/ko/react.html" }, 11 | { 12 | text: "더 알아보기", 13 | items: [ 14 | { text: "Window Focus Refetching", link: "/ko/window-focus-refetching.html" }, 15 | { text: "Devtools", link: "/ko/devtools.html" } 16 | ] 17 | } 18 | ] 19 | } 20 | ]; 21 | 22 | const koConfig = defineConfig({ 23 | lang: "ko-KR", 24 | themeConfig: { 25 | sidebar: sidebarList 26 | } 27 | }); 28 | 29 | export default koConfig; 30 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-home-hero-name-color: transparent; 3 | --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #ef4444, #f59e0b); 4 | } 5 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | import DefaultTheme from "vitepress/theme"; 2 | import "./custom.css"; 3 | 4 | export default DefaultTheme; 5 | -------------------------------------------------------------------------------- /docs/en/core.md: -------------------------------------------------------------------------------- 1 | # Core Layer 2 | 3 | The core layer does not depend on any specific library. Let's learn how to implement the core logic of `QueryClient`, `QueryCache`, `Query`, and `QueryObserver` directly. 4 | 5 | ## Step 1: QueryClient 6 | 7 | The `QueryClient` is arguably the most important object in the core of TanStack Query. Most of the features provided by TanStack Query are accessed through the `QueryClient`. 8 | 9 | Because of its importance, the `QueryClient` is often made globally accessible. Typically, a `QueryClient` instance is created at the start of the application and shared throughout. 10 | 11 | In a React environment, the `QueryClient` instance is shared across components using the `useContext` API. 12 | 13 | ```javascript 14 | import QueryCache from "./QueryCache"; 15 | import { hashKey } from "./utils"; 16 | 17 | class QueryClient { 18 | cache; 19 | defaultOptions; 20 | 21 | constructor(config) { 22 | this.cache = config.cache || new QueryCache(); 23 | this.defaultOptions = config.defaultOptions; 24 | } 25 | 26 | getQueryCache = () => { 27 | return this.cache; 28 | }; 29 | 30 | defaultQueryOptions = (options) => { 31 | // `options`가 전달되는 경우 `defaultOptions`와 병합하는 과정을 진행합니다. 32 | const mergedQueryOptions = { 33 | ...this.defaultOptions?.queries, 34 | ...options 35 | }; 36 | 37 | const defaultedQueryOptions = { 38 | ...mergedQueryOptions, 39 | queryHash: mergedQueryOptions.queryHash || hashKey(mergedQueryOptions.queryKey) 40 | }; 41 | 42 | return defaultedQueryOptions; 43 | }; 44 | } 45 | ``` 46 | 47 | `QueryClient` manages the global option values of `Query` objects through `defaultQueryOptions`. You can pass the `defaultQueryOptions` value when creating the `QueryClient` instance. This option allows you to avoid duplicating code when creating `Query` objects. 48 | 49 | However, `QueryClient` does not implement many functions directly. It relies on the `QueryCache` object and delegates most feature implementations externally. 50 | 51 | ### What values can you specify in defaultOptions? 52 | 53 | You can globally specify option values provided by `Query` such as `staleTime`, `gcTime`, etc. 54 | 55 | For example, by specifying `defaultOptions` as below, you assign the default `staleTime` of `Query` to `Infinity`. 56 | 57 | ```javascript 58 | const queryClient = new QueryClient({ 59 | defaultOptions: { 60 | queries: { staleTime: Infinity } 61 | } 62 | }); 63 | ``` 64 | 65 | ### When is QueryClient usually created? 66 | 67 | Create it at the application startup. After creating the `QueryClient` instance, allow global access and share the instance. 68 | 69 | > [!TIP] React QueryClientProvider 70 | > 71 | > - In React, use [QueryClientProvider](https://tanstack.com/query/latest/docs/framework/react/reference/QueryClientProvider) to allow global access. 72 | 73 | ### What is the hashKey function? 74 | 75 | It serializes the `queryKey` value of `Query`. Internally, it uses the `JSON.stringify()` method. 76 | 77 | ```javascript 78 | export function hashKey(queryKey) { 79 | return JSON.stringify(queryKey); 80 | } 81 | ``` 82 | 83 | ## Step2: QueryCache 84 | 85 | TanStack Query provides data caching functionality. `QueryCache` implements caching by storing `Query` object instances in **browser memory**. 86 | 87 | `QueryCache` manages data using a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) and offers lookup, deletion, and creation functions. 88 | 89 | ```javascript 90 | import { Query } from "./Query"; 91 | import { hashKey } from "./util"; 92 | 93 | class QueryCache { 94 | queries; 95 | 96 | constructor() { 97 | this.queries = new Map(); 98 | } 99 | 100 | get = (queryHash) => { 101 | return this.queries.get(queryHash); 102 | }; 103 | 104 | getAll = () => { 105 | const queries = this.queries.values(); 106 | 107 | return [...queries]; 108 | }; 109 | 110 | build(client, options) { 111 | const queryKey = options.queryKey; 112 | const queryHash = hashKey(queryKey); 113 | 114 | let query = this.get(queryHash); 115 | 116 | if (!query) { 117 | query = new Query({ 118 | cache: this, 119 | queryKey, 120 | queryHash, 121 | options: client.defaultQueryOptions(options) 122 | }); 123 | 124 | this.queries.set(query.queryHash, query); 125 | } 126 | 127 | return query; 128 | } 129 | 130 | remove = (query) => { 131 | this.queries.delete(query.queryHash); 132 | }; 133 | } 134 | ``` 135 | 136 | ### How does caching work? 137 | 138 | `QueryCache` stores cached data in memory using the `queries` variable. 139 | 140 | The `queries` variable uses a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) with this key-value pair: 141 | 142 | - `key`: `queryHash` of the `Query` 143 | - `value`: instance of the `Query` object 144 | 145 | > [!TIP] What is `queryHash`? 146 | > 147 | > - It is the hashed value of the `Query`'s `queryKey`. 148 | > - Hashing uses the [hashKey](https://github.com/mugglim/build-your-own-tanstack-query/blob/main/tanstack-query-lite/core/util.js#L2) function. 149 | 150 | ### How to add cache to `QueryCache`? 151 | 152 | Use the `build` method. If a `Query` already exists in `queries`, it returns the cached `Query` instance. 153 | 154 | ```javascript{8-21} 155 | class QueryCache { 156 | ... 157 | 158 | build(client, options) { 159 | const queryKey = options.queryKey; 160 | const queryHash = hashKey(queryKey); 161 | 162 | let query = this.get(queryHash); 163 | 164 | if (!query) { 165 | query = new Query({ 166 | cache: this, 167 | queryKey, 168 | queryHash, 169 | options: client.defaultQueryOptions(options) 170 | }); 171 | 172 | this.queries.set(query.queryHash, query); 173 | } 174 | 175 | return query; 176 | } 177 | } 178 | ``` 179 | 180 | ## Step3: Query 181 | 182 | `Query` fetches and manages server state in TanStack Query. 183 | 184 | `Query` tracks the fetch status (pending, success, error) and data as its state. It notifies subscribers whenever the state changes. Additionally, `Query` prevents duplicate fetch requests for the same data. 185 | 186 | ```javascript 187 | export class Query { 188 | cache; 189 | queryKey; 190 | queryHash; 191 | options; 192 | observers; 193 | state; 194 | promise; 195 | gcTimeout; 196 | 197 | constructor(config) { 198 | this.observers = []; 199 | this.cache = config.cache; 200 | this.queryHash = config.queryHash; 201 | this.queryKey = config.queryKey; 202 | this.options = { 203 | ...config.defaultOptions, 204 | ...config.options 205 | }; 206 | this.state = { 207 | data: undefined, 208 | error: undefined, 209 | status: "pending", 210 | isFetching: true, 211 | lastUpdated: undefined 212 | }; 213 | 214 | this.scheduleGcTimeout(); 215 | } 216 | 217 | scheduleGcTimeout = () => { 218 | const { gcTime } = this.options; 219 | 220 | this.gcTimeout = setTimeout(() => { 221 | this.cache.remove(this); 222 | this.cache.notify(); 223 | }, gcTime); 224 | }; 225 | 226 | clearGcTimeout = () => { 227 | clearTimeout(this.gcTimeout); 228 | this.gcTimeout = null; 229 | }; 230 | 231 | subscribe = (observer) => { 232 | this.observers.push(observer); 233 | this.clearGcTimeout(); 234 | 235 | const unsubscribe = () => { 236 | this.observers = this.observers.filter((d) => { 237 | return d !== observer; 238 | }); 239 | 240 | if (!this.observers.length) { 241 | this.scheduleGcTimeout(); 242 | } 243 | }; 244 | 245 | return unsubscribe; 246 | }; 247 | 248 | setState = (updater) => { 249 | this.state = updater(this.state); 250 | 251 | this.observers.forEach((observer) => { 252 | observer.notify(); 253 | }); 254 | 255 | this.cache.notify(); 256 | }; 257 | 258 | fetch = () => { 259 | if (!this.promise) { 260 | this.promise = (async () => { 261 | this.setState((old) => ({ ...old, isFetching: true, error: undefined })); 262 | 263 | try { 264 | if (!this.options.queryFn) { 265 | throw new Error(`Missing queryFn: '${this.options.queryHash}'`); 266 | } 267 | 268 | const data = await this.options.queryFn(); 269 | 270 | this.setState((old) => ({ ...old, status: "success", data, lastUpdated: Date.now() })); 271 | } catch (error) { 272 | this.setState((old) => ({ ...old, status: "error", error })); 273 | } finally { 274 | this.setState((old) => ({ ...old, isFetching: false })); 275 | 276 | this.promise = null; 277 | } 278 | })(); 279 | } 280 | 281 | return this.promise; 282 | }; 283 | } 284 | ``` 285 | 286 | ### How does server state management work? 287 | 288 | Server state management divides into fetching and updating. 289 | 290 | **Fetching server state** uses the `fetch` method and the `queryFn` function passed when creating the `Query`. 291 | To prevent duplicate requests, `Query` stores the Promise handling the request in an internal variable called `promise`. 292 | 293 | | `promise` assigned? | `fetch` method behavior | 294 | | ------------------- | -------------------------------------------------------------- | 295 | | `false` | Create a new Promise via `queryFn` and assign it to `promise`. | 296 | | `true` | Return the existing `promise` to avoid duplicate requests. | 297 | 298 | **Updating server state** uses the `setState` method. 299 | `Query` notifies subscribers on every state change. 300 | 301 | ### What is `gcTime`? 302 | 303 | `gcTime` defines how long `QueryCache` keeps a cached `Query` before removing it. 304 | 305 | `Query` uses `setTimeout` at creation and manages this via `scheduleGcTimeout`. 306 | When the `gcTime` timeout fires, `QueryCache` removes the `Query`. 307 | 308 | If any subscriber exists, `clearGcTimeout` cancels the timeout. 309 | When all subscribers unsubscribe, `scheduleGcTimeout` sets the timeout again. 310 | 311 | ## Step4: QueryObserver 312 | 313 | `QueryObserver` optimizes subscriptions to `Query`. 314 | For example, it uses `staleTime` to prevent unnecessary `fetch` calls. 315 | 316 | `QueryObserver` subscribes to a single `Query`. 317 | It selects the `Query` by `queryKey` and receives updated states whenever the `Query` state changes. 318 | 319 | ```javascript 320 | class QueryObserver { 321 | client; 322 | options; 323 | notify; 324 | 325 | constructor(client, options) { 326 | this.client = client; 327 | this.options = options; 328 | } 329 | 330 | getQuery = () => { 331 | const query = this.client.getQueryCache().build(this.client, this.options); 332 | 333 | return query; 334 | }; 335 | 336 | getResult = () => { 337 | return this.getQuery().state; 338 | }; 339 | 340 | subscribe = (callback) => { 341 | this.notify = callback; 342 | 343 | const query = this.getQuery(); 344 | 345 | const { lastUpdated } = query.state; 346 | const { staleTime } = this.options; 347 | 348 | const needsToFetch = !lastUpdated || Date.now() - lastUpdated > staleTime; 349 | 350 | const unsubscribeQuery = query.subscribe(this); 351 | 352 | if (needsToFetch) { 353 | query.fetch(); 354 | } 355 | 356 | const unsubscribe = () => { 357 | unsubscribeQuery(); 358 | }; 359 | 360 | return unsubscribe; 361 | }; 362 | } 363 | ``` 364 | 365 | ### What is `staleTime`? 366 | 367 | `staleTime` means the time interval after which the server state changes from fresh to stale. 368 | 369 | `Query` saves the last time the server state changed in the `lastUpdated` variable. 370 | `QueryObserver` uses `lastUpdated` to decide whether to run `fetch`. It runs `fetch` only if `Date.now() - lastUpdated` is greater than `staleTime`. 371 | 372 | | `Date.now() - lastUpdated` > `staleTime` | Should `fetch` run? | 373 | | :--------------------------------------: | :-----------------: | 374 | | `false` | `false` | 375 | | `true` | `true` | 376 | 377 | > [!TIP] What do fresh and stale states mean? 378 | > 379 | > - **Fresh state** 380 | > - The data is up-to-date. 381 | > - **Stale state** 382 | > - The data is outdated. 383 | > - (Note) When `Date.now() - lastUpdated` is greater than `staleTime`, the state is stale. 384 | -------------------------------------------------------------------------------- /docs/en/devtools.md: -------------------------------------------------------------------------------- 1 | # ReactQueryDevTools 2 | 3 | ## Explanation 4 | 5 | Let's build the [ReactQueryDevTools](https://tanstack.com/query/v5/docs/framework/react/devtools) for TanStack Query. 6 | 7 | ## Requirements 8 | 9 | - Display the cached `Query`'s status, staleTime, and gcTime information. 10 | - Refresh the list of cached `Query` objects whenever changes occur. 11 | 12 | ## Solution 13 | 14 | To detect changes in the cached `Query` objects inside the `QueryCache`, apply a subscription feature to `QueryCache`. 15 | 16 | ### QueryCache 17 | 18 | ```jsx 19 | class QueryCache { 20 | listeners; 21 | 22 | constructor() { 23 | this.listeners = new Set(); 24 | } 25 | 26 | subscribe = (listener) => { 27 | this.listeners.add(listener); 28 | 29 | const unsubscribe = () => { 30 | this.listeners.delete(listener); 31 | }; 32 | 33 | return unsubscribe; 34 | }; 35 | 36 | notify = () => { 37 | this.listeners.forEach((callback) => { 38 | callback(); 39 | }); 40 | }; 41 | } 42 | ``` 43 | 44 | ### Query 45 | 46 | `Query` calls the `notify` method of `QueryCache` whenever the server state changes. This method publishes events to all subscribers registered to the `QueryCache`. 47 | 48 | ```jsx 49 | class Query { 50 | scheduleGcTimeout = () => { 51 | // ... 52 | this.gcTimeout = setTimeout(() => { 53 | this.cache.notify(); 54 | }, gcTime); 55 | }; 56 | 57 | setState() { 58 | // ... 59 | this.cache.notify(); 60 | } 61 | } 62 | ``` 63 | 64 | ### ReactQueryDevtools 65 | 66 | ReactQueryDevtools accesses the list of cached `Query` objects through the `QueryCache`. It re-renders to update the `Query` list state whenever the server state changes. 67 | 68 | ```jsx 69 | const ReactQueryDevtools = () => { 70 | const queryClient = useQueryClient(); 71 | 72 | const [, rerender] = useReducer((i) => i + 1, 0); 73 | 74 | useEffect(() => { 75 | return queryClient.cache.subscribe(rerender); 76 | }, [queryClient]); 77 | 78 | const queries = queryClient.getQueryCache().getAll(); 79 | const sortedQueries = [...queries].sort((a, b) => (a.queryHash > b.queryHash ? 1 : -1)); 80 | 81 | return ( 82 |
83 | {sortedQueries.map((query) => { 84 | const { queryKey, queryHash, state, observers, options } = query; 85 | const { isFetching, status } = state; 86 | 87 | const { staleTime, gcTime } = options; 88 | 89 | return ( 90 |
91 | {JSON.stringify(queryKey, null, 2)}, {JSON.stringify({ staleTime, gcTime }, null, 2)} -{" "} 92 | 93 | {(() => { 94 | if (isFetching) { 95 | return fetching; 96 | } 97 | 98 | if (!observers.length) { 99 | return inactive; 100 | } 101 | 102 | if (status === "success") { 103 | return success; 104 | } 105 | 106 | if (status === "error") { 107 | return error; 108 | } 109 | 110 | return null; 111 | })()} 112 | 113 |
114 | ); 115 | })} 116 |
117 | ); 118 | }; 119 | ``` 120 | 121 | Rendering `ReactQueryDevtools` in the top-level component allows you to verify that the DevTools work correctly. 122 | 123 | ```jsx 124 | const App = ({ children }) => { 125 | return ( 126 | 127 | 128 | {children} 129 | 130 | ); 131 | }; 132 | ``` 133 | -------------------------------------------------------------------------------- /docs/en/guide.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | The structure of TanStack Query can be divided into two parts: **core layer** and **library support layer**. 4 | 5 | The core layer does not depend on any specific library. It contains the core logic of TanStack Query, including `QueryClient`, `QueryCache`, `Query`, and `QueryObserver`. 6 | 7 | The library support layer builds on top of the core logic and provides code that allows TanStack Query to be used with specific libraries. For example, when using TanStack Query with React, you can use the `react-query` package. 8 | 9 | > [!TIP] Do you want to explore the code provided by TanStack Query? 10 | > You can find out more about the core and React layers at the following links: 11 | > 12 | > - Core layer: https://github.com/TanStack/query/tree/main/packages/query-core 13 | > - React layer: https://github.com/TanStack/query/tree/main/packages/react-query 14 | 15 | ## Understanding the Structure of `tanstack-query-lite` 16 | 17 | The code we'll be writing is a simple version of TanStack Query. We'll call the package `tanstack-query-lite`. 18 | 19 | > [!WARNING] Not intended for production use 20 | > `tanstack-query-lite` is for learning purposes only and **should not** be used in production environments. 21 | 22 | The `tanstack-query-lite` package is made up of two folders. 23 | 24 | 1. `tanstack-query-lite/core`: Implements code in the core layer. 25 | 2. `tanstack-query-lite/react`: Implements code that relies on React. 26 | 27 | This is how the `tanstack-query-lite` package is structured. 28 | 29 | ![image](/architecture.png) 30 | -------------------------------------------------------------------------------- /docs/en/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: "Build Your Own TanStack Query" 6 | tagline: "Rewrite TanStack Query from scratch" 7 | actions: 8 | - theme: brand 9 | text: About Build Your Own TanStack Query 10 | link: /intro.html 11 | - theme: alt 12 | text: GitHub 13 | link: https://github.com/mugglim/build-your-own-tanstack-query 14 | 15 | features: 16 | - title: Hands-on Experience 17 | details: Rewrite everything from scratch to learn more about TanStack Query. 18 | - title: query-core 19 | details: Rewrites QueryClient, QueryCache, Query, and QueryObserver. 20 | - title: react-query 21 | details: Rewrites useQuery, QueryClientProvider, and ReactQueryDevtools. 22 | --- 23 | -------------------------------------------------------------------------------- /docs/en/intro.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | We're going to rewrite TanStack Query and useQuery from scratch. 4 | 5 | > [!WARNING] The English translation was helped by AI. 6 | > 7 | > - Please feel to create an issue if you find any awkward wording. 8 | > - https://github.com/mugglim/build-your-own-tanstack-query/issues 9 | 10 | ## Goal 11 | 12 | - Learn how TanStack Query works. 13 | - Learn how to use TanStack Query in React. 14 | 15 | ## Getting Started 16 | 17 | ```bash 18 | ## 1. Clone the repository 19 | git clone https://github.com/mugglim/build-your-own-tanstack-query 20 | 21 | ## 2. Install dependencies 22 | npm install 23 | 24 | ## 3. Run the development server (It runs on https://localhost:3000 by default.) 25 | npm run dev 26 | ``` 27 | 28 | ## Result 29 | 30 | You can check out the completed implementation on [GitHub](https://github.com/mugglim/build-your-own-tanstack-query). 31 | 32 | 35 | 36 | ## Reference 37 | 38 | - [Let's Build React Query in 150 Lines of Code!](https://www.youtube.com/watch?v=9SrIirrnwk0) 39 | - [TanStack Query](https://tanstack.com/query/latest) (based on v5) 40 | -------------------------------------------------------------------------------- /docs/en/react.md: -------------------------------------------------------------------------------- 1 | # React Layer 2 | 3 | Let's learn how to use the core layer code in React. 4 | 5 | When we use TanStack Query in React, we expect two main features: 6 | 7 | - Ability to fetch server state. 8 | - Re-rendering when the server state changes. 9 | 10 | Let's implement a custom hook `useQuery` that satisfies these requirements. 11 | 12 | ## Step 1. Sharing QueryClient 13 | 14 | `QueryClient` is an object accessible globally. 15 | 16 | Let's create a custom hook that shares `QueryClient` globally using React's Context API. 17 | 18 | ```jsx 19 | export const QueryClientContext = createContext(null); 20 | 21 | export const QueryClientProvider = ({ client, children }) => { 22 | return {children}; 23 | }; 24 | 25 | export const useQueryClient = () => { 26 | const client = useContext(QueryClientContext); 27 | 28 | if (queryClient) { 29 | return queryClient; 30 | } 31 | 32 | if (!client) { 33 | throw new Error("No QueryClient set, use QueryClientProvider to set one"); 34 | } 35 | 36 | return client; 37 | }; 38 | ``` 39 | 40 | Now, if you declare `QueryClientProvider` in the top-level component like below, child components can access the `QueryClient` instance. 41 | 42 | ```jsx 43 | const queryClient = new QueryClient({ 44 | defaultOptions: { 45 | queries: { 46 | staleTime: 1_000, 47 | gcTime: 1_000 * 60 48 | } 49 | } 50 | }); 51 | 52 | const App = ({ children }) => { 53 | return {children}; 54 | }; 55 | ``` 56 | 57 | ## Step2: Fetching Server State 58 | 59 | Which core object should we use to fetch server state in React? 60 | 61 | It’s the `QueryObserver`. Using `QueryObserver`, we create and subscribe to a `Query`, fetch the server state, and receive events whenever the `Query` state changes. 62 | 63 | Let’s create a `QueryObserver` using `useState`. 64 | 65 | ```jsx 66 | const useBaseQuery = (options, Observer, queryClient) => { 67 | const client = useQueryClient(queryClient); 68 | 69 | const [observer] = useState(() => { 70 | const defaultOptions = client.defaultQueryOptions(options); 71 | 72 | return new Observer(client, defaultOptions); 73 | }); 74 | 75 | return observer.getResult(); 76 | }; 77 | ``` 78 | 79 | Now you can fetch server state and receive events whenever the state changes. However, React won’t re-render automatically because the core code is not built with React. So, even if the `Query` state changes, no re-render happens. 80 | 81 | ## Step3: Triggering Re-render 82 | 83 | Since React 18, React provides the [`useSyncExternalStore`](https://ko.react.dev/reference/react/useSyncExternalStore) hook to subscribe to external state. 84 | 85 | Using `useSyncExternalStore`, you can listen to external state changes and trigger re-render whenever the state updates. 86 | 87 | Here is how to combine core logic with `useSyncExternalStore` in the `useQuery` hook. 88 | 89 | ```jsx 90 | import { useCallback, useState, useSyncExternalStore } from "react"; 91 | import QueryObserver from "../core/QueryObserver"; 92 | import { useQueryClient } from "./QueryClientProvider"; 93 | 94 | const useBaseQuery = (options, Observer, queryClient) => { 95 | ... 96 | 97 | const subscribe = useCallback( 98 | (onStoreChange) => { 99 | const unsubscribe = observer.subscribe(onStoreChange); 100 | return unsubscribe; 101 | }, 102 | [observer] 103 | ); 104 | 105 | const getSnapshot = useCallback(() => { 106 | return observer.getResult(); 107 | }, [observer]); 108 | 109 | useSyncExternalStore(subscribe, getSnapshot); 110 | 111 | return observer.getResult(); 112 | }; 113 | 114 | const useQuery = (options, queryClient) => { 115 | return useBaseQuery(options, QueryObserver, queryClient); 116 | }; 117 | 118 | export default useQuery; 119 | ``` 120 | 121 | ## Step4: Verify Results 122 | 123 | Finally, let's verify that `useQuery` works as expected. 124 | 125 | ```js 126 | const usePostListQuery = () => { 127 | return useQuery({ 128 | queryKey: ["posts"], 129 | queryFn: async () => { 130 | const { data } = await axios.get("https://jsonplaceholder.typicode.com/posts"); 131 | 132 | return data.slice(0, 5); 133 | } 134 | }); 135 | }; 136 | 137 | const queryClient = new QueryClient({ 138 | defaultOptions: { 139 | queries: { 140 | staleTime: 1_000, 141 | gcTime: 1_000 * 60 142 | } 143 | } 144 | }); 145 | 146 | const PostList = () => { 147 | const { data: postListData } = usePostListQuery(); 148 | 149 | if (!postListData) { 150 | return
loading...
; 151 | } 152 | 153 | return ( 154 | 161 | ); 162 | }; 163 | 164 | const App = () => { 165 | return ( 166 | 167 | 168 | 169 | ); 170 | }; 171 | ``` 172 | 173 | Please refer to the video below for the correct result. 174 | 175 | 178 | -------------------------------------------------------------------------------- /docs/en/window-focus-refetching.md: -------------------------------------------------------------------------------- 1 | # Window Focus Refetching 2 | 3 | ## Explanation 4 | 5 | Let's implement logic to refetch the state when the browser's focus changes. 6 | 7 | ## Requirements 8 | 9 | - Call the `fetch` method of each Query when the browser's focus state changes. 10 | 11 | ## Solution 12 | 13 | ### QueryCache 14 | 15 | `QueryCache` manages the list of Queries. Implement an `onFocus` method that fetches each Query by iterating over the cached Queries as follows. 16 | 17 | ```jsx 18 | class QueryCache { 19 | // ... 20 | getAll = () => { 21 | const queries = this.queries.values(); 22 | 23 | return [...queries]; 24 | }; 25 | 26 | onFocus = () => { 27 | const queries = this.getAll(); 28 | 29 | queries.forEach((query) => { 30 | query.fetch(); 31 | }); 32 | }; 33 | } 34 | ``` 35 | 36 | ### QueryClientProvider 37 | 38 | We can detect focus state changes using the `visibilitychange` event on the `document` object. 39 | 40 | When the `visibilitychange` event fires, if `document.visibilityState !== 'hidden'`, it means the browser has regained focus. In this case, call the `focus` method of `QueryCache` to trigger `fetch` on active Queries. 41 | 42 | ```jsx 43 | export const QueryClientProvider = ({ children, client }) => { 44 | useEffect(() => { 45 | const cache = client.getQueryCache(); 46 | 47 | const onFocus = () => { 48 | const isFocused = document.visibilityState !== "hidden"; 49 | 50 | if (isFocused) { 51 | cache.onFocus(); 52 | } 53 | }; 54 | 55 | window.addEventListener("visibilitychange", onFocus, false); 56 | window.addEventListener("focus", onFocus, false); 57 | 58 | return () => { 59 | window.removeEventListener("visibilitychange", onFocus, false); 60 | window.removeEventListener("focus", onFocus, false); 61 | }; 62 | }, [client]); 63 | 64 | return {children}; 65 | }; 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/ko/core.md: -------------------------------------------------------------------------------- 1 | # 코어 영역 2 | 3 | 코어 영역은 특정 라이브러리를 의존하지 않습니다. 코어 영역의 핵심 로직인 `QueryClient`, `QueryCache`, `Query`, `QueryObserver`를 직접 구현하는 방법을 알아봅시다. 4 | 5 | ## Step1: QueryClient 6 | 7 | `QueryClient`는 코어 영역에서 가장 중요한 객체라고 말할 수 있습니다. TanStack Query에서 제공하는 많은 기능들을 `QueryClient`를 통해 제공하기 때문입니다. 그래서 `QueryClient`는 전역에서 접근을 허용하는 경우가 많습니다. 주로 애플리케이션 시작 시점에 `QueryClient` 인스턴스를 생성하여 공유합니다. React 환경에서는 `useContext` API를 통해 컴포넌트 간 `QueryClient` 인스턴스를 공유합니다. 8 | 9 | ```javascript 10 | import QueryCache from "./QueryCache"; 11 | import { hashKey } from "./utils"; 12 | 13 | class QueryClient { 14 | cache; 15 | defaultOptions; 16 | 17 | constructor(config) { 18 | this.cache = config.cache || new QueryCache(); 19 | this.defaultOptions = config.defaultOptions; 20 | } 21 | 22 | getQueryCache = () => { 23 | return this.cache; 24 | }; 25 | 26 | defaultQueryOptions = (options) => { 27 | // `options`가 전달되는 경우 `defaultOptions`와 병합하는 과정을 진행합니다. 28 | const mergedQueryOptions = { 29 | ...this.defaultOptions?.queries, 30 | ...options 31 | }; 32 | 33 | const defaultedQueryOptions = { 34 | ...mergedQueryOptions, 35 | queryHash: mergedQueryOptions.queryHash || hashKey(mergedQueryOptions.queryKey) 36 | }; 37 | 38 | return defaultedQueryOptions; 39 | }; 40 | } 41 | ``` 42 | 43 | `QueryClient`는 `Query` 객체의 전역 옵션값을 관리하는 `defaultQueryOptions`로 관리합니다. `defaultQueryOptions` 값은 `QueryClient` 인스턴스를 생성하는 시점에 전달할 수 있습니다. `defaultQueryOptions` 옵션을 통해 `Query` 객체를 생성할 때 중복되는 코드 영역을 선언하지 않아도 됩니다. 44 | 45 | 하지만 `QueryClient`는 많은 기능을 직접 구현하지는 않습니다. `QueryClient`는 `QueryCache` 객체를 의존하여 대부분의 기능 구현을 외부에 위임합니다. 46 | 47 | ### defaultOptions에는 어떤 값을 지정할 수 있나요? 48 | 49 | `Query`에서 제공하는 옵션값(staleTime, gcTime, etc)을 전역으로 지정할 수 있습니다. 50 | 51 | 예를 들어 다음과 같이 `defaultOptions`을 지정하면 `Query`의 기본 `staleTime` 값을 `Infinity`로 할당합니다. 52 | 53 | ```javascript 54 | const queryClient = new QueryClient({ 55 | defaultOptions: { 56 | queries: { staleTime: Infinity } 57 | } 58 | }); 59 | ``` 60 | 61 | ### QueryClient를 주로 언제 생성하나요? 62 | 63 | 애플리케이션 생성 시점에 생성합니다. `QueryClient`는 인스턴스를 생성 후 전역에서 접근을 허용하여 인스턴스를 공유하는 것을 권장합니다. 64 | 65 | > [!TIP] React QueryClientProvider 66 | > 67 | > - React에서는 [QueryClientProvider](https://tanstack.com/query/latest/docs/framework/react/reference/QueryClientProvider)를 통해 전역으로 접근을 허용합니다. 68 | 69 | ### hashKey 함수는 무엇인가요? 70 | 71 | `Query`의 queryKey 값을 직렬화하는 함수입니다. 내부적으로 `JSON.stringify()` 메소드를 사용합니다. 72 | 73 | ```javascript 74 | export function hashKey(queryKey) { 75 | return JSON.stringify(queryKey); 76 | } 77 | ``` 78 | 79 | ## Step2: QueryCache 80 | 81 | TanStack Query는 데이터를 캐싱하는 기능을 제공합니다. `QueryCache`는 `Query` 객체의 인스턴스를 **브라우저 메모리**에 저장하여 캐싱을 구현합니다. 82 | 83 | `QueryCache`는 [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) 형태로 데이터를 관리하며 조회/삭제/생성 기능을 제공합니다. 84 | 85 | ```javascript 86 | import { Query } from "./Query"; 87 | import { hashKey } from "./util"; 88 | 89 | class QueryCache { 90 | queries; 91 | 92 | constructor() { 93 | this.queries = new Map(); 94 | } 95 | 96 | get = (queryHash) => { 97 | return this.queries.get(queryHash); 98 | }; 99 | 100 | getAll = () => { 101 | const queries = this.queries.values(); 102 | 103 | return [...queries]; 104 | }; 105 | 106 | build(client, options) { 107 | const queryKey = options.queryKey; 108 | const queryHash = hashKey(queryKey); 109 | 110 | let query = this.get(queryHash); 111 | 112 | if (!query) { 113 | query = new Query({ 114 | cache: this, 115 | queryKey, 116 | queryHash, 117 | options: client.defaultQueryOptions(options) 118 | }); 119 | 120 | this.queries.set(query.queryHash, query); 121 | } 122 | 123 | return query; 124 | } 125 | 126 | remove = (query) => { 127 | this.queries.delete(query.queryHash); 128 | }; 129 | } 130 | ``` 131 | 132 | ### 캐싱은 어떻게 동작하나요? 133 | 134 | `QueryCache`는 `queries` 변수를 사용해 메모리에 캐시 데이터를 저장합니다. 135 | 136 | `queries` 변수는 [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) 사용합니다. key-value는 다음과 같습니다. 137 | 138 | - `key`: `Query`의 `queryHash` 139 | - `value`: `Query` 객체 인스턴스 140 | 141 | > [!TIP] queryHash가 무엇인가요? 142 | > 143 | > - `Query`의 `queryKey`를 해싱한 값을 의미합니다. 144 | > - 해싱은 [hashKey](https://github.com/mugglim/build-your-own-tanstack-query/blob/main/tanstack-query-lite/core/util.js#L2) 함수를 사용합니다. 145 | 146 | ### `QueryCache`에 어떻게 캐시를 추가하나요? 147 | 148 | `build` 메소드를 사용합니다. 만약 `queries`에 `Query`가 캐싱되어 있는 경우, 캐싱된 `Query` 인스턴스를 반환합니다. 149 | 150 | ```javascript{8-22} 151 | class QueryCache { 152 | ... 153 | 154 | build(client, options) { 155 | const queryKey = options.queryKey; 156 | const queryHash = hashKey(queryKey); 157 | 158 | let query = this.get(queryHash); 159 | 160 | if (!query) { 161 | query = new Query({ 162 | cache: this, 163 | queryKey, 164 | queryHash, 165 | options: client.defaultQueryOptions(options) 166 | }); 167 | 168 | this.queries.set(query.queryHash, query); 169 | } 170 | 171 | // 캐싱되어 있는 Query를 반환합니다. 172 | return query; 173 | } 174 | } 175 | ``` 176 | 177 | ## Step3: Query 178 | 179 | `Query`는 TanStack Query에서 서버 상태를 조회하고 관리합니다. 180 | 181 | `Query`는 서버 상태를 조회할 때 조회 현황(대기, 성공, 실패)과 데이터를 상태로 관리합니다. 상태가 변경될 때마다 구독자들에게 변경 사항을 전달합니다. 추가로 `Query`는 서버 상태를 조회할 때 중복 조회를 방지합니다. 182 | 183 | ```javascript 184 | export class Query { 185 | cache; 186 | queryKey; 187 | queryHash; 188 | options; 189 | observers; 190 | state; 191 | promise; 192 | gcTimeout; 193 | 194 | constructor(config) { 195 | this.observers = []; 196 | this.cache = config.cache; 197 | this.queryHash = config.queryHash; 198 | this.queryKey = config.queryKey; 199 | this.options = { 200 | ...config.defaultOptions, 201 | ...config.options 202 | }; 203 | this.state = { 204 | data: undefined, 205 | error: undefined, 206 | status: "pending", 207 | isFetching: true, 208 | lastUpdated: undefined 209 | }; 210 | 211 | this.scheduleGcTimeout(); 212 | } 213 | 214 | scheduleGcTimeout = () => { 215 | const { gcTime } = this.options; 216 | 217 | this.gcTimeout = setTimeout(() => { 218 | this.cache.remove(this); 219 | this.cache.notify(); 220 | }, gcTime); 221 | }; 222 | 223 | clearGcTimeout = () => { 224 | clearTimeout(this.gcTimeout); 225 | this.gcTimeout = null; 226 | }; 227 | 228 | subscribe = (observer) => { 229 | this.observers.push(observer); 230 | this.clearGcTimeout(); 231 | 232 | const unsubscribe = () => { 233 | this.observers = this.observers.filter((d) => { 234 | return d !== observer; 235 | }); 236 | 237 | if (!this.observers.length) { 238 | this.scheduleGcTimeout(); 239 | } 240 | }; 241 | 242 | return unsubscribe; 243 | }; 244 | 245 | setState = (updater) => { 246 | this.state = updater(this.state); 247 | 248 | this.observers.forEach((observer) => { 249 | observer.notify(); 250 | }); 251 | 252 | this.cache.notify(); 253 | }; 254 | 255 | fetch = () => { 256 | if (!this.promise) { 257 | this.promise = (async () => { 258 | this.setState((old) => ({ ...old, isFetching: true, error: undefined })); 259 | 260 | try { 261 | if (!this.options.queryFn) { 262 | throw new Error(`Missing queryFn: '${this.options.queryHash}'`); 263 | } 264 | 265 | const data = await this.options.queryFn(); 266 | 267 | this.setState((old) => ({ ...old, status: "success", data, lastUpdated: Date.now() })); 268 | } catch (error) { 269 | this.setState((old) => ({ ...old, status: "error", error })); 270 | } finally { 271 | this.setState((old) => ({ ...old, isFetching: false })); 272 | 273 | this.promise = null; 274 | } 275 | })(); 276 | } 277 | 278 | return this.promise; 279 | }; 280 | } 281 | ``` 282 | 283 | ### 서버 상태는 어떻게 관리되나요? 284 | 285 | 서버 상태 관리는 조회와 변경으로 구분할 수 있습니다. 286 | 287 | **서버 상태 조회**는 `fetch` 메소드 사용하며 `Query` 생성 시점에 전달되는 `queryFn` 함수를 사용합니다. 288 | `Query`는 동일한 요청이 중복으로 발생하는 현상을 방지하기 위해, 서버 상태 요청을 담당하는 Promise 객체를 내부 변수 `promise`로 관리합니다. 289 | 290 | `promise` 값 상태에 따른 `fetch` 메소드 동작을 정리하면 다음과 같습니다. 291 | 292 | | `promise` 값 할당 여부 | `fetch` 내부 동작 | 293 | | :--------------------: | :-----------------------------------------------------------------------------------------: | 294 | | `false` | `queryFn` 함수 기반으로 Promise 객체를 생성하여 `promise` 변수에 할당합니다. | 295 | | `true` | `promise` 값을 반환합니다. Promise 객체를 새롭게 생성하지 않아 중복 요청 호출을 방지합니다. | 296 | 297 | **서버 상태 변경**은 `setState` 메소드를 사용합니다. `Query`는 상태가 변경될 때마다 구독자들에게 상태 변경 이벤트를 전달합니다. 298 | 299 | ### `gcTime`은 무엇인가요? 300 | 301 | `gcTime`은 `QueryCache`에서 캐싱되어 있는 `Query`를 제거하지 않는 시간을 의미합니다. 302 | 303 | `Query`가 생성되는 시점에 [setTimeout](https://developer.mozilla.org/ko/docs/Web/API/Window/setTimeout)를 사용하여 `scheduleGcTimeout` 메소드를 통해 관리합니다. `gcTime` timeout이 호출되면 QueryCache에게 제거를 요청합니다. 304 | 305 | 단, `Query`에 구독이 발생될 때마다 `clearGcTimeout` 메소드를 사용하여 `gcTime` timeout이 초기화됩니다. 만약 구독이 해제될 때 구독된 구독자가 없다면 `scheduleGcTimeout`을 통해 `gcTime` timeout이 다시 할당됩니다. 306 | 307 | ## Step4: QueryObserver 308 | 309 | `QueryObserver`는 `Query`의 최적화 용도로 사용됩니다. 예를 들어 `staleTime`을 활용하여 불필요한 `Query`의 `fetch` 호출을 방지합니다. 310 | 311 | `QueryObserver`는 하나의 `Query`를 구독합니다. `queryKey` 값을 기반으로 구독할 `Query` 객체를 결정하며 `Query`의 상태가 변경될 때 마다 새로운 상태를 전달받습니다. 312 | 313 | ```javascript 314 | class QueryObserver { 315 | client; 316 | options; 317 | notify; 318 | 319 | constructor(client, options) { 320 | this.client = client; 321 | this.options = options; 322 | } 323 | 324 | getQuery = () => { 325 | const query = this.client.getQueryCache().build(this.client, this.options); 326 | 327 | return query; 328 | }; 329 | 330 | getResult = () => { 331 | return this.getQuery().state; 332 | }; 333 | 334 | subscribe = (callback) => { 335 | this.notify = callback; 336 | 337 | const query = this.getQuery(); 338 | 339 | const { lastUpdated } = query.state; 340 | const { staleTime } = this.options; 341 | 342 | const needsToFetch = !lastUpdated || Date.now() - lastUpdated > staleTime; 343 | 344 | const unsubscribeQuery = query.subscribe(this); 345 | 346 | if (needsToFetch) { 347 | query.fetch(); 348 | } 349 | 350 | const unsubscribe = () => { 351 | unsubscribeQuery(); 352 | }; 353 | 354 | return unsubscribe; 355 | }; 356 | } 357 | ``` 358 | 359 | ### `staleTime`은 무엇인가요? 360 | 361 | `staleTime`은 서버 상태를 fresh 상태에서 stale 상태로 변경되는 시간을 의미합니다. 362 | 363 | `Query`는 서버 상태가 마지막으로 변경된 시점을 `lastUpdated` 변수로 저장합니다. `QueryObserver`는 `Query`의 `lastUpdated` 값을 활용하여 `fetch` 메소드가 실행되기 전 `Date.now() - lastUpdated` 값이 `staleTime` 보다 큰 경우에만 `fetch` 메소드를 실행시킵니다. 364 | 365 | `Date.now() - lastUpdated` > `staleTime` 값 상태에 따른 `fetch` 메소드 동작을 정리하면 다음과 같습니다. 366 | 367 | | `Date.now() - lastUpdated` > `staleTime` | `fetch` 실행 여부 | 368 | | :--------------------------------------: | :---------------: | 369 | | `false` | `false` | 370 | | `true ` | `true` | 371 | 372 | > [!TIP] fresh, stale 한 상태가 무엇인가요? 373 | > 374 | > - **fresh 상태** 375 | > - 최신 서버 상태를 의미합니다. 376 | > - **stale 상태** 377 | > - 최신 서버 상태가 아닌 상황을 의미합니다. 378 | > - (참고) `Date.now() - lastUpdated` 값이 `staleTime` 보다 큰 경우 stale 상태라고 판단합니다. 379 | -------------------------------------------------------------------------------- /docs/ko/devtools.md: -------------------------------------------------------------------------------- 1 | # ReactQueryDevTools 2 | 3 | ## 설명 4 | 5 | TanStack Query의 [ReactQueryDevTools](https://tanstack.com/query/v5/docs/framework/react/devtools)를 만들어봅시다. 6 | 7 | ## 요구사항 8 | 9 | - 캐싱 되어 있는 `Query`의 status, staleTime, gcTime 정보가 표시됩니다. 10 | - 캐싱 되어 있는 `Query`의 변화가 발생하면 최신 `Query` 목록을 갱신합니다. 11 | 12 | ## 해결방안 13 | 14 | `QueryCache`에 캐싱 되어 있는 `Query`의 변화를 감지하기 위해, `QueryCache`에게 구독 기능을 적용합니다. 15 | 16 | ### QueryCache 17 | 18 | ```jsx 19 | class QueryCache { 20 | listeners; 21 | 22 | constructor() { 23 | // 이벤트를 발행할 구독자들을 저장합니다. 24 | this.listeners = new Set(); 25 | } 26 | 27 | // 이벤트를 발행할 구독자를 추가합니다. 28 | subscribe = (listener) => { 29 | this.listeners.add(listener); 30 | 31 | const unsubscribe = () => { 32 | this.listeners.delete(listener); 33 | }; 34 | 35 | return unsubscribe; 36 | }; 37 | 38 | // 이벤트를 발행합니다. 39 | notify = () => { 40 | this.listeners.forEach((callback) => { 41 | callback(); 42 | }); 43 | }; 44 | } 45 | ``` 46 | 47 | ### Query 48 | 49 | `Query`는 서버 상태가 변경될 때 `QueryCache`의 notify 메소드를 호출하여, `QueryCache`에 구독되어 있는 구독자들에게 이벤트를 발행합니다. 50 | 51 | ```jsx 52 | class Query { 53 | scheduleGcTimeout = () => { 54 | // ... 55 | this.gcTimeout = setTimeout(() => { 56 | // gc 시점에 QueryCache에게 이벤트를 발행합니다 57 | this.cache.notify(); 58 | }, gcTime); 59 | }; 60 | 61 | setState() { 62 | // ... 63 | 64 | // 상태 변경되면 QueryCache에게 이벤트를 발행합니다. 65 | this.cache.notify(); 66 | } 67 | } 68 | ``` 69 | 70 | ### ReactQueryDevtools 71 | 72 | ReactQueryDevtools는 `QueryCache`를 통해 캐싱되어 있는 Query 목록을 조회합니다. 서버 상태가 변경될 때 Query 목록의 상태를 갱신하기 위해 리렌더링 됩니다. 73 | 74 | ```jsx 75 | const ReactQueryDevtools = () => { 76 | const queryClient = useQueryClient(); 77 | 78 | // rerender 함수를 호출하면 다시 렌더링이 발생합니다. 79 | const [, rerender] = useReducer((i) => i + 1, 0); 80 | 81 | useEffect(() => { 82 | // QueryCache에서 notify 이벤트가 발행되면 rerender 함수를 호출합니다. 83 | return queryClient.cache.subscribe(rerender); 84 | }, [queryClient]); 85 | 86 | const queries = queryClient.getQueryCache().getAll(); 87 | const sortedQueries = [...queries].sort((a, b) => (a.queryHash > b.queryHash ? 1 : -1)); 88 | 89 | return ( 90 |
91 | {sortedQueries.map((query) => { 92 | const { queryKey, queryHash, state, observers, options } = query; 93 | const { isFetching, status } = state; 94 | 95 | const { staleTime, gcTime } = options; 96 | 97 | return ( 98 |
99 | {JSON.stringify(queryKey, null, 2)}, {JSON.stringify({ staleTime, gcTime }, null, 2)} -{" "} 100 | 101 | {(() => { 102 | if (isFetching) { 103 | return fetching; 104 | } 105 | 106 | if (!observers.length) { 107 | return inactive; 108 | } 109 | 110 | if (status === "success") { 111 | return success; 112 | } 113 | 114 | if (status === "error") { 115 | return error; 116 | } 117 | 118 | return null; 119 | })()} 120 | 121 |
122 | ); 123 | })} 124 |
125 | ); 126 | }; 127 | ``` 128 | 129 | 최상위 컴포넌트에서 `ReactQueryDevtools`를 렌더링하면 DevTools가 동작하는 것을 확인하실 수 있습니다. 130 | 131 | ```jsx 132 | const App = ({ children }) => { 133 | return ( 134 | 135 | 136 | {children} 137 | 138 | ); 139 | }; 140 | ``` 141 | -------------------------------------------------------------------------------- /docs/ko/guide.md: -------------------------------------------------------------------------------- 1 | # 개요 2 | 3 | TanStack Query의 구조는 **코어 영역**과 **라이브러리 지원 영역**으로 분리해 볼 수 있습니다. 4 | 5 | 코어 영역은 특정 라이브러리를 의존하지 않습니다. 코어 영역에는 TanStack Query의 핵심 로직인 `QueryClient`, `QueryCache`, `Query`, `QueryObserver`이 있습니다. 6 | 7 | 라이브러리 지원 영역은 코어 영역의 코드를 기반하여, 특정 라이브러리에서 TanStack Query를 사용할 수 있는 코드를 제공합니다. 예를 들어 React에서 TanStack Query를 사용하는 경우에는 react-query 패키지를 사용하면 됩니다. 8 | 9 | > [!TIP] TanStack Query에서 제공하는 코드가 궁금해요. 10 | > 코어 영역과 React 영역 코드는 다음 링크로 확인하실 수 있습니다. 11 | > 12 | > - 코어 영역: https://github.com/TanStack/query/tree/main/packages/query-core 13 | > - React 영역: https://github.com/TanStack/query/tree/main/packages/react-query 14 | 15 | ## tanstack-query-lite 구조 이해하기 16 | 17 | 앞으로 우리가 작성해 볼 코드는 경량화된 TanStack Query입니다. 앞으로 작성할 패키지의 이름을 `tanstack-query-lite`로 부르겠습니다. 18 | 19 | > [!WARNING] production 환경에서 사용을 주의하세요. 20 | > `tanstack-query-lite`는 학습을 위한 코드입니다. production 환경에서 사용은 **지양합니다**. 21 | 22 | `tanstack-query-lite` 패키지는 두 가지 폴더로 분리되어 있습니다. 23 | 24 | 1. `tanstack-query-lite/core`: 코어 영역의 코드를 구현합니다. 25 | 2. `tanstack-query-lite/react`: React를 의존하는 코드를 구현합니다. 26 | 27 | `tanstack-query-lite` 패키지의 전체적인 구조는 다음과 같습니다. 28 | 29 | ![image](/architecture.png) 30 | -------------------------------------------------------------------------------- /docs/ko/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: "Build Your Own TanStack Query" 6 | tagline: "밑바닥부터 구현해보기" 7 | actions: 8 | - theme: brand 9 | text: 알아보기 10 | link: /ko/intro.html 11 | - theme: alt 12 | text: GitHub 13 | link: https://github.com/mugglim/build-your-own-tanstack-query 14 | 15 | features: 16 | - title: 직접 만들어보는 경험 17 | details: 밑바닥부터 구현해보면서 TanStack Query의 이해도를 높일 수 있습니다. 18 | - title: query-core 19 | details: QueryClient, QueryCache, Query, QueryObserver를 직접 구현해봅니다. 20 | - title: react-query 21 | details: useQuery, QueryClientProvider, ReactQueryDevtools를 직접 구현해봅니다. 22 | --- 23 | -------------------------------------------------------------------------------- /docs/ko/intro.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | ## 개요 4 | 5 | TanStack Query 라이브러리를 밑바닥부터 구현하는 방법을 소개합니다. 6 | 7 | ## 목표 8 | 9 | - TanStack Query의 동작 흐름을 알아봅니다. 10 | - React 환경에서 TanStack Query를 사용하는 방법을 알아봅니다. 11 | 12 | ## 개발 환경 구성하기 13 | 14 | ```bash 15 | ## 1. 레포지토리를 복제합니다. 16 | git clone https://mugglim.github.io/build-your-own-tanstack-query 17 | 18 | ## 2. 의존성을 설치합니다. 19 | npm install 20 | 21 | ## 3. 개발 서버을 실행합니다. (기본적으로 https://localhost:3000 경로에 실행됩니다.) 22 | npm run dev 23 | ``` 24 | 25 | ## 최종 결과 확인하기 26 | 27 | 구현된 코드는 [GitHub](https://github.com/mugglim/build-your-own-tanstack-query)에서 확인하실 수 있습니다. 28 | 29 | 32 | 33 | ## Reference 34 | 35 | - [Let's Build React Query in 150 Lines of Code!](https://www.youtube.com/watch?v=9SrIirrnwk0) 36 | - [TanStack Query](https://tanstack.com/query/latest) (based on v5) 37 | -------------------------------------------------------------------------------- /docs/ko/react.md: -------------------------------------------------------------------------------- 1 | # React 영역 2 | 3 | React에서 코어 영역의 코드를 사용하는 방법을 알아봅시다. 4 | 5 | 우리가 React에서 TanStack Query를 사용하는 경우 두 가지 기능을 기대합니다. 6 | 7 | - 서버 상태를 조회할 수 있다. 8 | - 서버 상태가 변경될 때 리렌더링이 발생한다. 9 | 10 | 위 요구사항을 만족하는 `useQuery` 커스텀훅을 구현해 봅시다. 11 | 12 | ## Step1. QueryClient 공유하기 13 | 14 | `QueryClient`는 전역에서 접근이 가능한 객체입니다. Context API를 이용하여 전역으로 `QueryClient`를 공유하는 커스텀 Hook을 작성해 봅시다. 15 | 16 | ```jsx 17 | export const QueryClientContext = createContext(null); 18 | 19 | export const QueryClientProvider = ({ client, children }) => { 20 | return {children}; 21 | }; 22 | 23 | export const useQueryClient = () => { 24 | const client = useContext(QueryClientContext); 25 | 26 | if (queryClient) { 27 | return queryClient; 28 | } 29 | 30 | if (!client) { 31 | throw new Error("No QueryClient set, use QueryClientProvider to set one"); 32 | } 33 | 34 | return client; 35 | }; 36 | ``` 37 | 38 | 이제 다음과 같이 최상위 컴포넌트에서 `QueryClientProvider`를 선언하면 자식 컴포넌트에서 `QueryClient`를 접근할 수 있습니다. 39 | 40 | ```jsx 41 | const queryClient = new QueryClient({ 42 | defaultOptions: { 43 | queries: { 44 | staleTime: 1_000, 45 | gcTime: 1_000 * 60 46 | } 47 | } 48 | }); 49 | 50 | const App = ({ children }) => { 51 | return {children}; 52 | }; 53 | ``` 54 | 55 | ## Step2: 서버 상태 조회하기 56 | 57 | React에서 서버 상태를 조회하려면 코어 영역의 어떤 객체를 사용해야 할까요? 58 | 59 | `QueryObserver`입니다. `QueryObserver`를 사용하면 `Query`를 생성 및 구독하여 서버 상태를 조회할 수 있고, `Query`의 상태가 변경될 떄마다 이벤트도 전달받습니다. 60 | 61 | `useState`를 통해 `QueryObserver`를 생성해 봅시다. 62 | 63 | ```jsx 64 | const useBaseQuery = (options, Observer, queryClient) => { 65 | const client = useQueryClient(queryClient); 66 | 67 | const [observer] = useState(() => { 68 | const defaultOptions = client.defaultQueryOptions(options); 69 | 70 | return new Observer(client, defaultOptions); 71 | }); 72 | 73 | return observer.getResult(); 74 | }; 75 | ``` 76 | 77 | 이제 서버 상태를 조회하고 상태가 변경될 떄마다 이벤트를 전달받을 수 있습니다. 하지만 리렌더링은 발생하지 않습니다. 코어 영역의 코드는 React로 작성된 코드가 아닙니다. 그래서 `Query`의 상태가 변경되더라도 리렌더링이 발생하지 않습니다. 78 | 79 | ## Step3: 리렌더링 발생시키기 80 | 81 | React는 18 버전부터 외부 상태를 구독할 수 있는 [useSyncExternalStore](https://ko.react.dev/reference/react/useSyncExternalStore) 커스텀 Hook을 제공하고 있습니다. 82 | 83 | `useSyncExternalStore`을 통해 외부 상태 변경을 구독할 수 있으며, 상태 값이 변경될 때마다 리렌더링을 발생시킬 수 있습니다. 84 | 85 | `useQuery`에 코어 영역의 코드와 `useSyncExternalStore`를 사용해 정리해 보면 다음과 같습니다. 86 | 87 | ```jsx 88 | import { useCallback, useState, useSyncExternalStore } from "react"; 89 | import QueryObserver from "../core/QueryObserver"; 90 | import { useQueryClient } from "./QueryClientProvider"; 91 | 92 | const useBaseQuery = (options, Observer, queryClient) => { 93 | ... 94 | 95 | const subscribe = useCallback( 96 | (onStoreChange) => { 97 | const unsubscribe = observer.subscribe(onStoreChange); 98 | return unsubscribe; 99 | }, 100 | [observer] 101 | ); 102 | 103 | const getSnapshot = useCallback(() => { 104 | return observer.getResult(); 105 | }, [observer]); 106 | 107 | useSyncExternalStore(subscribe, getSnapshot); 108 | 109 | return observer.getResult(); 110 | }; 111 | 112 | const useQuery = (options, queryClient) => { 113 | return useBaseQuery(options, QueryObserver, queryClient); 114 | }; 115 | 116 | export default useQuery; 117 | ``` 118 | 119 | ## Step4: 동작 검증하기 120 | 121 | 마지막으로 `useQuery`의 동작을 검증해 봅시다. 122 | 123 | ```js 124 | const usePostListQuery = () => { 125 | return useQuery({ 126 | queryKey: ["posts"], 127 | queryFn: async () => { 128 | const { data } = await axios.get("https://jsonplaceholder.typicode.com/posts"); 129 | 130 | return data.slice(0, 5); 131 | } 132 | }); 133 | }; 134 | 135 | const queryClient = new QueryClient({ 136 | defaultOptions: { 137 | queries: { 138 | staleTime: 1_000, 139 | gcTime: 1_000 * 60 140 | } 141 | } 142 | }); 143 | 144 | const PostList = () => { 145 | const { data: postListData } = usePostListQuery(); 146 | 147 | if (!postListData) { 148 | return
loading...
; 149 | } 150 | 151 | return ( 152 | 159 | ); 160 | }; 161 | 162 | const App = () => { 163 | return ( 164 | 165 | 166 | 167 | ); 168 | }; 169 | ``` 170 | 171 | 정상 동작은 아래 영상을 참고해 주세요. 172 | 173 | 176 | -------------------------------------------------------------------------------- /docs/ko/window-focus-refetching.md: -------------------------------------------------------------------------------- 1 | # Window Focus Refetching 2 | 3 | ## 설명 4 | 5 | 브라우저의 focus 상태가 변경될 때 상태를 다시 조회하는 로직을 구현해봅시다. 6 | 7 | ## 요구사항 8 | 9 | - 브라우저의 focus 상태가 변경될 때 Query의 `fetch` 메소드가 호출됩니다. 10 | 11 | ## 해결방안 12 | 13 | ### QueryCache 14 | 15 | `QueryCache`는 Query 목록을 관리하고 있습니다. 다음과 같이 Query 목록을 조회 후 `fetch` 발생시키는 `onFocus` 메소드를 구현해봅시다. 16 | 17 | ```jsx 18 | class QueryCache { 19 | // ... 20 | getAll = () => { 21 | const queries = this.queries.values(); 22 | 23 | return [...queries]; 24 | }; 25 | 26 | onFocus = () => { 27 | const queries = this.getAll(); 28 | 29 | queries.forEach((query) => { 30 | query.fetch(); 31 | }); 32 | }; 33 | } 34 | ``` 35 | 36 | ### QueryClientProvider 37 | 38 | focus 상태 변경에 대한 감지는 document 객체의 [visibilitychange](https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event) 이벤트를 기반으로 감지할 수 있습니다. 39 | 40 | `visibilitychange` 이벤트가 발생할 때 `document.visibilityState !== hidden` 인 경우, 브라우저의 focus가 다시 활성화된 상태로 판단할 수 있습니다. 브라우저의 focus가 다시 활성화된 경우, `QueryCache`의 `focus` 메소드를 호출하여 활성화 된 `Query`들에게 fetch를 발생시킬 수 있습니다. 41 | 42 | ```jsx 43 | export const QueryClientProvider = ({ children, client }) => { 44 | useEffect(() => { 45 | const cache = client.getQueryCache(); 46 | 47 | const onFocus = () => { 48 | const isFocused = document.visibilityState !== "hidden"; 49 | 50 | if (isFocused) { 51 | cache.onFocus(); 52 | } 53 | }; 54 | 55 | window.addEventListener("visibilitychange", onFocus, false); 56 | window.addEventListener("focus", onFocus, false); 57 | 58 | return () => { 59 | window.removeEventListener("visibilitychange", onFocus, false); 60 | window.removeEventListener("focus", onFocus, false); 61 | }; 62 | }, [client]); 63 | 64 | return {children}; 65 | }; 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/public/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mugglim/build-your-own-tanstack-query/ce88f33ecd123588c1a92adf4d93d272c4468b6a/docs/public/architecture.png -------------------------------------------------------------------------------- /docs/public/demo.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mugglim/build-your-own-tanstack-query/ce88f33ecd123588c1a92adf4d93d272c4468b6a/docs/public/demo.mov -------------------------------------------------------------------------------- /docs/public/tanstack-query-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mugglim/build-your-own-tanstack-query/ce88f33ecd123588c1a92adf4d93d272c4468b6a/docs/public/tanstack-query-logo.png -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | import pluginImport from "eslint-plugin-import"; 7 | 8 | export default tseslint.config( 9 | { ignores: ["dist"] }, 10 | { 11 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 12 | files: ["**/*.{ts,tsx}", "**/*.{js,jsx}"], 13 | languageOptions: { 14 | ecmaVersion: 2020, 15 | globals: globals.browser 16 | }, 17 | plugins: { 18 | "react-hooks": reactHooks, 19 | "react-refresh": reactRefresh, 20 | import: pluginImport 21 | }, 22 | rules: { 23 | ...reactHooks.configs.recommended.rules, 24 | "@typescript-eslint/no-empty-object-type": "off", 25 | "react-refresh/only-export-components": "off", 26 | "import/order": [ 27 | "error", 28 | { 29 | groups: ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"] 30 | } 31 | ] 32 | } 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Build Your Own TanStack Query 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-your-own-tanstack-query", 3 | "private": true, 4 | "homepage": "https://mugglim.github.io/build-your-own-tanstack-query", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "docs:dev": "vitepress dev docs", 12 | "docs:build": "vitepress build docs", 13 | "docs:preview": "vitepress preview docs" 14 | }, 15 | "dependencies": { 16 | "@tanstack/react-router": "^1.78.2", 17 | "axios": "^1.7.7", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "^9.13.0", 23 | "@tanstack/router-devtools": "^1.78.2", 24 | "@tanstack/router-plugin": "^1.78.2", 25 | "@types/node": "^22.9.0", 26 | "@types/react": "^18.3.12", 27 | "@types/react-dom": "^18.3.1", 28 | "@vitejs/plugin-react": "^4.3.3", 29 | "autoprefixer": "^10.4.20", 30 | "eslint": "^9.11.0", 31 | "eslint-plugin-import": "^2.31.0", 32 | "eslint-plugin-react-hooks": "^5.0.0", 33 | "eslint-plugin-react-refresh": "^0.4.14", 34 | "globals": "^15.11.0", 35 | "postcss": "^8.4.47", 36 | "tailwindcss": "^3.4.14", 37 | "typescript": "~5.6.2", 38 | "typescript-eslint": "^8.8.0", 39 | "vite": "^5.4.10", 40 | "vitepress": "^1.6.3" 41 | }, 42 | "optionalDependencies": { 43 | "@rollup/rollup-linux-x64-gnu": "^4.30.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { RouterProvider } from "@tanstack/react-router"; 4 | import { QueryClient } from "tanstack-query-lite/core/QueryClient"; 5 | import { QueryClientProvider } from "tanstack-query-lite/react/QueryClientProvider"; 6 | import ReactQueryDevtools from "tanstack-query-lite/react/ReactQueryDevtools"; 7 | 8 | import router from "./router"; 9 | 10 | import "./index.css"; 11 | 12 | const queryClient = new QueryClient({ 13 | defaultOptions: { 14 | queries: { 15 | staleTime: 0, 16 | gcTime: 5_000 17 | } 18 | } 19 | }); 20 | 21 | const rootElement = document.getElementById("root"); 22 | 23 | if (!rootElement.innerHTML) { 24 | const root = ReactDOM.createRoot(rootElement); 25 | root.render( 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/queries/usePostDetailQuery/index.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import useQuery from "tanstack-query-lite/react/useQuery"; 4 | 5 | const usePostDetailQuery = ({ id }) => { 6 | return useQuery({ 7 | queryKey: ["post", id], 8 | queryFn: async () => { 9 | const { data } = await axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`); 10 | 11 | return data; 12 | } 13 | }); 14 | }; 15 | 16 | export default usePostDetailQuery; 17 | -------------------------------------------------------------------------------- /src/queries/usePostListQuery/index.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import useQuery from "tanstack-query-lite/react/useQuery"; 4 | 5 | const usePostListQuery = () => { 6 | return useQuery({ 7 | queryKey: ["posts"], 8 | queryFn: async () => { 9 | const { data } = await axios.get("https://jsonplaceholder.typicode.com/posts"); 10 | 11 | return data.slice(0, 5); 12 | } 13 | }); 14 | }; 15 | 16 | export default usePostListQuery; 17 | -------------------------------------------------------------------------------- /src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | // Import Routes 12 | 13 | import { Route as rootRoute } from "./routes/__root"; 14 | import { Route as IndexImport } from "./routes/index"; 15 | import { Route as PostsPostIdImport } from "./routes/posts/$postId"; 16 | 17 | // Create/Update Routes 18 | 19 | const IndexRoute = IndexImport.update({ 20 | id: "/", 21 | path: "/", 22 | getParentRoute: () => rootRoute 23 | } as any); 24 | 25 | const PostsPostIdRoute = PostsPostIdImport.update({ 26 | id: "/posts/$postId", 27 | path: "/posts/$postId", 28 | getParentRoute: () => rootRoute 29 | } as any); 30 | 31 | // Populate the FileRoutesByPath interface 32 | 33 | declare module "@tanstack/react-router" { 34 | interface FileRoutesByPath { 35 | "/": { 36 | id: "/"; 37 | path: "/"; 38 | fullPath: "/"; 39 | preLoaderRoute: typeof IndexImport; 40 | parentRoute: typeof rootRoute; 41 | }; 42 | "/posts/$postId": { 43 | id: "/posts/$postId"; 44 | path: "/posts/$postId"; 45 | fullPath: "/posts/$postId"; 46 | preLoaderRoute: typeof PostsPostIdImport; 47 | parentRoute: typeof rootRoute; 48 | }; 49 | } 50 | } 51 | 52 | // Create and export the route tree 53 | 54 | export interface FileRoutesByFullPath { 55 | "/": typeof IndexRoute; 56 | "/posts/$postId": typeof PostsPostIdRoute; 57 | } 58 | 59 | export interface FileRoutesByTo { 60 | "/": typeof IndexRoute; 61 | "/posts/$postId": typeof PostsPostIdRoute; 62 | } 63 | 64 | export interface FileRoutesById { 65 | __root__: typeof rootRoute; 66 | "/": typeof IndexRoute; 67 | "/posts/$postId": typeof PostsPostIdRoute; 68 | } 69 | 70 | export interface FileRouteTypes { 71 | fileRoutesByFullPath: FileRoutesByFullPath; 72 | fullPaths: "/" | "/posts/$postId"; 73 | fileRoutesByTo: FileRoutesByTo; 74 | to: "/" | "/posts/$postId"; 75 | id: "__root__" | "/" | "/posts/$postId"; 76 | fileRoutesById: FileRoutesById; 77 | } 78 | 79 | export interface RootRouteChildren { 80 | IndexRoute: typeof IndexRoute; 81 | PostsPostIdRoute: typeof PostsPostIdRoute; 82 | } 83 | 84 | const rootRouteChildren: RootRouteChildren = { 85 | IndexRoute: IndexRoute, 86 | PostsPostIdRoute: PostsPostIdRoute 87 | }; 88 | 89 | export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileTypes(); 90 | 91 | /* ROUTE_MANIFEST_START 92 | { 93 | "routes": { 94 | "__root__": { 95 | "filePath": "__root.jsx", 96 | "children": [ 97 | "/", 98 | "/posts/$postId" 99 | ] 100 | }, 101 | "/": { 102 | "filePath": "index.jsx" 103 | }, 104 | "/posts/$postId": { 105 | "filePath": "posts/$postId.jsx" 106 | } 107 | } 108 | } 109 | ROUTE_MANIFEST_END */ 110 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import { createRouter } from "@tanstack/react-router"; 2 | 3 | import { routeTree } from "./routeTree.gen"; 4 | 5 | const router = createRouter({ routeTree }); 6 | 7 | export default router; 8 | -------------------------------------------------------------------------------- /src/routes/__root.jsx: -------------------------------------------------------------------------------- 1 | import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; 2 | 3 | export const Route = createRootRoute({ 4 | component: () => ( 5 | <> 6 |
7 | 8 | Posts 9 | 10 |
11 |
12 |
13 | 14 |
15 | 16 | ), 17 | }); 18 | -------------------------------------------------------------------------------- /src/routes/index.jsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, Link } from "@tanstack/react-router"; 2 | 3 | import usePostListQuery from "~/queries/usePostListQuery"; 4 | 5 | const Index = () => { 6 | const { data: postListData } = usePostListQuery(); 7 | 8 | if (!postListData) { 9 | return
loading...
; 10 | } 11 | 12 | return ( 13 |
    14 | {postListData.map((post) => { 15 | const { id, title } = post; 16 | 17 | return ( 18 |
  • 19 | {title} 20 |
  • 21 | ); 22 | })} 23 |
24 | ); 25 | }; 26 | 27 | export const Route = createFileRoute("/")({ 28 | component: Index, 29 | }); 30 | -------------------------------------------------------------------------------- /src/routes/posts/$postId.jsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | 3 | import usePostDetailQuery from "~/queries/usePostDetailQuery"; 4 | 5 | const PostDetail = () => { 6 | const { postId } = Route.useParams(); 7 | 8 | const { data: postDetailData } = usePostDetailQuery({ id: postId }); 9 | 10 | if (!postDetailData) { 11 | return
loading...
; 12 | } 13 | 14 | return <>{postDetailData.title}; 15 | }; 16 | 17 | export const Route = createFileRoute("/posts/$postId")({ 18 | component: PostDetail, 19 | }); 20 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "./tanstack-query-lite/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tanstack-query-lite/core/Query.js: -------------------------------------------------------------------------------- 1 | export class Query { 2 | cache; 3 | queryKey; 4 | queryHash; 5 | options; 6 | observers; 7 | state; 8 | promise; 9 | gcTimeout; 10 | 11 | constructor(config) { 12 | this.observers = []; 13 | this.cache = config.cache; 14 | this.queryHash = config.queryHash; 15 | this.queryKey = config.queryKey; 16 | this.options = { 17 | ...config.defaultOptions, 18 | ...config.options 19 | }; 20 | this.state = { 21 | data: undefined, 22 | error: undefined, 23 | status: "pending", 24 | isFetching: true, 25 | lastUpdated: undefined 26 | }; 27 | 28 | this.scheduleGcTimeout(); 29 | } 30 | 31 | scheduleGcTimeout = () => { 32 | const { gcTime } = this.options; 33 | 34 | this.gcTimeout = setTimeout(() => { 35 | this.cache.remove(this); 36 | this.cache.notify(); 37 | }, gcTime); 38 | }; 39 | 40 | clearGcTimeout = () => { 41 | clearTimeout(this.gcTimeout); 42 | this.gcTimeout = null; 43 | }; 44 | 45 | subscribe = (observer) => { 46 | this.observers.push(observer); 47 | this.clearGcTimeout(); 48 | 49 | const unsubscribe = () => { 50 | this.observers = this.observers.filter((d) => { 51 | return d !== observer; 52 | }); 53 | 54 | if (!this.observers.length) { 55 | this.scheduleGcTimeout(); 56 | } 57 | }; 58 | 59 | return unsubscribe; 60 | }; 61 | 62 | setState = (updater) => { 63 | this.state = updater(this.state); 64 | 65 | this.observers.forEach((observer) => { 66 | observer.notify(); 67 | }); 68 | 69 | this.cache.notify(); 70 | }; 71 | 72 | fetch = () => { 73 | if (!this.promise) { 74 | this.promise = (async () => { 75 | this.setState((old) => ({ ...old, isFetching: true, error: undefined })); 76 | 77 | try { 78 | if (!this.options.queryFn) { 79 | throw new Error(`Missing queryFn: '${this.options.queryHash}'`); 80 | } 81 | 82 | const data = await this.options.queryFn(); 83 | 84 | this.setState((old) => ({ ...old, status: "success", data, lastUpdated: Date.now() })); 85 | } catch (error) { 86 | this.setState((old) => ({ ...old, status: "error", error })); 87 | } finally { 88 | this.setState((old) => ({ ...old, isFetching: false })); 89 | 90 | this.promise = null; 91 | } 92 | })(); 93 | } 94 | 95 | return this.promise; 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /tanstack-query-lite/core/QueryCache.js: -------------------------------------------------------------------------------- 1 | import { Query } from "./Query"; 2 | import { hashKey } from "./util"; 3 | 4 | class QueryCache { 5 | queries; 6 | listeners; 7 | 8 | constructor() { 9 | this.queries = new Map(); 10 | this.listeners = new Set(); 11 | } 12 | 13 | get = (queryHash) => { 14 | return this.queries.get(queryHash); 15 | }; 16 | 17 | getAll = () => { 18 | const queries = this.queries.values(); 19 | 20 | return [...queries]; 21 | }; 22 | 23 | build(client, options) { 24 | const queryKey = options.queryKey; 25 | const queryHash = hashKey(queryKey); 26 | 27 | let query = this.get(queryHash); 28 | 29 | if (!query) { 30 | query = new Query({ 31 | cache: this, 32 | queryKey, 33 | queryHash, 34 | options: client.defaultQueryOptions(options) 35 | }); 36 | 37 | this.queries.set(query.queryHash, query); 38 | this.notify(); 39 | } 40 | 41 | return query; 42 | } 43 | 44 | remove = (query) => { 45 | this.queries.delete(query.queryHash); 46 | }; 47 | 48 | subscribe = (listener) => { 49 | this.listeners.add(listener); 50 | 51 | const unsubscribe = () => { 52 | this.listeners.delete(listener); 53 | }; 54 | 55 | return unsubscribe; 56 | }; 57 | 58 | notify = () => { 59 | this.listeners.forEach((callback) => { 60 | callback(); 61 | }); 62 | }; 63 | 64 | onFocus = () => { 65 | const queries = this.getAll(); 66 | 67 | queries.forEach((query) => { 68 | query.fetch(); 69 | }); 70 | }; 71 | } 72 | 73 | export default QueryCache; 74 | -------------------------------------------------------------------------------- /tanstack-query-lite/core/QueryClient.js: -------------------------------------------------------------------------------- 1 | import QueryCache from "./QueryCache"; 2 | import { hashKey } from "./util"; 3 | 4 | export class QueryClient { 5 | cache; 6 | defaultOptions; 7 | 8 | constructor(config) { 9 | this.cache = config.cache || new QueryCache(); 10 | this.defaultOptions = config.defaultOptions; 11 | } 12 | 13 | getQueryCache = () => { 14 | return this.cache; 15 | }; 16 | 17 | defaultQueryOptions = (options) => { 18 | const mergedQueryOptions = { 19 | ...this.defaultOptions?.queries, 20 | ...options 21 | }; 22 | 23 | const defaultedQueryOptions = { 24 | ...mergedQueryOptions, 25 | queryHash: mergedQueryOptions.queryHash || hashKey(mergedQueryOptions.queryKey) 26 | }; 27 | 28 | return defaultedQueryOptions; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /tanstack-query-lite/core/QueryObserver.js: -------------------------------------------------------------------------------- 1 | const noop = () => {}; 2 | 3 | class QueryObserver { 4 | client; 5 | options; 6 | notify = noop; 7 | 8 | constructor(client, options) { 9 | this.client = client; 10 | this.options = options; 11 | } 12 | 13 | getQuery = () => { 14 | const query = this.client.getQueryCache().build(this.client, this.options); 15 | 16 | return query; 17 | }; 18 | 19 | getResult = () => { 20 | return this.getQuery().state; 21 | }; 22 | 23 | subscribe = (callback) => { 24 | this.notify = callback; 25 | 26 | const query = this.getQuery(); 27 | 28 | const { lastUpdated } = query.state; 29 | const { staleTime } = this.options; 30 | 31 | const needsToFetch = !lastUpdated || Date.now() - lastUpdated > staleTime; 32 | 33 | const unsubscribeQuery = query.subscribe(this); 34 | 35 | if (needsToFetch) { 36 | query.fetch(); 37 | } 38 | 39 | const unsubscribe = () => { 40 | unsubscribeQuery(); 41 | this.notify = noop; 42 | }; 43 | 44 | return unsubscribe; 45 | }; 46 | } 47 | 48 | export default QueryObserver; 49 | -------------------------------------------------------------------------------- /tanstack-query-lite/core/util.js: -------------------------------------------------------------------------------- 1 | /** {@link https://github.com/TanStack/query/blob/74c65cc2db0fa378c108448445f38464e1acd27a/packages/query-core/src/utils.ts#L201-L216 More info } */ 2 | export function hashKey(queryKey) { 3 | return JSON.stringify(queryKey); 4 | } 5 | -------------------------------------------------------------------------------- /tanstack-query-lite/react/QueryClientProvider.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect } from "react"; 2 | 3 | export const QueryClientContext = createContext(null); 4 | 5 | export const useQueryClient = (queryClient) => { 6 | const client = useContext(QueryClientContext); 7 | 8 | if (queryClient) { 9 | return queryClient; 10 | } 11 | 12 | if (!client) { 13 | throw new Error("No QueryClient set, use QueryClientProvider to set one"); 14 | } 15 | 16 | return client; 17 | }; 18 | 19 | export const QueryClientProvider = ({ children, client }) => { 20 | useEffect(() => { 21 | const cache = client.getQueryCache(); 22 | 23 | const onFocus = () => { 24 | const isFocused = document.visibilityState !== "hidden"; 25 | 26 | if (isFocused) { 27 | cache.onFocus(); 28 | } 29 | }; 30 | 31 | window.addEventListener("visibilitychange", onFocus, false); 32 | window.addEventListener("focus", onFocus, false); 33 | 34 | return () => { 35 | window.removeEventListener("visibilitychange", onFocus, false); 36 | window.removeEventListener("focus", onFocus, false); 37 | }; 38 | }, [client]); 39 | 40 | return {children}; 41 | }; 42 | -------------------------------------------------------------------------------- /tanstack-query-lite/react/ReactQueryDevtools.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from "react"; 2 | import { useQueryClient } from "./QueryClientProvider"; 3 | 4 | const ReactQueryDevtools = () => { 5 | const queryClient = useQueryClient(); 6 | 7 | const [, rerender] = useReducer((i) => i + 1, 0); 8 | 9 | useEffect(() => { 10 | return queryClient.cache.subscribe(rerender); 11 | }, [queryClient]); 12 | 13 | const queries = queryClient.getQueryCache().getAll(); 14 | const sortedQueries = [...queries].sort((a, b) => (a.queryHash > b.queryHash ? 1 : -1)); 15 | 16 | return ( 17 |
18 | {sortedQueries.map((query) => { 19 | const { queryKey, queryHash, state, observers, options } = query; 20 | const { isFetching, status } = state; 21 | 22 | const { staleTime, gcTime } = options; 23 | 24 | return ( 25 |
26 | {JSON.stringify(queryKey, null, 2)}, {JSON.stringify({ staleTime, gcTime }, null, 2)} -{" "} 27 | 28 | {(() => { 29 | if (isFetching) { 30 | return fetching; 31 | } 32 | 33 | if (!observers.length) { 34 | return inactive; 35 | } 36 | 37 | if (status === "success") { 38 | return success; 39 | } 40 | 41 | if (status === "error") { 42 | return error; 43 | } 44 | 45 | return null; 46 | })()} 47 | 48 |
49 | ); 50 | })} 51 |
52 | ); 53 | }; 54 | 55 | export default ReactQueryDevtools; 56 | -------------------------------------------------------------------------------- /tanstack-query-lite/react/useQuery.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useState, useSyncExternalStore } from "react"; 2 | import QueryObserver from "../core/QueryObserver"; 3 | import { useQueryClient } from "./QueryClientProvider"; 4 | 5 | const useBaseQuery = (options, Observer, queryClient) => { 6 | const client = useQueryClient(queryClient); 7 | 8 | const [observer] = useState(() => { 9 | const defaultOptions = client.defaultQueryOptions(options); 10 | return new Observer(client, defaultOptions); 11 | }); 12 | 13 | const subscribe = useCallback( 14 | (onStoreChange) => { 15 | const unsubscribe = observer.subscribe(onStoreChange); 16 | return unsubscribe; 17 | }, 18 | [observer] 19 | ); 20 | 21 | const getSnapshot = useCallback(() => { 22 | return observer.getResult(); 23 | }, [observer]); 24 | 25 | useSyncExternalStore(subscribe, getSnapshot); 26 | 27 | return observer.getResult(); 28 | }; 29 | 30 | const useQuery = (options, queryClient) => { 31 | return useBaseQuery(options, QueryObserver, queryClient); 32 | }; 33 | 34 | export default useQuery; 35 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "baseUrl": ".", 5 | "paths": { 6 | "~/*": ["src/*"], 7 | "tanstack-query-lite/*": ["tanstack-query-lite/*"] 8 | }, 9 | "target": "ES2020", 10 | "useDefineForClassFields": true, 11 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 12 | "module": "ESNext", 13 | "skipLibCheck": true, 14 | 15 | /* Bundler mode */ 16 | "moduleResolution": "Bundler", 17 | "allowImportingTsExtensions": true, 18 | "isolatedModules": true, 19 | "moduleDetection": "force", 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | 23 | /* Linting */ 24 | "strict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noFallthroughCasesInSwitch": true, 28 | "noUncheckedSideEffectImports": true, 29 | "allowJs": true 30 | }, 31 | "include": ["src", "tanstack-query-lite"] 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react"; 4 | 5 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; 6 | 7 | export default defineConfig({ 8 | plugins: [TanStackRouterVite(), react()], 9 | resolve: { 10 | alias: [ 11 | { find: "~", replacement: path.resolve(__dirname, "src/") }, 12 | { find: "tanstack-query-lite", replacement: path.resolve(__dirname, "tanstack-query-lite/") }, 13 | ], 14 | }, 15 | }); 16 | --------------------------------------------------------------------------------