snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
223
examples/react/offline/src/App.tsx
Normal file
223
examples/react/offline/src/App.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
MutationCache,
|
||||
QueryClient,
|
||||
onlineManager,
|
||||
useIsRestoring,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { Toaster, toast } from 'react-hot-toast'
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
ReactLocation,
|
||||
Router,
|
||||
useMatch,
|
||||
} from '@tanstack/react-location'
|
||||
|
||||
import * as api from './api'
|
||||
import { movieKeys, useMovie } from './movies'
|
||||
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
})
|
||||
|
||||
const location = new ReactLocation()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
staleTime: 2000,
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
// configure global cache callbacks to show toast notifications
|
||||
mutationCache: new MutationCache({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// we need a default mutation function so that paused mutations can resume after a page reload
|
||||
queryClient.setMutationDefaults(movieKeys.all(), {
|
||||
mutationFn: async ({ id, comment }) => {
|
||||
// to avoid clashes with our optimistic update when an offline mutation continues
|
||||
await queryClient.cancelQueries({ queryKey: movieKeys.detail(id) })
|
||||
return api.updateMovie(id, comment)
|
||||
},
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
onSuccess={() => {
|
||||
// resume mutations after initial restore from localStorage was successful
|
||||
queryClient.resumePausedMutations().then(() => {
|
||||
queryClient.invalidateQueries()
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Movies />
|
||||
<ReactQueryDevtools initialIsOpen />
|
||||
</PersistQueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Movies() {
|
||||
const isRestoring = useIsRestoring()
|
||||
return (
|
||||
<Router
|
||||
location={location}
|
||||
routes={[
|
||||
{
|
||||
path: '/',
|
||||
element: <List />,
|
||||
},
|
||||
{
|
||||
path: ':movieId',
|
||||
element: <Detail />,
|
||||
errorElement: <MovieError />,
|
||||
loader: ({ params: { movieId } }) =>
|
||||
queryClient.getQueryData(movieKeys.detail(movieId)) ??
|
||||
// do not load if we are offline or hydrating because it returns a promise that is pending until we go online again
|
||||
// we just let the Detail component handle it
|
||||
(onlineManager.isOnline() && !isRestoring
|
||||
? queryClient.query({
|
||||
queryKey: movieKeys.detail(movieId),
|
||||
queryFn: () => api.fetchMovie(movieId),
|
||||
})
|
||||
: undefined),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
function List() {
|
||||
const moviesQuery = useQuery({
|
||||
queryKey: movieKeys.list(),
|
||||
queryFn: api.fetchMovies,
|
||||
})
|
||||
|
||||
if (moviesQuery.isLoading) {
|
||||
return 'Loading...'
|
||||
}
|
||||
|
||||
if (moviesQuery.data) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Movies</h1>
|
||||
<p>
|
||||
Try to mock offline behaviour with the button in the devtools. You can
|
||||
navigate around as long as there is already data in the cache. You'll
|
||||
get a refetch as soon as you go online again.
|
||||
</p>
|
||||
<ul>
|
||||
{moviesQuery.data.movies.map((movie) => (
|
||||
<li key={movie.id}>
|
||||
<Link to={`./${movie.id}`} preload>
|
||||
{movie.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
Updated at: {new Date(moviesQuery.data.ts).toLocaleTimeString()}
|
||||
</div>
|
||||
<div>{moviesQuery.isFetching && 'fetching...'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// query will be in 'idle' fetchStatus while restoring from localStorage
|
||||
return null
|
||||
}
|
||||
|
||||
function MovieError() {
|
||||
const { error } = useMatch()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to="..">Back</Link>
|
||||
<h1>Couldn't load movie!</h1>
|
||||
<div>{error.message}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail() {
|
||||
const {
|
||||
params: { movieId },
|
||||
} = useMatch()
|
||||
const { comment, setComment, updateMovie, movieQuery } = useMovie(movieId)
|
||||
|
||||
if (movieQuery.isLoading) {
|
||||
return 'Loading...'
|
||||
}
|
||||
|
||||
function submitForm(event: any) {
|
||||
event.preventDefault()
|
||||
|
||||
updateMovie.mutate({
|
||||
id: movieId,
|
||||
comment,
|
||||
})
|
||||
}
|
||||
|
||||
if (movieQuery.data) {
|
||||
return (
|
||||
<form onSubmit={submitForm}>
|
||||
<Link to="..">Back</Link>
|
||||
<h1>Movie: {movieQuery.data.movie.title}</h1>
|
||||
<p>
|
||||
Try to mock offline behaviour with the button in the devtools, then
|
||||
update the comment. The optimistic update will succeed, but the actual
|
||||
mutation will be paused and resumed once you go online again.
|
||||
</p>
|
||||
<p>
|
||||
You can also reload the page, which will make the persisted mutation
|
||||
resume, as you will be online again when you "come back".
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
Comment: <br />
|
||||
<textarea
|
||||
name="comment"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<button type="submit">Submit</button>
|
||||
<div>
|
||||
Updated at: {new Date(movieQuery.data.ts).toLocaleTimeString()}
|
||||
</div>
|
||||
<div>{movieQuery.isFetching && 'fetching...'}</div>
|
||||
<div>
|
||||
{updateMovie.isPaused
|
||||
? 'mutation paused - offline'
|
||||
: updateMovie.isPending && 'updating...'}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
if (movieQuery.isPaused) {
|
||||
return "We're offline and have no data to show :("
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
84
examples/react/offline/src/api.ts
Normal file
84
examples/react/offline/src/api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { HttpResponse, delay, http, passthrough } from 'msw'
|
||||
import { setupWorker } from 'msw/browser'
|
||||
|
||||
const movies = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Guardians of the Galaxy',
|
||||
comment: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Wall-E',
|
||||
comment: '',
|
||||
},
|
||||
]
|
||||
|
||||
export const fetchMovie = async (
|
||||
id: string,
|
||||
): Promise<{
|
||||
ts: number
|
||||
movie: { comment: string; id: string; title: string }
|
||||
}> => {
|
||||
const response = await fetch(`/movies/${id}`)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const fetchMovies = async (): Promise<{
|
||||
ts: number
|
||||
movies: typeof movies
|
||||
}> => {
|
||||
const response = await fetch('/movies')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const updateMovie = async (id: string, comment: string) => {
|
||||
const response = await fetch(`/movies/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const worker = setupWorker(
|
||||
http.get('/movies', async () => {
|
||||
await delay(500)
|
||||
return HttpResponse.json({
|
||||
ts: Date.now(),
|
||||
movies: movies.map(({ id, title }) => ({ id, title })),
|
||||
})
|
||||
}),
|
||||
|
||||
http.get('/movies/:id', async ({ params }) => {
|
||||
const { id } = params
|
||||
|
||||
const movie = movies.find((movie) => movie.id === id)
|
||||
|
||||
if (!movie) {
|
||||
return new HttpResponse(`Movie with id ${id} not found`, {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
await delay(500)
|
||||
return HttpResponse.json({ ts: Date.now(), movie })
|
||||
}),
|
||||
|
||||
http.post('/movies/:id', async ({ request, params }) => {
|
||||
const { id } = params
|
||||
const body = await request.json()
|
||||
const { comment } = body
|
||||
|
||||
movies.forEach((movie) => {
|
||||
if (movie.id === id) {
|
||||
movie.comment = comment.toUpperCase()
|
||||
}
|
||||
})
|
||||
|
||||
await delay(500)
|
||||
return HttpResponse.json({ message: `Successfully updated movie ${id}` })
|
||||
}),
|
||||
http.get('*.js', () => passthrough()),
|
||||
http.get('*.svg', () => passthrough()),
|
||||
)
|
||||
14
examples/react/offline/src/index.tsx
Normal file
14
examples/react/offline/src/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { worker } from './api'
|
||||
|
||||
worker.start()
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) throw new Error('Missing #root element')
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<div style={{ padding: '16px' }}>
|
||||
<App />
|
||||
</div>,
|
||||
)
|
||||
55
examples/react/offline/src/movies.ts
Normal file
55
examples/react/offline/src/movies.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from './api'
|
||||
|
||||
export const movieKeys = {
|
||||
all: () => ['movies'],
|
||||
list: () => [...movieKeys.all(), 'list'],
|
||||
details: () => [...movieKeys.all(), 'detail'],
|
||||
detail: (id: string) => [...movieKeys.details(), id],
|
||||
}
|
||||
|
||||
export const useMovie = (movieId: string) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const movieQuery = useQuery({
|
||||
queryKey: movieKeys.detail(movieId),
|
||||
queryFn: () => api.fetchMovie(movieId),
|
||||
})
|
||||
|
||||
const [comment, setComment] = React.useState<string | undefined>()
|
||||
|
||||
const updateMovie = useMutation({
|
||||
mutationKey: movieKeys.detail(movieId),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: movieKeys.detail(movieId) })
|
||||
const previousData = queryClient.getQueryData(movieKeys.detail(movieId))
|
||||
|
||||
// remove local state so that server state is taken instead
|
||||
setComment(undefined)
|
||||
|
||||
queryClient.setQueryData(movieKeys.detail(movieId), {
|
||||
...previousData,
|
||||
movie: {
|
||||
...previousData.movie,
|
||||
comment,
|
||||
},
|
||||
})
|
||||
|
||||
return { previousData }
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
queryClient.setQueryData(movieKeys.detail(movieId), context.previousData)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: movieKeys.detail(movieId) })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
comment: comment ?? movieQuery.data?.movie.comment,
|
||||
setComment,
|
||||
updateMovie,
|
||||
movieQuery,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user