├── .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 |
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 | {
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 |
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 |
--------------------------------------------------------------------------------