Framework
Version
Search

React Example: Offline

jsx
import * as React from 'react'

import {
  useQuery,
  QueryClient,
  MutationCache,
  onlineManager,
  useIsRestoring,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import toast, { Toaster } from 'react-hot-toast'

import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import {
  Link,
  Outlet,
  ReactLocation,
  Router,
  useMatch,
} from '@tanstack/react-location'

import * as api from './api'
import { movieKeys, useMovie } from './movies'

const persister = createSyncStoragePersister({
  storage: window.localStorage,
})

const location = new ReactLocation()

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      cacheTime: 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.fetchQuery({
                  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 && moviesQuery.isFetching) {
    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 && movieQuery.isFetching) {
    return 'Loading...'
  }

  function submitForm(event) {
    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.isLoading && 'updating...'}
        </div>
      </form>
    )
  }

  if (movieQuery.isPaused) {
    return "We're offline and have no data to show :("
  }

  return null
}
import * as React from 'react'

import {
  useQuery,
  QueryClient,
  MutationCache,
  onlineManager,
  useIsRestoring,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import toast, { Toaster } from 'react-hot-toast'

import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import {
  Link,
  Outlet,
  ReactLocation,
  Router,
  useMatch,
} from '@tanstack/react-location'

import * as api from './api'
import { movieKeys, useMovie } from './movies'

const persister = createSyncStoragePersister({
  storage: window.localStorage,
})

const location = new ReactLocation()

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      cacheTime: 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.fetchQuery({
                  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 && moviesQuery.isFetching) {
    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 && movieQuery.isFetching) {
    return 'Loading...'
  }

  function submitForm(event) {
    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.isLoading && 'updating...'}
        </div>
      </form>
    )
  }

  if (movieQuery.isPaused) {
    return "We're offline and have no data to show :("
  }

  return null
}

You are currently reading v4 docs. Redirect to latest version?

Latest
scarf analytics