├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json ├── mockServiceWorker.js └── robots.txt ├── src ├── api │ └── openlibrary.ts ├── books │ ├── book-detail-item.tsx │ ├── book-search-item.tsx │ ├── cover-image.tsx │ ├── header.tsx │ ├── pagination.tsx │ ├── search-form.tsx │ └── search-states.tsx ├── main.tsx ├── routeTree.gen.ts ├── routes │ ├── __root.tsx │ └── index.tsx ├── server │ └── handlers.ts └── styles.css ├── tsconfig.json └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | .idea -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | src/routes/routeTree.gen.ts 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "semi": false, 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/routeTree.gen.ts": true 4 | }, 5 | "search.exclude": { 6 | "**/routeTree.gen.ts": true 7 | }, 8 | "files.readonlyInclude": { 9 | "**/routeTree.gen.ts": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Dominik Dorfmeister 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Query - Beyond the Basics 2 | A workshop by TkDodo 3 | 4 | In this workshop, we'll go beyond the fundamentals and explore some of the more powerful features React Query has to offer. You'll gain a deeper understanding of how it works under the hood and learn how to write scalable, maintainable React Query code. 5 | 6 | Together, we'll build a simple example app and incrementally enhance it with one core objective in mind: delivering the best possible user experience. That means snappy interactions, minimal layout shifts, and avoiding unnecessary loading spinners wherever we can. 7 | 8 | To achieve this, we'll dive into advanced techniques like various forms of prefetching (including integration with route loaders), seeding the query cache, crafting smooth paginated experiences, and even persisting query state through full page reloads using persistence plugins. 9 | 10 | Note: You should have prior knowledge about React Query if you attend this workshop. 11 | 12 | # Getting Started 13 | 14 | To run this application: 15 | 16 | ```bash 17 | npm install 18 | npm run dev 19 | ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tseslint, { configs } from 'typescript-eslint' 3 | // eslint-disable-next-line import-x/default 4 | import reactHooks from 'eslint-plugin-react-hooks' 5 | import eslintReact from '@eslint-react/eslint-plugin' 6 | import pluginQuery from '@tanstack/eslint-plugin-query' 7 | import pluginRouter from '@tanstack/eslint-plugin-router' 8 | import * as pluginImportX from 'eslint-plugin-import-x' 9 | // eslint-disable-next-line import-x/default 10 | import tsParser from '@typescript-eslint/parser' 11 | 12 | export default tseslint.config( 13 | eslint.configs.recommended, 14 | configs.strictTypeChecked, 15 | pluginQuery.configs['flat/recommended'], 16 | pluginRouter.configs['flat/recommended'], 17 | eslintReact.configs['recommended-type-checked'], 18 | reactHooks.configs['recommended-latest'], 19 | pluginImportX.flatConfigs.recommended, 20 | pluginImportX.flatConfigs.typescript, 21 | { 22 | ignores: ['eslint.config.js'], 23 | languageOptions: { 24 | ecmaVersion: 'latest', 25 | sourceType: 'module', 26 | parser: tsParser, 27 | parserOptions: { 28 | projectService: true, 29 | tsconfigRootDir: import.meta.dirname, 30 | }, 31 | }, 32 | rules: { 33 | '@typescript-eslint/no-misused-promises': [ 34 | 'error', 35 | { 36 | checksVoidReturn: false, 37 | }, 38 | ], 39 | '@typescript-eslint/no-unused-vars': [ 40 | 'error', 41 | { 42 | args: 'all', 43 | argsIgnorePattern: '^_', 44 | caughtErrors: 'all', 45 | caughtErrorsIgnorePattern: '^_', 46 | destructuredArrayIgnorePattern: '^_', 47 | varsIgnorePattern: '^_', 48 | ignoreRestSiblings: true, 49 | }, 50 | ], 51 | '@typescript-eslint/restrict-template-expressions': [ 52 | 'error', 53 | { allowNumber: true }, 54 | ], 55 | }, 56 | }, 57 | { 58 | files: ['**/*.js'], 59 | extends: [configs.disableTypeChecked], 60 | }, 61 | ) 62 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | OpenLibrary Explorer 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-query-beyond-the-basics", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite --port 3000", 7 | "start": "vite --port 3000", 8 | "build": "vite build && tsc", 9 | "serve": "vite preview", 10 | "format": "prettier --write .", 11 | "lint": "eslint . --ext .ts,.tsx", 12 | "lint:fix": "eslint . --ext .ts,.tsx --fix" 13 | }, 14 | "dependencies": { 15 | "@tailwindcss/vite": "^4.0.6", 16 | "@tanstack/query-async-storage-persister": "^5.75.4", 17 | "@tanstack/react-query": "^5.75.4", 18 | "@tanstack/react-query-devtools": "^5.75.4", 19 | "@tanstack/react-query-persist-client": "^5.75.4", 20 | "@tanstack/react-router": "^1.120.2", 21 | "@tanstack/react-router-devtools": "^1.120.2", 22 | "@tanstack/router-plugin": "^1.120.2", 23 | "arktype": "^2.1.20", 24 | "idb-keyval": "^6.2.1", 25 | "ky": "^1.8.1", 26 | "lucide-react": "^0.488.0", 27 | "msw": "^2.7.4", 28 | "react": "^19.1.0", 29 | "react-dom": "^19.1.0", 30 | "react-markdown": "^10.1.0", 31 | "remark-gfm": "^4.0.1", 32 | "tailwindcss": "^4.1.3" 33 | }, 34 | "devDependencies": { 35 | "@eslint-react/eslint-plugin": "^1.47.2", 36 | "@eslint/js": "^9.24.0", 37 | "@tanstack/eslint-plugin-query": "^5.74.7", 38 | "@tanstack/eslint-plugin-router": "^1.115.0", 39 | "@types/react": "^19.0.8", 40 | "@types/react-dom": "^19.0.3", 41 | "@vitejs/plugin-react": "^4.3.4", 42 | "eslint": "^9.24.0", 43 | "eslint-import-resolver-typescript": "^4.3.2", 44 | "eslint-plugin-import-x": "^4.10.3", 45 | "eslint-plugin-react-hooks": "^5.2.0", 46 | "prettier": "^3.5.3", 47 | "prettier-plugin-tailwindcss": "^0.6.11", 48 | "typescript": "^5.8.3", 49 | "typescript-eslint": "^8.29.1", 50 | "vite": "^6.1.0" 51 | }, 52 | "msw": { 53 | "workerDirectory": [ 54 | "public" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TkDodo/react-query-beyond-the-basics/8bd2e3efef9c75be4678b5672423fd6ff68f2e36/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TkDodo/react-query-beyond-the-basics/8bd2e3efef9c75be4678b5672423fd6ff68f2e36/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TkDodo/react-query-beyond-the-basics/8bd2e3efef9c75be4678b5672423fd6ff68f2e36/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "TanStack App", 3 | "name": "Create TanStack App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker. 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const PACKAGE_VERSION = '2.7.4' 12 | const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' 13 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') 14 | const activeClientIds = new Set() 15 | 16 | self.addEventListener('install', function () { 17 | self.skipWaiting() 18 | }) 19 | 20 | self.addEventListener('activate', function (event) { 21 | event.waitUntil(self.clients.claim()) 22 | }) 23 | 24 | self.addEventListener('message', async function (event) { 25 | const clientId = event.source.id 26 | 27 | if (!clientId || !self.clients) { 28 | return 29 | } 30 | 31 | const client = await self.clients.get(clientId) 32 | 33 | if (!client) { 34 | return 35 | } 36 | 37 | const allClients = await self.clients.matchAll({ 38 | type: 'window', 39 | }) 40 | 41 | switch (event.data) { 42 | case 'KEEPALIVE_REQUEST': { 43 | sendToClient(client, { 44 | type: 'KEEPALIVE_RESPONSE', 45 | }) 46 | break 47 | } 48 | 49 | case 'INTEGRITY_CHECK_REQUEST': { 50 | sendToClient(client, { 51 | type: 'INTEGRITY_CHECK_RESPONSE', 52 | payload: { 53 | packageVersion: PACKAGE_VERSION, 54 | checksum: INTEGRITY_CHECKSUM, 55 | }, 56 | }) 57 | break 58 | } 59 | 60 | case 'MOCK_ACTIVATE': { 61 | activeClientIds.add(clientId) 62 | 63 | sendToClient(client, { 64 | type: 'MOCKING_ENABLED', 65 | payload: { 66 | client: { 67 | id: client.id, 68 | frameType: client.frameType, 69 | }, 70 | }, 71 | }) 72 | break 73 | } 74 | 75 | case 'MOCK_DEACTIVATE': { 76 | activeClientIds.delete(clientId) 77 | break 78 | } 79 | 80 | case 'CLIENT_CLOSED': { 81 | activeClientIds.delete(clientId) 82 | 83 | const remainingClients = allClients.filter((client) => { 84 | return client.id !== clientId 85 | }) 86 | 87 | // Unregister itself when there are no more clients 88 | if (remainingClients.length === 0) { 89 | self.registration.unregister() 90 | } 91 | 92 | break 93 | } 94 | } 95 | }) 96 | 97 | self.addEventListener('fetch', function (event) { 98 | const { request } = event 99 | 100 | // Bypass navigation requests. 101 | if (request.mode === 'navigate') { 102 | return 103 | } 104 | 105 | // Opening the DevTools triggers the "only-if-cached" request 106 | // that cannot be handled by the worker. Bypass such requests. 107 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 108 | return 109 | } 110 | 111 | // Bypass all requests when there are no active clients. 112 | // Prevents the self-unregistered worked from handling requests 113 | // after it's been deleted (still remains active until the next reload). 114 | if (activeClientIds.size === 0) { 115 | return 116 | } 117 | 118 | // Generate unique request ID. 119 | const requestId = crypto.randomUUID() 120 | event.respondWith(handleRequest(event, requestId)) 121 | }) 122 | 123 | async function handleRequest(event, requestId) { 124 | const client = await resolveMainClient(event) 125 | const response = await getResponse(event, client, requestId) 126 | 127 | // Send back the response clone for the "response:*" life-cycle events. 128 | // Ensure MSW is active and ready to handle the message, otherwise 129 | // this message will pend indefinitely. 130 | if (client && activeClientIds.has(client.id)) { 131 | ;(async function () { 132 | const responseClone = response.clone() 133 | 134 | sendToClient( 135 | client, 136 | { 137 | type: 'RESPONSE', 138 | payload: { 139 | requestId, 140 | isMockedResponse: IS_MOCKED_RESPONSE in response, 141 | type: responseClone.type, 142 | status: responseClone.status, 143 | statusText: responseClone.statusText, 144 | body: responseClone.body, 145 | headers: Object.fromEntries(responseClone.headers.entries()), 146 | }, 147 | }, 148 | [responseClone.body], 149 | ) 150 | })() 151 | } 152 | 153 | return response 154 | } 155 | 156 | // Resolve the main client for the given event. 157 | // Client that issues a request doesn't necessarily equal the client 158 | // that registered the worker. It's with the latter the worker should 159 | // communicate with during the response resolving phase. 160 | async function resolveMainClient(event) { 161 | const client = await self.clients.get(event.clientId) 162 | 163 | if (activeClientIds.has(event.clientId)) { 164 | return client 165 | } 166 | 167 | if (client?.frameType === 'top-level') { 168 | return client 169 | } 170 | 171 | const allClients = await self.clients.matchAll({ 172 | type: 'window', 173 | }) 174 | 175 | return allClients 176 | .filter((client) => { 177 | // Get only those clients that are currently visible. 178 | return client.visibilityState === 'visible' 179 | }) 180 | .find((client) => { 181 | // Find the client ID that's recorded in the 182 | // set of clients that have registered the worker. 183 | return activeClientIds.has(client.id) 184 | }) 185 | } 186 | 187 | async function getResponse(event, client, requestId) { 188 | const { request } = event 189 | 190 | // Clone the request because it might've been already used 191 | // (i.e. its body has been read and sent to the client). 192 | const requestClone = request.clone() 193 | 194 | function passthrough() { 195 | // Cast the request headers to a new Headers instance 196 | // so the headers can be manipulated with. 197 | const headers = new Headers(requestClone.headers) 198 | 199 | // Remove the "accept" header value that marked this request as passthrough. 200 | // This prevents request alteration and also keeps it compliant with the 201 | // user-defined CORS policies. 202 | const acceptHeader = headers.get('accept') 203 | if (acceptHeader) { 204 | const values = acceptHeader.split(',').map((value) => value.trim()) 205 | const filteredValues = values.filter( 206 | (value) => value !== 'msw/passthrough', 207 | ) 208 | 209 | if (filteredValues.length > 0) { 210 | headers.set('accept', filteredValues.join(', ')) 211 | } else { 212 | headers.delete('accept') 213 | } 214 | } 215 | 216 | return fetch(requestClone, { headers }) 217 | } 218 | 219 | // Bypass mocking when the client is not active. 220 | if (!client) { 221 | return passthrough() 222 | } 223 | 224 | // Bypass initial page load requests (i.e. static assets). 225 | // The absence of the immediate/parent client in the map of the active clients 226 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 227 | // and is not ready to handle requests. 228 | if (!activeClientIds.has(client.id)) { 229 | return passthrough() 230 | } 231 | 232 | // Notify the client that a request has been intercepted. 233 | const requestBuffer = await request.arrayBuffer() 234 | const clientMessage = await sendToClient( 235 | client, 236 | { 237 | type: 'REQUEST', 238 | payload: { 239 | id: requestId, 240 | url: request.url, 241 | mode: request.mode, 242 | method: request.method, 243 | headers: Object.fromEntries(request.headers.entries()), 244 | cache: request.cache, 245 | credentials: request.credentials, 246 | destination: request.destination, 247 | integrity: request.integrity, 248 | redirect: request.redirect, 249 | referrer: request.referrer, 250 | referrerPolicy: request.referrerPolicy, 251 | body: requestBuffer, 252 | keepalive: request.keepalive, 253 | }, 254 | }, 255 | [requestBuffer], 256 | ) 257 | 258 | switch (clientMessage.type) { 259 | case 'MOCK_RESPONSE': { 260 | return respondWithMock(clientMessage.data) 261 | } 262 | 263 | case 'PASSTHROUGH': { 264 | return passthrough() 265 | } 266 | } 267 | 268 | return passthrough() 269 | } 270 | 271 | function sendToClient(client, message, transferrables = []) { 272 | return new Promise((resolve, reject) => { 273 | const channel = new MessageChannel() 274 | 275 | channel.port1.onmessage = (event) => { 276 | if (event.data && event.data.error) { 277 | return reject(event.data.error) 278 | } 279 | 280 | resolve(event.data) 281 | } 282 | 283 | client.postMessage( 284 | message, 285 | [channel.port2].concat(transferrables.filter(Boolean)), 286 | ) 287 | }) 288 | } 289 | 290 | async function respondWithMock(response) { 291 | // Setting response status code to 0 is a no-op. 292 | // However, when responding with a "Response.error()", the produced Response 293 | // instance will have status code set to 0. Since it's not possible to create 294 | // a Response instance with status code 0, handle that use-case separately. 295 | if (response.status === 0) { 296 | return Response.error() 297 | } 298 | 299 | const mockedResponse = new Response(response.body, response) 300 | 301 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { 302 | value: true, 303 | enumerable: true, 304 | }) 305 | 306 | return mockedResponse 307 | } 308 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/api/openlibrary.ts: -------------------------------------------------------------------------------- 1 | import ky from 'ky' 2 | 3 | export const limit = 6 4 | 5 | export type BookSearchItem = Awaited< 6 | ReturnType 7 | >['docs'][number] 8 | 9 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 10 | 11 | export async function getBooks({ 12 | filter, 13 | page, 14 | }: { 15 | filter: string 16 | page: number 17 | }) { 18 | const params = new URLSearchParams({ 19 | q: filter, 20 | page: String(page), 21 | limit: String(limit), 22 | has_fulltext: 'true', 23 | fields: 'key,title,author_name,author_key,first_publish_year,cover_i', 24 | }) 25 | const { q, ...response } = await ky 26 | .get(`https://openlibrary.org/search.json?${params.toString()}`) 27 | .json<{ 28 | numFound: number 29 | q: string 30 | docs: Array<{ 31 | key: string 32 | title: string 33 | author_name?: Array 34 | author_key?: Array 35 | coverId: string 36 | first_publish_year: number 37 | cover_i: number 38 | }> 39 | }>() 40 | 41 | await sleep(500) 42 | 43 | return { 44 | ...response, 45 | filter: q, 46 | docs: response.docs.map((doc) => ({ 47 | id: doc.key, 48 | coverId: doc.cover_i, 49 | authorName: doc.author_name?.[0], 50 | authorId: doc.author_key?.[0] 51 | ? `/authors/${doc.author_key[0]}` 52 | : undefined, 53 | title: doc.title, 54 | publishYear: doc.first_publish_year, 55 | })), 56 | } 57 | } 58 | 59 | export type BookDetailItem = Awaited> 60 | 61 | export async function getBook(id: string) { 62 | const response = await ky.get(`https://openlibrary.org${id}.json`).json<{ 63 | title: string 64 | description?: string | { value: string } 65 | covers?: Array 66 | links?: Array<{ title: string; url: string }> 67 | authors?: Array<{ author: { key: string } }> 68 | }>() 69 | 70 | await sleep(250) 71 | 72 | const description = 73 | (typeof response.description === 'string' 74 | ? response.description 75 | : response.description?.value) ?? 'No description available' 76 | 77 | return { 78 | title: response.title, 79 | ...(description 80 | ? { description: description.replaceAll(String.raw`\n`, '\n') } 81 | : undefined), 82 | covers: response.covers?.filter((cover) => cover > 0) ?? [], 83 | ...(response.links ? { links: response.links } : undefined), 84 | authorId: response.authors?.[0]?.author.key, 85 | } 86 | } 87 | 88 | export type Author = Awaited> 89 | 90 | export async function getAuthor(id: string) { 91 | const response = await ky.get(`https://openlibrary.org${id}.json`).json<{ 92 | personal_name: string 93 | links?: Array<{ url: string }> 94 | }>() 95 | 96 | await sleep(1000) 97 | 98 | const link = response.links?.map((link) => ({ 99 | url: link.url, 100 | }))[0]?.url 101 | 102 | return { 103 | name: response.personal_name, 104 | ...(link ? { link } : undefined), 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/books/book-detail-item.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from 'react-markdown' 2 | import remarkGfm from 'remark-gfm' 3 | import { CoverImage } from '@/books/cover-image' 4 | import type { Author, BookDetailItem } from '@/api/openlibrary' 5 | 6 | type Props = Omit & { 7 | onBack: () => void 8 | author?: Author 9 | } 10 | 11 | export function BookDetailItem({ 12 | title, 13 | author, 14 | description, 15 | links, 16 | covers, 17 | onBack, 18 | }: Props) { 19 | const authorName = author?.name ?? '...' 20 | 21 | return ( 22 |
23 | { 26 | e.preventDefault() 27 | onBack() 28 | }} 29 | className="mb-4 inline-block rounded text-sm text-indigo-400 hover:underline focus:ring-2 focus:ring-indigo-400 focus:outline-none" 30 | > 31 | ← Back to search 32 | 33 |
34 |

{title}

35 | 36 |

37 | {author?.link ? ( 38 | 44 | {authorName} 45 | 46 | ) : ( 47 | authorName 48 | )} 49 |

50 | 51 | {covers.length > 0 && ( 52 |
53 | {covers.slice(0, 5).map((cover) => ( 54 | 55 | ))} 56 |
57 | )} 58 | 59 | {description ? ( 60 |
61 | 62 | {description} 63 | 64 |
65 | ) : ( 66 |
67 |
68 |
69 |
70 |
71 | )} 72 |
73 | 74 | {links && links.length > 0 && ( 75 |
76 |

77 | Related Links 78 |

79 | 93 |
94 | )} 95 |
96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /src/books/book-search-item.tsx: -------------------------------------------------------------------------------- 1 | import { CoverImage } from '@/books/cover-image' 2 | import type { BookSearchItem } from '@/api/openlibrary' 3 | 4 | type Props = BookSearchItem & 5 | Omit, 'href' | 'onClick' | 'className'> & { 6 | onClick: (id: string) => void 7 | } 8 | 9 | export function BookSearchItem({ 10 | id, 11 | coverId, 12 | authorId, 13 | authorName, 14 | title, 15 | publishYear, 16 | onClick, 17 | ...props 18 | }: Props) { 19 | return ( 20 | { 23 | event.preventDefault() 24 | onClick(id) 25 | }} 26 | className="group block rounded-xl border border-transparent transition-transform duration-200 ease-out hover:-translate-y-1 hover:border-indigo-400 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400 focus:outline-none" 27 | {...props} 28 | > 29 |
30 | 31 |
32 |

33 | {title.length > 100 ? `${title.slice(0, 100)}...` : title} 34 |

35 |

{authorName}

36 |

37 | Published in {publishYear} 38 |

39 |
40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/books/cover-image.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | type Props = { 4 | id: number 5 | title: string 6 | size?: 'L' | 'M' 7 | } 8 | 9 | export function CoverImage({ id, title, size = 'M' }: Props) { 10 | const [isLoaded, setLoaded] = useState(false) 11 | 12 | return ( 13 | <> 14 | {!isLoaded && ( 15 |
16 | )} 17 | {title} { 22 | setLoaded(true) 23 | }} 24 | /> 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/books/header.tsx: -------------------------------------------------------------------------------- 1 | import { LibraryBig } from 'lucide-react' 2 | 3 | type Props = { 4 | children?: React.ReactNode 5 | } 6 | 7 | export function Header({ children }: Props) { 8 | return ( 9 |
10 |
11 |
12 | 13 |
14 |

15 | OpenLibrary Explorer 16 |

17 |
18 | 19 | {children} 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/books/pagination.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeft, ChevronRight } from 'lucide-react' 2 | 3 | export function Pagination({ 4 | page, 5 | setPage, 6 | maxPages, 7 | }: { 8 | page: number 9 | setPage: (page: number) => void 10 | maxPages: number 11 | }) { 12 | return ( 13 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/books/search-form.tsx: -------------------------------------------------------------------------------- 1 | import { Search } from 'lucide-react' 2 | 3 | type Props = { 4 | defaultValue: string | undefined 5 | onSearch: (query: string) => void 6 | } 7 | 8 | export function SearchForm({ defaultValue, onSearch }: Props) { 9 | return ( 10 |
{ 12 | event.preventDefault() 13 | const formData = new FormData(event.currentTarget) 14 | const searchQuery = formData.get('search') as string 15 | onSearch(searchQuery) 16 | }} 17 | className="flex w-full items-center space-x-2 lg:w-auto" 18 | > 19 | 29 | 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/books/search-states.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Search, 3 | SearchCheck, 4 | FileWarning, 5 | Loader2, 6 | AlertTriangle, 7 | } from 'lucide-react' 8 | 9 | const SpotIcon = ({ 10 | icon: Icon, 11 | label, 12 | }: { 13 | icon: typeof Search 14 | label: string 15 | }) => ( 16 |
17 |
18 | 19 |
20 |

{label}

21 |
22 | ) 23 | 24 | export function EmptyState() { 25 | return ( 26 | 30 | ) 31 | } 32 | 33 | export function NoResultsState() { 34 | return ( 35 | 39 | ) 40 | } 41 | 42 | export function PendingState() { 43 | return ( 44 |
45 | 46 |
47 | ) 48 | } 49 | 50 | export function ErrorState({ error }: { error: Error }) { 51 | return ( 52 |
53 | 54 |

{error.message}

55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { RouterProvider, createRouter } from '@tanstack/react-router' 4 | 5 | // Import the generated route tree 6 | import { routeTree } from './routeTree.gen' 7 | 8 | import './styles.css' 9 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 10 | 11 | const queryClient = new QueryClient() 12 | 13 | // Create a new router instance 14 | const router = createRouter({ 15 | routeTree, 16 | context: { 17 | queryClient, 18 | }, 19 | defaultPreload: 'intent', 20 | scrollRestoration: true, 21 | defaultStructuralSharing: true, 22 | defaultPreloadStaleTime: 0, 23 | defaultGcTime: 0, 24 | defaultPendingMinMs: 0, 25 | defaultPendingMs: 100, 26 | }) 27 | 28 | // Register the router instance for type safety 29 | declare module '@tanstack/react-router' { 30 | interface Register { 31 | router: typeof router 32 | } 33 | } 34 | 35 | // Render the app 36 | const rootElement = document.querySelector('#app') 37 | if (rootElement && !rootElement.innerHTML) { 38 | const root = createRoot(rootElement) 39 | await (await import('@/server/handlers')).worker.start() 40 | root.render( 41 | 42 | 43 | 44 | 45 | , 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /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 | 16 | // Create/Update Routes 17 | 18 | const IndexRoute = IndexImport.update({ 19 | id: '/', 20 | path: '/', 21 | getParentRoute: () => rootRoute, 22 | } as any) 23 | 24 | // Populate the FileRoutesByPath interface 25 | 26 | declare module '@tanstack/react-router' { 27 | interface FileRoutesByPath { 28 | '/': { 29 | id: '/' 30 | path: '/' 31 | fullPath: '/' 32 | preLoaderRoute: typeof IndexImport 33 | parentRoute: typeof rootRoute 34 | } 35 | } 36 | } 37 | 38 | // Create and export the route tree 39 | 40 | export interface FileRoutesByFullPath { 41 | '/': typeof IndexRoute 42 | } 43 | 44 | export interface FileRoutesByTo { 45 | '/': typeof IndexRoute 46 | } 47 | 48 | export interface FileRoutesById { 49 | __root__: typeof rootRoute 50 | '/': typeof IndexRoute 51 | } 52 | 53 | export interface FileRouteTypes { 54 | fileRoutesByFullPath: FileRoutesByFullPath 55 | fullPaths: '/' 56 | fileRoutesByTo: FileRoutesByTo 57 | to: '/' 58 | id: '__root__' | '/' 59 | fileRoutesById: FileRoutesById 60 | } 61 | 62 | export interface RootRouteChildren { 63 | IndexRoute: typeof IndexRoute 64 | } 65 | 66 | const rootRouteChildren: RootRouteChildren = { 67 | IndexRoute: IndexRoute, 68 | } 69 | 70 | export const routeTree = rootRoute 71 | ._addFileChildren(rootRouteChildren) 72 | ._addFileTypes() 73 | 74 | /* ROUTE_MANIFEST_START 75 | { 76 | "routes": { 77 | "__root__": { 78 | "filePath": "__root.tsx", 79 | "children": [ 80 | "/" 81 | ] 82 | }, 83 | "/": { 84 | "filePath": "index.tsx" 85 | } 86 | } 87 | } 88 | ROUTE_MANIFEST_END */ 89 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' 2 | import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' 3 | import { QueryClient } from '@tanstack/react-query' 4 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 5 | 6 | interface RouterContext { 7 | queryClient: QueryClient 8 | } 9 | 10 | export const Route = createRootRouteWithContext()({ 11 | component: function Root() { 12 | return ( 13 |
14 | 15 | 16 | 17 |
18 | ) 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | import { useState } from 'react' 3 | import { SearchForm } from '@/books/search-form' 4 | import { Header } from '@/books/header' 5 | import { BookSearchItem } from '@/books/book-search-item' 6 | import { BookDetailItem } from '@/books/book-detail-item' 7 | import { Pagination } from '@/books/pagination' 8 | import { limit } from '@/api/openlibrary' 9 | 10 | export const Route = createFileRoute('/')({ 11 | component: App, 12 | }) 13 | 14 | function App() { 15 | const [filter, setFilter] = useState('') 16 | const [page, setPage] = useState(1) 17 | const [id, setId] = useState() 18 | 19 | if (id) { 20 | return ( 21 |
22 |
23 | 24 |
25 | ) 26 | } 27 | 28 | return ( 29 |
30 |
31 | { 33 | if (filter !== newFilter) { 34 | setFilter(newFilter) 35 | setPage(1) 36 | } 37 | }} 38 | defaultValue={filter} 39 | /> 40 |
41 | 47 |
48 | ) 49 | } 50 | 51 | function BookSearchOverview({ 52 | page, 53 | setPage, 54 | setId, 55 | }: { 56 | filter: string 57 | setId: (id: string) => void 58 | page: number 59 | setPage: (page: number) => void 60 | }) { 61 | const query = { 62 | data: { 63 | numFound: 13_629, 64 | docs: [ 65 | { 66 | id: '0', 67 | coverId: 240_727, 68 | authorName: 'J.K. Rowling', 69 | authorId: '/authors/OL73937A', 70 | title: "Harry Potter and the Philosopher's Stone", 71 | publishYear: 1997, 72 | }, 73 | { 74 | id: '1', 75 | coverId: 8_226_196, 76 | authorName: 'J.R.R. Tolkien', 77 | authorId: '/authors/OL26284A', 78 | title: 'The Hobbit', 79 | publishYear: 1937, 80 | }, 81 | { 82 | id: '2', 83 | coverId: 10_523_361, 84 | authorName: 'George Orwell', 85 | authorId: '/authors/OL26320A', 86 | title: '1984', 87 | publishYear: 1949, 88 | }, 89 | { 90 | id: '3', 91 | coverId: 11_169_123, 92 | authorName: 'F. Scott Fitzgerald', 93 | authorId: '/authors/OL26283A', 94 | title: 'The Great Gatsby', 95 | publishYear: 1925, 96 | }, 97 | { 98 | id: '4', 99 | coverId: 10_958_358, 100 | authorName: 'Mary Shelley', 101 | authorId: '/authors/OL26282A', 102 | title: 'Frankenstein', 103 | publishYear: 1818, 104 | }, 105 | { 106 | id: '5', 107 | coverId: 10_909_258, 108 | authorName: 'Charlotte Brontë', 109 | authorId: '/authors/OL26281A', 110 | title: 'Jane Eyre', 111 | publishYear: 1847, 112 | }, 113 | ], 114 | }, 115 | } 116 | 117 | return ( 118 |
119 |
120 | {query.data.numFound} records found 121 |
122 | 123 |
124 | {query.data.docs.map((book) => ( 125 | 126 | ))} 127 |
128 | 129 | 134 |
135 | ) 136 | } 137 | 138 | const mockDescription = `***A Game of Thrones*** is the inaugural novel in ***A Song of Ice and Fire***, an epic 139 | series of fantasy novels crafted by the American author **George R. R. Martin**. Published on August 1, 1996, this 140 | novel introduces readers to the richly detailed world of Westeros and Essos, where political intrigue, power 141 | struggles, and magical elements intertwine.` 142 | 143 | function BookDetail({ 144 | setId, 145 | }: { 146 | id: string 147 | setId: (id: string | undefined) => void 148 | }) { 149 | return ( 150 |
151 | { 179 | setId(undefined) 180 | }} 181 | /> 182 |
183 | ) 184 | } 185 | -------------------------------------------------------------------------------- /src/server/handlers.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse, bypass, delay } from 'msw' 2 | import type { JsonBodyType } from 'msw' 3 | 4 | import { get, set, createStore } from 'idb-keyval' 5 | import { setupWorker } from 'msw/browser' 6 | 7 | const openLibraryStore = createStore('open-library-store', 'open-library') 8 | 9 | const openLibraryCache = { 10 | async get(key: string) { 11 | return await get(key, openLibraryStore) 12 | }, 13 | async set(key: string, value: JsonBodyType) { 14 | await set(key, value, openLibraryStore) 15 | }, 16 | } 17 | 18 | const handlers = [ 19 | http.get('https://openlibrary.org/*', async ({ request }) => { 20 | const cacheKey = request.url.toString() 21 | 22 | const cachedResponse = await openLibraryCache 23 | .get(cacheKey) 24 | .catch(() => null) 25 | if (cachedResponse) { 26 | await delay() 27 | return HttpResponse.json(cachedResponse, { 28 | headers: { 'x-msw-cache': 'true' }, 29 | }) 30 | } 31 | 32 | try { 33 | const response = await fetch(bypass(request)) 34 | if (!response.ok) { 35 | return HttpResponse.error() 36 | } 37 | 38 | const data = (await response.json()) as JsonBodyType 39 | void openLibraryCache.set(cacheKey, data) 40 | 41 | return HttpResponse.json(data) 42 | } catch { 43 | return HttpResponse.error() 44 | } 45 | }), 46 | ] 47 | 48 | export const worker = setupWorker(...handlers) 49 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | body { 4 | @apply m-0; 5 | font-family: 6 | -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 7 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | code { 13 | font-family: 14 | source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "vite.config.js"], 3 | "compilerOptions": { 4 | "target": "ES2022", 5 | "jsx": "react-jsx", 6 | "module": "ESNext", 7 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 8 | "types": ["vite/client"], 9 | "allowJs": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noUncheckedIndexedAccess": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": ["./src/*"] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import viteReact from '@vitejs/plugin-react' 3 | import tailwindcss from '@tailwindcss/vite' 4 | 5 | import { TanStackRouterVite } from '@tanstack/router-plugin/vite' 6 | import path from 'node:path' 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [ 11 | TanStackRouterVite({ autoCodeSplitting: true }), 12 | viteReact(), 13 | tailwindcss(), 14 | ], 15 | test: { 16 | globals: true, 17 | environment: 'jsdom', 18 | }, 19 | 20 | resolve: { 21 | alias: { 22 | '@': path.resolve(import.meta.dirname, './src'), 23 | }, 24 | }, 25 | }) 26 | --------------------------------------------------------------------------------