snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
{
"extends": ["plugin:react/jsx-runtime", "plugin:react-hooks/recommended"]
}

27
examples/react/react-router/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
pnpm-lock.yaml
yarn.lock
package-lock.json
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,6 @@
# React Router Example
To run this example:
- `npm install`
- `npm run dev`

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/emblem-light.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>TanStack Query React Router Example App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,30 @@
{
"name": "@tanstack/query-example-react-router",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-query-devtools": "^5.102.8",
"localforage": "^1.10.0",
"match-sorter": "^6.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^6.25.1",
"react-router-dom": "^6.25.1",
"rooks": "^8.0.0",
"sort-by": "^1.2.0"
},
"devDependencies": {
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/sort-by": "^1.2.3",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "5.8.3",
"vite": "^6.4.1"
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="190px" height="190px" viewBox="0 0 190 190" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 60.1 (88133) - https://sketch.com -->
<title>emblem-light</title>
<desc>Created with Sketch.</desc>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g>
<path d="M39.7239712,61.3436237 C36.631224,46.362877 35.9675112,34.8727722 37.9666331,26.5293551 C39.1555965,21.5671678 41.3293088,17.5190846 44.6346064,14.5984631 C48.1241394,11.5150478 52.5360327,10.0020122 57.493257,10.0020122 C65.6712013,10.0020122 74.2682602,13.7273214 83.4557246,20.8044264 C87.2031203,23.6910458 91.0924366,27.170411 95.1316515,31.2444746 C95.4531404,30.8310265 95.8165416,30.4410453 96.2214301,30.0806152 C107.64098,19.9149716 117.255245,13.5989272 125.478408,11.1636507 C130.367899,9.715636 134.958526,9.57768202 139.138936,10.983031 C143.551631,12.4664684 147.06766,15.5329489 149.548314,19.8281091 C153.642288,26.9166735 154.721918,36.2310983 153.195595,47.7320243 C152.573451,52.4199112 151.50985,57.5263831 150.007094,63.0593153 C150.574045,63.1277086 151.142416,63.2532808 151.705041,63.4395297 C166.193932,68.2358678 176.453582,73.3937462 182.665021,79.2882839 C186.360669,82.7953831 188.773972,86.6998434 189.646365,91.0218204 C190.567176,95.5836746 189.669313,100.159332 187.191548,104.451297 C183.105211,111.529614 175.591643,117.11221 164.887587,121.534031 C160.589552,123.309539 155.726579,124.917559 150.293259,126.363748 C150.541176,126.92292 150.733521,127.516759 150.862138,128.139758 C153.954886,143.120505 154.618598,154.61061 152.619477,162.954027 C151.430513,167.916214 149.256801,171.964297 145.951503,174.884919 C142.46197,177.968334 138.050077,179.48137 133.092853,179.48137 C124.914908,179.48137 116.31785,175.756061 107.130385,168.678956 C103.343104,165.761613 99.4108655,162.238839 95.3254337,158.108619 C94.9050753,158.765474 94.3889681,159.376011 93.7785699,159.919385 C82.3590198,170.085028 72.744755,176.401073 64.5215915,178.836349 C59.6321009,180.284364 55.0414736,180.422318 50.8610636,179.016969 C46.4483686,177.533532 42.9323404,174.467051 40.4516862,170.171891 C36.3577116,163.083327 35.2780823,153.768902 36.8044053,142.267976 C37.449038,137.410634 38.56762,132.103898 40.1575891,126.339009 C39.5361041,126.276104 38.9120754,126.144816 38.2949591,125.940529 C23.8060684,121.144191 13.5464184,115.986312 7.33497892,110.091775 C3.63933121,106.584675 1.22602752,102.680215 0.353635235,98.3582381 C-0.567176333,93.7963839 0.330686581,89.2207269 2.80845236,84.9287618 C6.89478863,77.8504443 14.4083565,72.2678481 25.1124133,67.8460273 C29.5385143,66.0176154 34.5637208,64.366822 40.1939394,62.8874674 C39.9933393,62.3969171 39.8349374,61.8811235 39.7239712,61.3436237 Z" fill="#002C4B" fill-rule="nonzero" transform="translate(95.000000, 95.000000) scale(-1, 1) translate(-95.000000, -95.000000) "></path>
<path d="M80.3968824,64 L109.608177,64 C111.399254,64 113.053521,64.958025 113.944933,66.5115174 L128.577138,92.0115174 C129.461464,93.5526583 129.461464,95.4473417 128.577138,96.9884826 L113.944933,122.488483 C113.053521,124.041975 111.399254,125 109.608177,125 L80.3968824,125 C78.6058059,125 76.9515387,124.041975 76.0601262,122.488483 L61.4279211,96.9884826 C60.543596,95.4473417 60.543596,93.5526583 61.4279211,92.0115174 L76.0601262,66.5115174 C76.9515387,64.958025 78.6058059,64 80.3968824,64 Z M105.987827,70.2765273 C107.779849,70.2765273 109.434839,71.2355558 110.325899,72.7903404 L121.343038,92.0138131 C122.225607,93.5537825 122.225607,95.4462175 121.343038,96.9861869 L110.325899,116.20966 C109.434839,117.764444 107.779849,118.723473 105.987827,118.723473 L84.0172329,118.723473 C82.2252106,118.723473 80.5702207,117.764444 79.6791602,116.20966 L68.6620219,96.9861869 C67.7794521,95.4462175 67.7794521,93.5537825 68.6620219,92.0138131 L79.6791602,72.7903404 C80.5702207,71.2355558 82.2252106,70.2765273 84.0172329,70.2765273 L105.987827,70.2765273 Z M102.080648,77.1414791 L87.9244113,77.1414791 C86.1342282,77.1414791 84.4806439,78.0985567 83.5888998,79.6508285 L83.5888998,79.6508285 L76.4892166,92.0093494 C75.6032319,93.5515958 75.6032319,95.4484042 76.4892166,96.9906506 L76.4892166,96.9906506 L83.5888998,109.349172 C84.4806439,110.901443 86.1342282,111.858521 87.9244113,111.858521 L87.9244113,111.858521 L102.080648,111.858521 C103.870831,111.858521 105.524416,110.901443 106.41616,109.349172 L106.41616,109.349172 L113.515843,96.9906506 C114.401828,95.4484042 114.401828,93.5515958 113.515843,92.0093494 L113.515843,92.0093494 L106.41616,79.6508285 C105.524416,78.0985567 103.870831,77.1414791 102.080648,77.1414791 L102.080648,77.1414791 Z M98.3191856,83.7122186 C100.108028,83.7122186 101.760587,84.6678753 102.652827,86.2183156 L105.983552,92.0060969 C106.87203,93.5500005 106.87203,95.4499995 105.983552,96.9939031 L102.652827,102.781684 C101.760587,104.332125 100.108028,105.287781 98.3191856,105.287781 L91.685874,105.287781 C89.8970316,105.287781 88.2444725,104.332125 87.3522326,102.781684 L84.021508,96.9939031 C83.1330298,95.4499995 83.1330298,93.5500005 84.021508,92.0060969 L87.3522326,86.2183156 C88.2444725,84.6678753 89.8970316,83.7122186 91.685874,83.7122186 L98.3191856,83.7122186 Z M95.0037937,90.1848875 C93.459294,90.1848875 92.0343817,91.0072828 91.2630046,92.3424437 C90.4917325,93.6774232 90.4917325,95.3225768 91.2630046,96.6575563 C92.0343817,97.9927172 93.459294,98.8151125 95.0012659,98.8151125 L95.0012659,98.8151125 C96.5457656,98.8151125 97.9706779,97.9927172 98.7420549,96.6575563 C99.5133271,95.3225768 99.5133271,93.6774232 98.7420549,92.3424437 C97.9706779,91.0072828 96.5457656,90.1848875 95.0037937,90.1848875 L95.0037937,90.1848875 Z M60,94.5009646 L67.7677636,94.5009646" fill="#FFD94C"></path>
<path d="M54.8601729,108.357758 C56.1715224,107.608286 57.8360246,108.074601 58.5779424,109.399303 L58.5779424,109.399303 L59.0525843,110.24352 C62.8563392,116.982993 66.8190116,123.380176 70.9406016,129.435068 C75.8078808,136.585427 81.28184,143.82411 87.3624792,151.151115 C88.3168778,152.30114 88.1849437,154.011176 87.065686,154.997937 L87.065686,154.997937 L86.4542085,155.534625 C66.3465389,173.103314 53.2778188,176.612552 47.2480482,166.062341 C41.3500652,155.742717 43.4844915,136.982888 53.6513274,109.782853 C53.876818,109.179582 54.3045861,108.675291 54.8601729,108.357758 Z M140.534177,129.041504 C141.986131,128.785177 143.375496,129.742138 143.65963,131.194242 L143.65963,131.194242 L143.812815,131.986376 C148.782365,157.995459 145.283348,171 133.315764,171 C121.609745,171 106.708724,159.909007 88.6127018,137.727022 C88.2113495,137.235047 87.9945723,136.617371 88,135.981509 C88.013158,134.480686 89.2357854,133.274651 90.730918,133.287756 L90.730918,133.287756 L91.6846544,133.294531 C99.3056979,133.335994 106.714387,133.071591 113.910723,132.501323 C122.409039,131.82788 131.283523,130.674607 140.534177,129.041504 Z M147.408726,73.8119663 C147.932139,72.4026903 149.508386,71.6634537 150.954581,72.149012 L150.954581,72.149012 L151.742552,72.4154854 C177.583763,81.217922 187.402356,90.8916805 181.198332,101.436761 C175.129904,111.751366 157.484347,119.260339 128.26166,123.963678 C127.613529,124.067994 126.948643,123.945969 126.382735,123.618843 C125.047025,122.846729 124.602046,121.158214 125.388848,119.847438 L125.388848,119.847438 L125.889328,119.0105 C129.877183,112.31633 133.481358,105.654262 136.701854,99.0242957 C140.50501,91.1948179 144.073967,82.7907081 147.408726,73.8119663 Z M61.7383398,66.0363218 C62.3864708,65.9320063 63.0513565,66.0540315 63.6172646,66.3811573 C64.9529754,67.153271 65.3979538,68.8417862 64.6111517,70.1525615 L64.6111517,70.1525615 L64.1106718,70.9895001 C60.1228168,77.6836699 56.5186416,84.3457379 53.2981462,90.9757043 C49.49499,98.8051821 45.9260328,107.209292 42.5912744,116.188034 C42.0678608,117.59731 40.4916142,118.336546 39.045419,117.850988 L39.045419,117.850988 L38.2574475,117.584515 C12.4162372,108.782078 2.59764398,99.1083195 8.80166786,88.5632391 C14.8700957,78.2486335 32.515653,70.7396611 61.7383398,66.0363218 Z M103.545792,34.4653746 C123.653461,16.8966864 136.722181,13.3874478 142.751952,23.9376587 C148.649935,34.2572826 146.515508,53.0171122 136.348673,80.2171474 C136.123182,80.8204179 135.695414,81.324709 135.139827,81.6422422 C133.828478,82.3917144 132.163975,81.9253986 131.422058,80.6006966 L131.422058,80.6006966 L130.947416,79.7564798 C127.143661,73.0170065 123.180988,66.6198239 119.059398,60.564932 C114.192119,53.4145727 108.71816,46.1758903 102.637521,38.8488847 C101.683122,37.6988602 101.815056,35.9888243 102.934314,35.0020629 L102.934314,35.0020629 Z M57.6842361,18 C69.3902551,18 84.2912758,29.0909926 102.387298,51.2729777 C102.788651,51.7649527 103.005428,52.3826288 103,53.0184911 C102.986842,54.5193144 101.764215,55.7253489 100.269082,55.7122445 L100.269082,55.7122445 L99.3153456,55.7054689 C91.6943021,55.6640063 84.2856126,55.9284091 77.0892772,56.4986773 C68.5909612,57.17212 59.7164767,58.325393 50.4658235,59.9584962 C49.0138691,60.2148231 47.6245044,59.2578618 47.3403697,57.805758 L47.3403697,57.805758 L47.1871846,57.0136235 C42.2176347,31.0045412 45.7166519,18 57.6842361,18 Z" fill="#FF4154"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -0,0 +1,100 @@
import localforage from 'localforage'
import { matchSorter } from 'match-sorter'
import sortBy from 'sort-by'
const initialData = [
{
avatar: 'https://avatars.githubusercontent.com/u/5580297?v=4',
createdAt: 1660978713047,
favorite: false,
first: 'Tanner',
id: 'usupkc1',
last: 'Linsley',
notes: 'Created React Query',
twitter: '@tannerlinsley',
},
{
avatar: 'https://avatars.githubusercontent.com/u/1021430',
createdAt: 1660979124264,
favorite: false,
first: 'Dominik',
id: 'kvvztl7',
last: 'D',
notes: 'Maintains React Query',
twitter: '@tkdodo',
},
]
export type Contact = (typeof initialData)[number]
const seed = async () => {
const contacts = await localforage.getItem<Contact[]>('contacts')
if (!contacts) {
set(initialData)
}
}
seed()
export async function getContacts(query?: string) {
await fakeNetwork()
let contacts = await localforage.getItem<Contact[]>('contacts')
if (!contacts) contacts = []
if (query) {
contacts = matchSorter(contacts, query, { keys: ['first', 'last'] })
}
return contacts.sort(sortBy('last', 'createdAt'))
}
export async function createContact(
data: Pick<Contact, 'first' | 'last' | 'twitter' | 'avatar' | 'notes'>,
) {
await fakeNetwork()
let id = Math.random().toString(36).substring(2, 9)
let contact = { ...data, id, createdAt: Date.now(), favorite: false }
let contacts = await getContacts()
contacts.unshift(contact)
await set(contacts)
return contact
}
export async function getContact(id: string) {
await fakeNetwork()
let contacts = await localforage.getItem<Contact[]>('contacts')
if (!contacts) return null
let contact = contacts.find((contact) => contact.id === id)
return contact ?? null
}
export async function updateContact(id: string, updates: Partial<Contact>) {
await fakeNetwork()
let contacts = await localforage.getItem<Contact[]>('contacts')
if (!contacts) return false
let contact = contacts.find((contact) => contact.id === id)
if (!contact) throw new Error(`No contact found for ${id}`)
Object.assign(contact, updates)
await set(contacts)
return contact
}
export async function deleteContact(id: string) {
let contacts = await localforage.getItem<Contact[]>('contacts')
if (!contacts) return false
let index = contacts.findIndex((contact) => contact.id === id)
if (index > -1) {
contacts.splice(index, 1)
await set(contacts)
return true
}
return false
}
function set(contacts: Contact[]) {
return localforage.setItem('contacts', contacts)
}
async function fakeNetwork() {
return new Promise((res) => {
setTimeout(res, Math.random() * 800)
})
}

View File

@@ -0,0 +1,23 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom'
export default function ErrorPage() {
const error = useRouteError()
console.error(error)
let errorMessage = ''
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText
} else if (error instanceof Error) {
errorMessage = error.message
}
return (
<div id="error-page">
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{errorMessage}</i>
</p>
</div>
)
}

View File

@@ -0,0 +1,394 @@
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
html,
body {
height: 100%;
margin: 0;
line-height: 1.5;
color: #121212;
}
form textarea,
form input,
form button,
a.button {
font-size: 1rem;
font-family: inherit;
border: none;
border-radius: 8px;
padding: 0.5rem 0.75rem;
box-shadow:
0 0px 1px hsla(0, 0%, 0%, 0.2),
0 1px 2px hsla(0, 0%, 0%, 0.2);
background-color: white;
line-height: 1.5;
margin: 0;
}
form button,
a.button {
color: #3992ff;
font-weight: 500;
text-decoration: none;
cursor: pointer;
}
form textarea:hover,
form input:hover,
form button:hover,
a.button:hover {
box-shadow:
0 0px 1px hsla(0, 0%, 0%, 0.6),
0 1px 2px hsla(0, 0%, 0%, 0.2);
}
form button:active,
a.button:active {
box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.4);
transform: translateY(1px);
}
#contact h1 {
display: flex;
align-items: flex-start;
gap: 1rem;
}
#contact h1 form {
display: flex;
align-items: center;
margin-top: 0.25rem;
}
#contact h1 form button {
box-shadow: none;
font-size: 1.5rem;
font-weight: 400;
padding: 0;
}
#contact h1 form button[value='true'] {
color: #a4a4a4;
}
#contact h1 form button[value='true']:hover,
#contact h1 form button[value='false'] {
color: #eeb004;
}
form[action$='destroy'] button {
color: #f44250;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
#root {
display: flex;
height: 100%;
width: 100%;
}
#sidebar {
width: 22rem;
background-color: #f7f7f7;
border-right: solid 1px #e3e3e3;
display: flex;
flex-direction: column;
}
#sidebar > * {
padding-left: 2rem;
padding-right: 2rem;
}
#sidebar h1 {
font-size: 1rem;
font-weight: 500;
display: flex;
align-items: center;
margin: 0;
padding: 1rem 2rem;
border-top: 1px solid #e3e3e3;
order: 1;
line-height: 1;
}
#sidebar h1::before {
content: url("data:image/svg+xml,%3Csvg width='25' height='18' viewBox='0 0 25 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19.4127 6.4904C18.6984 6.26581 18.3295 6.34153 17.5802 6.25965C16.4219 6.13331 15.9604 5.68062 15.7646 4.51554C15.6551 3.86516 15.7844 2.9129 15.5048 2.32334C14.9699 1.19921 13.7183 0.695046 12.461 0.982805C11.3994 1.22611 10.516 2.28708 10.4671 3.37612C10.4112 4.61957 11.1197 5.68054 12.3363 6.04667C12.9143 6.22097 13.5284 6.3087 14.132 6.35315C15.2391 6.43386 15.3241 7.04923 15.6236 7.55574C15.8124 7.87508 15.9954 8.18975 15.9954 9.14193C15.9954 10.0941 15.8112 10.4088 15.6236 10.7281C15.3241 11.2334 14.9547 11.5645 13.8477 11.6464C13.244 11.6908 12.6288 11.7786 12.0519 11.9528C10.8353 12.3201 10.1268 13.3799 10.1828 14.6234C10.2317 15.7124 11.115 16.7734 12.1766 17.0167C13.434 17.3056 14.6855 16.8003 15.2204 15.6762C15.5013 15.0866 15.6551 14.4187 15.7646 13.7683C15.9616 12.6032 16.423 12.1505 17.5802 12.0242C18.3295 11.9423 19.1049 12.0242 19.8071 11.6253C20.5491 11.0832 21.212 10.2696 21.212 9.14192C21.212 8.01428 20.4976 6.83197 19.4127 6.4904Z' fill='%23F44250'/%3E%3Cpath d='M7.59953 11.7459C6.12615 11.7459 4.92432 10.5547 4.92432 9.09441C4.92432 7.63407 6.12615 6.44287 7.59953 6.44287C9.0729 6.44287 10.2747 7.63407 10.2747 9.09441C10.2747 10.5536 9.07172 11.7459 7.59953 11.7459Z' fill='black'/%3E%3Cpath d='M2.64217 17.0965C1.18419 17.093 -0.0034949 15.8971 7.72743e-06 14.4356C0.00352588 12.9765 1.1994 11.7888 2.66089 11.7935C4.12004 11.797 5.30772 12.9929 5.30306 14.4544C5.29953 15.9123 4.10366 17.1 2.64217 17.0965Z' fill='black'/%3E%3Cpath d='M22.3677 17.0965C20.9051 17.1046 19.7046 15.9217 19.6963 14.4649C19.6882 13.0023 20.8712 11.8017 22.3279 11.7935C23.7906 11.7854 24.9911 12.9683 24.9993 14.4251C25.0075 15.8866 23.8245 17.0883 22.3677 17.0965Z' fill='black'/%3E%3C/svg%3E%0A");
margin-right: 0.5rem;
position: relative;
top: 1px;
}
#sidebar > div {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e3e3e3;
}
#sidebar > div form {
position: relative;
}
#sidebar > div form input[type='search'] {
width: 100%;
padding-left: 2rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='%23999' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' /%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 0.625rem 0.75rem;
background-size: 1rem;
position: relative;
}
#sidebar > div form input[type='search'].loading {
background-image: none;
}
#search-spinner {
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%23000' strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M20 4v5h-.582m0 0a8.001 8.001 0 00-15.356 2m15.356-2H15M4 20v-5h.581m0 0a8.003 8.003 0 0015.357-2M4.581 15H9' /%3E%3C/svg%3E");
animation: spin 1s infinite linear;
position: absolute;
left: 0.625rem;
top: 0.75rem;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
#sidebar nav {
flex: 1;
overflow: auto;
padding-top: 1rem;
}
#sidebar nav a span {
float: right;
color: #eeb004;
}
#sidebar nav a.active span {
color: inherit;
}
i {
color: #818181;
}
#sidebar nav .active i {
color: inherit;
}
#sidebar ul {
padding: 0;
margin: 0;
list-style: none;
}
#sidebar li {
margin: 0.25rem 0;
}
#sidebar nav a {
display: flex;
align-items: center;
justify-content: space-between;
overflow: hidden;
white-space: pre;
padding: 0.5rem;
border-radius: 8px;
color: inherit;
text-decoration: none;
gap: 1rem;
}
#sidebar nav a:hover {
background: #e3e3e3;
}
#sidebar nav a.active {
background: hsl(224, 98%, 58%);
color: white;
}
#sidebar nav a.pending {
color: hsl(224, 98%, 58%);
}
#detail {
flex: 1;
padding: 2rem 4rem;
width: 100%;
}
#detail.loading {
opacity: 0.25;
transition: opacity 200ms;
transition-delay: 200ms;
}
#contact {
max-width: 40rem;
display: flex;
}
#contact h1 {
font-size: 2rem;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
#contact h1 + p {
margin: 0;
}
#contact h1 + p + p {
white-space: break-spaces;
}
#contact h1:focus {
outline: none;
color: hsl(224, 98%, 58%);
}
#contact a[href*='twitter'] {
display: flex;
font-size: 1.5rem;
color: #3992ff;
text-decoration: none;
}
#contact a[href*='twitter']:hover {
text-decoration: underline;
}
#contact img {
width: 12rem;
height: 12rem;
background: #c8c8c8;
margin-right: 2rem;
border-radius: 1.5rem;
object-fit: cover;
}
#contact h1 ~ div {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
#contact-form {
display: flex;
max-width: 40rem;
flex-direction: column;
gap: 1rem;
}
#contact-form > p:first-child {
margin: 0;
padding: 0;
}
#contact-form > p:first-child > :nth-child(2) {
margin-right: 1rem;
}
#contact-form > p:first-child,
#contact-form label {
display: flex;
}
#contact-form p:first-child span,
#contact-form label span {
width: 8rem;
}
#contact-form p:first-child input,
#contact-form label input,
#contact-form label textarea {
flex-grow: 2;
}
#contact-form-avatar {
margin-right: 2rem;
}
#contact-form-avatar img {
width: 12rem;
height: 12rem;
background: hsla(0, 0%, 0%, 0.2);
border-radius: 1rem;
}
#contact-form-avatar input {
box-sizing: border-box;
width: 100%;
}
#contact-form p:last-child {
display: flex;
gap: 0.5rem;
margin: 0 0 0 8rem;
}
#contact-form p:last-child button[type='button'] {
color: inherit;
}
#zero-state {
margin: 2rem auto;
text-align: center;
color: #818181;
}
#zero-state a {
color: inherit;
}
#zero-state a:hover {
color: #121212;
}
#zero-state:before {
display: block;
margin-bottom: 0.5rem;
content: url("data:image/svg+xml,%3Csvg width='50' height='33' viewBox='0 0 50 33' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M38.8262 11.1744C37.3975 10.7252 36.6597 10.8766 35.1611 10.7128C32.8444 10.4602 31.9215 9.55475 31.5299 7.22456C31.3108 5.92377 31.5695 4.01923 31.0102 2.8401C29.9404 0.591789 27.4373 -0.416556 24.9225 0.158973C22.7992 0.645599 21.0326 2.76757 20.9347 4.94569C20.8228 7.43263 22.2399 9.5546 24.6731 10.2869C25.8291 10.6355 27.0574 10.8109 28.2646 10.8998C30.4788 11.0613 30.6489 12.292 31.2479 13.3051C31.6255 13.9438 31.9914 14.5731 31.9914 16.4775C31.9914 18.3819 31.6231 19.0112 31.2479 19.6499C30.6489 20.6606 29.9101 21.3227 27.696 21.4865C26.4887 21.5754 25.2581 21.7508 24.1044 22.0994C21.6712 22.834 20.2542 24.9537 20.366 27.4406C20.4639 29.6187 22.2306 31.7407 24.3538 32.2273C26.8686 32.8052 29.3717 31.7945 30.4415 29.5462C31.0032 28.3671 31.3108 27.0312 31.5299 25.7304C31.9238 23.4002 32.8467 22.4948 35.1611 22.2421C36.6597 22.0784 38.2107 22.2421 39.615 21.4443C41.099 20.36 42.4248 18.7328 42.4248 16.4775C42.4248 14.2222 40.9961 11.8575 38.8262 11.1744Z' fill='%23E3E3E3'/%3E%3Cpath d='M15.1991 21.6854C12.2523 21.6854 9.84863 19.303 9.84863 16.3823C9.84863 13.4615 12.2523 11.0791 15.1991 11.0791C18.1459 11.0791 20.5497 13.4615 20.5497 16.3823C20.5497 19.3006 18.1436 21.6854 15.1991 21.6854Z' fill='%23E3E3E3'/%3E%3Cpath d='M5.28442 32.3871C2.36841 32.38 -0.00698992 29.9882 1.54551e-05 27.0652C0.00705187 24.1469 2.39884 21.7715 5.32187 21.7808C8.24022 21.7878 10.6156 24.1796 10.6063 27.1027C10.5992 30.0187 8.20746 32.3941 5.28442 32.3871Z' fill='%23E3E3E3'/%3E%3Cpath d='M44.736 32.387C41.8107 32.4033 39.4096 30.0373 39.3932 27.1237C39.3769 24.1984 41.7428 21.7973 44.6564 21.7808C47.5817 21.7645 49.9828 24.1305 49.9993 27.0441C50.0156 29.9671 47.6496 32.3705 44.736 32.387Z' fill='%23E3E3E3'/%3E%3C/svg%3E%0A");
}
#error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}

View File

@@ -0,0 +1,78 @@
import * as React from 'react'
import * as ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import './index.css'
import ErrorPage from './error-page'
import Root, { loader as rootLoader } from './routes/root'
import Contact, {
loader as contactLoader,
action as contactAction,
} from './routes/contact'
import EditContact, { action as editAction } from './routes/edit'
import { action as destroyAction } from './routes/destroy'
import Index from './routes/index'
import NewContact, { action as newAction } from './routes/new'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 10,
},
},
})
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader(queryClient),
children: [
{
index: true,
element: <Index />,
},
{
path: 'contacts/new',
element: <NewContact />,
action: newAction(queryClient),
errorElement: <ErrorPage />,
},
{
path: 'contacts/:contactId',
element: <Contact />,
loader: contactLoader(queryClient),
action: contactAction(queryClient),
errorElement: <ErrorPage />,
},
{
path: 'contacts/:contactId/edit',
element: <EditContact />,
loader: contactLoader(queryClient),
action: editAction(queryClient),
errorElement: <ErrorPage />,
},
{
path: 'contacts/:contactId/destroy',
element: <EditContact />,
action: destroyAction(queryClient),
errorElement: <ErrorPage />,
},
],
},
])
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Missing #root element')
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools buttonPosition="bottom-right" />
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,120 @@
import { Form, Link, useFetcher, useLoaderData } from 'react-router-dom'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { getContact, updateContact } from '../contacts'
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router-dom'
import type { Contact } from '../contacts'
import type { QueryClient } from '@tanstack/react-query'
export const contactDetailQuery = (id: string) =>
queryOptions({
queryKey: ['contacts', 'detail', id],
queryFn: async () => {
const contact = await getContact(id)
if (!contact) {
throw new Response('', {
status: 404,
statusText: 'Not Found',
})
}
return contact
},
})
export const loader =
(queryClient: QueryClient) =>
async ({ params }: LoaderFunctionArgs) => {
if (!params.contactId) {
throw new Error('No contact ID provided')
}
await queryClient.ensureQueryData(contactDetailQuery(params.contactId))
return { contactId: params.contactId }
}
export const action =
(queryClient: QueryClient) =>
async ({ request, params }: ActionFunctionArgs) => {
const formData = await request.formData()
if (!params.contactId) {
throw new Error('No contact ID provided')
}
await updateContact(params.contactId, {
favorite: formData.get('favorite') === 'true',
})
await queryClient.invalidateQueries({ queryKey: ['contacts'] })
return null
}
export default function Contact() {
const { contactId } = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof loader>>
>
const { data: contact } = useSuspenseQuery(contactDetailQuery(contactId))
return (
<div id="contact">
<div>
<img key={contact.avatar} src={contact.avatar || undefined} />
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{' '}
<Favorite contact={contact} />
</h1>
{contact.twitter && (
<p>
<a target="_blank" href={`https://twitter.com/${contact.twitter}`}>
{contact.twitter}
</a>
</p>
)}
{contact.notes && <p>{contact.notes}</p>}
<div>
<Link to="edit" className="button">
Edit
</Link>
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (!confirm('Please confirm you want to delete this record.')) {
event.preventDefault()
}
}}
>
<button type="submit">Delete</button>
</Form>
</div>
</div>
</div>
)
}
function Favorite({ contact }: { contact: Contact }) {
const fetcher = useFetcher({ key: `contact:${contact.id}` })
let favorite = contact.favorite
if (fetcher.formData) {
favorite = fetcher.formData.get('favorite') === 'true'
}
return (
<fetcher.Form method="post">
<button
name="favorite"
value={favorite ? 'false' : 'true'}
title={favorite ? 'Remove from favorites' : 'Add to favorites'}
>
{favorite ? '★' : '☆'}
</button>
</fetcher.Form>
)
}

View File

@@ -0,0 +1,14 @@
import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { deleteContact } from '../contacts'
import { QueryClient } from '@tanstack/react-query'
export const action =
(queryClient: QueryClient) =>
async ({ params }: ActionFunctionArgs) => {
if (!params.contactId) {
throw new Error('No contact ID provided')
}
await deleteContact(params.contactId)
queryClient.invalidateQueries({ queryKey: ['contacts'] })
return redirect('/')
}

View File

@@ -0,0 +1,92 @@
import {
Form,
useLoaderData,
redirect,
useNavigate,
ActionFunctionArgs,
} from 'react-router-dom'
import { Contact, updateContact } from '../contacts'
import { QueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { contactDetailQuery, loader } from './contact'
export const action =
(queryClient: QueryClient) =>
async ({ request, params }: ActionFunctionArgs) => {
const formData = await request.formData()
const updates = Object.fromEntries(formData)
if (!params.contactId) {
throw new Error('No contact ID provided')
}
await updateContact(params.contactId, updates)
queryClient.invalidateQueries({ queryKey: ['contacts'] })
return redirect(`/contacts/${params.contactId}`)
}
export default function Edit() {
const { contactId } = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof loader>>
>
const { data: contact } = useSuspenseQuery(contactDetailQuery(contactId))
return <ContactForm contact={contact} />
}
export function ContactForm({ contact }: { contact?: Contact }) {
const navigate = useNavigate()
return (
<Form method="post" id="contact-form">
<p>
<span>Name</span>
<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact?.first}
/>
<input
placeholder="Last"
aria-label="Last name"
type="text"
name="last"
defaultValue={contact?.last}
/>
</p>
<label>
<span>Twitter</span>
<input
type="text"
name="twitter"
placeholder="@jack"
defaultValue={contact?.twitter}
/>
</label>
<label>
<span>Avatar URL</span>
<input
placeholder="https://example.com/avatar.jpg"
type="text"
name="avatar"
defaultValue={contact?.avatar}
/>
</label>
<label>
<span>Notes</span>
<textarea name="notes" defaultValue={contact?.notes} rows={6} />
</label>
<p>
<button type="submit">Save</button>
<button
type="button"
onClick={() => {
navigate(-1)
}}
>
Cancel
</button>
</p>
</Form>
)
}

View File

@@ -0,0 +1,14 @@
export default function Index() {
return (
<p id="zero-state">
This is a demo for integrating React Router with React Query.
<br />
Check out{' '}
<a href="https://reactrouter.com/">
the docs at reactrouter.com
</a> and{' '}
<a href="https://tanstack.com/query/v5/docs/">the docs at tanstack.com</a>
.
</p>
)
}

View File

@@ -0,0 +1,19 @@
import { redirect, ActionFunctionArgs } from 'react-router-dom'
import { createContact } from '../contacts'
import { QueryClient } from '@tanstack/react-query'
import { ContactForm } from './edit'
export const action =
(queryClient: QueryClient) =>
async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData()
const data = Object.fromEntries(formData)
const contact = await createContact(data as any)
queryClient.invalidateQueries({ queryKey: ['contacts', 'list'] })
return redirect(`/contacts/${contact.id}`)
}
export default function New() {
return <ContactForm />
}

View File

@@ -0,0 +1,110 @@
import {
Link,
NavLink,
Outlet,
useLoaderData,
useNavigation,
useSubmit,
} from 'react-router-dom'
import { useDebounce } from 'rooks'
import {
queryOptions,
useIsFetching,
useSuspenseQuery,
} from '@tanstack/react-query'
import { getContacts } from '../contacts'
import type { QueryClient } from '@tanstack/react-query'
import type { LoaderFunctionArgs } from 'react-router-dom'
const contactListQuery = (q?: string) =>
queryOptions({
queryKey: ['contacts', 'list', q ?? 'all'],
queryFn: () => getContacts(q),
})
export const loader =
(queryClient: QueryClient) =>
async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url)
const q = url.searchParams.get('q') ?? ''
await queryClient.ensureQueryData(contactListQuery(q))
return { q }
}
export default function Root() {
const { q } = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof loader>>
>
const { data: contacts } = useSuspenseQuery(contactListQuery(q))
const searching = useIsFetching({ queryKey: ['contacts', 'list'] }) > 0
const navigation = useNavigation()
const submit = useSubmit()
const debouncedSubmit = useDebounce(submit, 500)
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
key={q}
autoFocus
defaultValue={q}
className={searching ? 'loading' : ''}
onChange={(event) => {
debouncedSubmit(event.currentTarget.form)
}}
/>
<div id="search-spinner" aria-hidden hidden={!searching} />
<div className="sr-only" aria-live="polite"></div>
</form>
<Link to="contacts/new" className="button">
New
</Link>
</div>
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<NavLink
to={`contacts/${contact.id}`}
className={({ isActive, isPending }) =>
isActive ? 'active' : isPending ? 'pending' : ''
}
>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{' '}
{contact.favorite && <span>★</span>}
</NavLink>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
</div>
<div
id="detail"
className={navigation.state === 'loading' ? 'loading' : ''}
>
<Outlet />
</div>
</>
)
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}