Tanstack Query, oauth2, openapi typescript, musicfun, Часть 1

Оценить качество материала и подачу материала автором видео:

Front-end

Трудоустройтесь middle front-end разработчиком на React JS (TypeScript) за 12-16 месяцев обучения с ежедневной менторской поддержкой в формате видео 1 на 1 и коммерческими проектами в портфолио

Перейти на курс
Front-end

Back-end

Трудоустройтесь middle back-end разработчиком за 12-16 месяцев обучения с ежедневной менторской поддержкой в формате видео 1 на 1 и коммерческими проектами в портфолио

Перейти на курс
Back-end

Карьерный бустер

Получите коммерческий опыт на реальных стартапах, прокачайте tech & soft навыки, научитесь работать в команде, проходить собеседования и получите первую работу в IT!

Перейти на курс
Карьерный бустер

Основы Front-end

Сделайте первый шаг в IT, освоив базовые знания разработки и научившись создавать небольшие проекты на JavaScript

Перейти на курс
Основы Front-end

Основы Back-end

Сделайте первый шаг в IT, освоив базовые знания разработки. Без опыта. Без математики. Только практика: JavaScript, SQL, Node JS, база данных

Перейти на курс
Основы Back-end

React Middle курс: Tanstack Query, OAuth2, openapi typescript, musicfun

Автор конспекта: Елизавета Савинова

Полезные ссылки

Введение и архитектурные принципы

Separation of Concerns (Разделение ответственностей)

  • Один из главных архитектурных принципов

  • На его основе выделяются слои (layers/tiers) приложения

  • Каждый слой отвечает за свою область ответственности

Основные слои архитектуры

architecture-frontend

1. Data Access Layer (DAL)

  • Самый независимый слой

  • Управляет запросами на сервер

  • Зависит только от источника данных (REST API, бэкенд)

  • Не импортирует Redux, TanStack Query, React

  • Может использовать axios или нативный fetch

  • Можно сгенерировать автоматически на основе документации API

2. Business Logic Layer / State Management

  • Главный слой приложения

  • Для фронтенда = управление состоянием (state management)

  • Импортирует функции из Data Access Layer

  • Управляет бизнес-логикой приложения

  • В современных фронтенд-приложениях часто просто пинает DAL для получения данных

3. Presentation Layer (UI)

  • Слой презентации данных

  • Библиотека React занимается эффективным рендерингом UI

  • Импортирует из Business Logic Layer

  • Не должен знать детали работы с сервером

Направление зависимостей

UI → Business Logic → Data Access → Server

  • Зависимости идут только слева направо

  • Нижние слои не должны импортировать верхние

  • Инверсия управления через паттерн Observer

TanStack Query - основные концепции

Что такое TanStack Query?

  • Инструмент для асинхронного state management

  • Управляет кэшированием данных с сервера

  • Построен на паттерне Observer

  • Не зависит от конкретного UI фреймворка

Query Client

  • Центральный объект, управляющий всем кэшем

  • Создается один раз на приложение

  • Предоставляется через React Context

  • Реализует паттерн Observer для уведомления компонентов

Ключевые концепции

Query (Запросы)

  • Для получения данных (GET запросы)

  • Автоматически кэшируются

  • Дедуплицируются (несколько компонентов = один запрос)

Mutation (Мутации)

  • Для изменения данных (POST, PUT, DELETE)

  • Запускаются императивно

  • Могут инвалидировать кэш после выполнения

Старт и настройка проекта

Для старта проекта мы будем использовать Vite

bash
pnpm create vite

После выбора соответствующих настроек и запуска проекта установим инструмент для асинхронного стейт менеджмента - TanStack Query

Необходимые пакеты:

bash
# Основные
pnpm install @tanstack/react-query
pnpm install @tanstack/react-query-devtools
 
# Для генерации типов из OpenAPI
pnpm install -D openapi-typescript
pnpm install openapi-fetch
 
# Дополнительные
pnpm install react-hook-form
pnpm install @tanstack/react-router

Так же по рекомендации официальной документации Open API добавим в файл tsconfig.app.json:

tsconfig.app.json
{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true
  }
}

Первый запрос к API

Для работы с MusicFun API необходимо зарегистрироваться. Там же вы найдёте ссылку на документацию в Swagger

Сделаем пробный запрос

App.tsx
function App() {
  useEffect(() => {
    ;(async function () {
      const response = await fetch("https://musicfun.it-incubator.app/api/1.0/playlists", {
        headers: {
          "api-key": "72c3121c-c679-4c0e-9131-2d3f35e6a3bd",
        },
      })
 
      const data = response.json()
      console.log(data)
    })()
  }, [])
}

После обновления страницы в Network мы увидим запрос за плейлистами

Генерация кода

В документации openapi-fetch копируем команду и вставляем её в package.json в качестве скрипта

package.json
"scripts": {
"api:gen": "pnpm openapi-typescript https://musicfun.it-incubator.app/api-json -o ./src/shared/api/schema.ts --root-types"
}

После запуска скрипта в папке src/shared/api/schema.ts мы увидим полное описание типов из API

Помимо того, что мы сгенерировали схемы, нам так же надо сгенерировать инструмент Open fetch - своего рода аналог axios

api/client.ts
import createClient from "openapi-fetch"
import type { paths } from "./schema"
 
export const client = createClient<paths>({
  baseUrl: "https://musicfun.it-incubator.app/api/1.0",
  headers: {
    "api-key": "72c3121c-c679-4c0e-9131-2d3f35e6a3bd",
  },
})

Воспользуемся клиентом для запроса за плейлистами

App.tsx
function App() {
  useEffect(() => {
    ;(async function () {
      const response = await client.GET("/playlists")
      const data = response.data
      console.log(data)
    })()
  }, [])
}

Что мы получили от сгенерированной схемы и написанного клиента:

  • минимизирована возможность ошибки в параметре запроса
  • есть строгая типизацию приходящих данных
  • не думаем про непосредственно документацию

useQuery

В дальнейшем мы избавимся от useEffect, но не потому что не будем его использовать, а потому что он будет спрятан внутри хуков которые нам предоставляет TanStack Query

main.tsx
import { createRoot } from "react-dom/client"
import "./index.css"
import App from "./App.tsx"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
 
const queryClient = new QueryClient() //создание экземпляра клиента React
// Query - управляет кэшем, контролирует настройки, синхронизирует данные
 
createRoot(document.getElementById("root")!).render(
  //React-компонент провайдер - предоставляет доступ к queryClient всему
  // дереву компонентов, создает контекст, должен оборачивать корневой
  // компонент приложения
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
)
App.tsx
function App() {
  //useEffect(() => {
  //  (async function() {
  //    const response = await client.GET('/playlists')
  //    const data = response.data;
  //    console.log(data)
  //  })()
  //}, [])
  const query = useQuery({
    queryKey: ["playlists"],
    queryFn: () => {
      client.GET("./playlists")
    },
  })
}

Теперь попробуем отобразить приходящие данные

App.tsx
function App() {
  return (
    <>
      <h2>hello it-incubator!!!</h2>
      <Playlists />
    </>
  )
}
 
const Playlists = () => {
  const query = useQuery({
    queryKey: ["playlists"],
    queryFn: () => {
      client.GET("./playlists")
    },
  })
 
  return (
    <div>
      <ul>
        {query.data?.data?.data.map((playlist: { id }) => (
          <li>{playlist.attributes.title}</li>
        ))}
      </ul>
    </div>
  )
}

staleTime

⚠️

TanStack по умолчанию считает, что данные устарели сразу после того как их запросили, поэтому мы наблюдаем множественные запросы в Network даже если мы всего лишь потеряли вкладку браузера из фокуса и снова к ней вернулись

В запросе за плейлистами нам это свойство TanStack не нужно, поэтому мы установим staleTime (когда данные будут устаревать)

App.tsx
const Playlists = () => {
  const query = useQuery({
    staleTime: 10000,
    queryKey: ["playlists"],
    queryFn: () => {
      client.GET("./playlists")
    },
  })
}

Перезапрос происходит через 10 секунд

Об этом поведении можно почитать в разделе Important Defaults

gcTime

gcTime (Garbage Collection Time) в TanStack Query - это время, через которое неиспользуемые данные удаляются из кэша.

gcTime срабатывает только когда:

  • Нет активных компонентов, использующих эти данные
  • Все наблюдатели (observers) отписались от query
App.tsx
function App() {
  const [isVisible, setIsVisible] = useState(true)
  useEffect(() => {
    setInterval(() => {
      setIsVisible((prev) => !prev)
    }, 3000)
  }, [])
  return (
    <>
      <h2>hello it-incubator!!!</h2>
      {isVisible && <Playlists />}
    </>
  )
}
 
const Playlists = () => {
  const query = useQuery({
    staleTime: Infinity,
    gcTime: 5 * 1000,
    queryKey: ["playlists"],
    queryFn: () => {
      client.GET("./playlists")
    },
  })
 
  return (
    <div>
      <ul>
        {query.data?.data?.data.map((playlist: { id }) => (
          <li>{playlist.attributes.title}</li>
        ))}
      </ul>
    </div>
  )
}

DevTools

ReactQueryDevtools - это инструмент разработчика для отладки и мониторинга React Query.

main.tsx
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
 
createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>,
)

refetchOnMount / refetchOnFocus

refetchOnMount и refetchOnFocus - это настройки автоматического обновления данных в TanStack Query.

refetchOnMount

Управляет тем, должны ли данные обновляться при монтировании компонента.

Возможные значения:

  • true - всегда рефетчить при монтировании
  • false - никогда не рефетчить при монтировании
  • "always" - рефетчить даже если данные свежие (не stale)

refetchOnFocus

Управляет обновлением данных при возвращении фокуса на вкладку/окно.

Возможные значения:

  • true - рефетчить при фокусе если данные stale
  • false - никогда не рефетчить при фокусе
  • "always" - всегда рефетчить при фокусе

Эти настройки помогают держать данные актуальными без лишних запросов к серверу.

refetchOnReconnect

refetchOnReconnect - это настройка автоматического обновления данных при восстановлении интернет-соединения.

Возможные значения

  • true - рефетчить при переподключении если данные stale (по умолчанию)
  • false - никогда не рефетчить при переподключении
  • "always" - всегда рефетчить при переподключении, даже если данные свежие

Это особенно полезно для мобильных приложений, где потеря соединения - частое явление.

Все эти значения удобнее всего хранить в клиенте

main.tsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
      refetchOnMount: false,
      refetchOnWindowFocus: false,
      refetchOnReconnect: false,
      gcTime: 5 * 1000,
    },
  },
})

defaultOptions - это глобальные настройки по умолчанию для всех queries и mutations в TanStack Query. Позволяет настроить поведение всех запросов в приложении одним местом, избегая дублирования настроек.

Styles

app/styles/reset.css
/* Box sizing rules */
*,
*::before,
*::after {
  box-sizing: border-box;
}
 
/* Prevent font size inflation */
html {
  -moz-text-size-adjust: none;
  -webkit-text-size-adjust: none;
  text-size-adjust: none;
}
 
/* Remove default margin in favour of better control in authored CSS */
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
  margin-block-end: 0;
}
 
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul,
ol {
  list-style: none;
}
 
/* Set core body defaults */
body {
  min-height: 100vh;
  line-height: 1.5;
}
 
/* Set shorter line heights on headings and interactive elements */
h1,
h2,
h3,
h4,
button,
input,
label {
  line-height: 1.1;
}
 
/* Balance text wrapping on headings */
h1,
h2,
h3,
h4 {
  text-wrap: balance;
}
 
/* A elements that don't have a class get default styles */
a:not([class]) {
  text-decoration-skip-ink: auto;
  color: currentColor;
}
 
/* Make images easier to work with */
img,
picture {
  max-width: 100%;
  display: block;
}
 
/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
  font-family: inherit;
  font-size: inherit;
}
 
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
  min-height: 10em;
}
 
/* Anything that has been anchored to should have extra scroll margin */
:target {
  scroll-margin-block: 5ex;
}
app/styles/index.css
body {
  font-family:
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    Roboto,
    "Helvetica Neue",
    Arial,
    sans-serif;
  background: #060707;
  color: #9c9c9c;
}
 
a {
  color: #9c9c9c;
}

Так же перенесём main.ts в app/entrypoint и удалим index.css и App.css в папке src - делаем нашу структуру больше похожей на FSD

Настройка TanStack Router

Доустанавливаем необходимый плагин для работы с TanStack Router (сам TanStack Router мы установили ранее)

bash
pnpm add -D @tanstack/router-plugin

Настроим плагин:

vite.config.ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import { tanstackRouter } from "@tanstack/router-plugin/vite"
 
export default defineConfig({
  plugins: [
    tanstackRouter({
      target: "react",
      autoCodeSplitting: true,
    }),
    react(),
  ],
})
⚠️
tanstackRouter() должен располагаться выше, чем react()

Создаём файлы src/app/routes/__root.tsx и src/app/routes/index.tsx

__root.tsx
import { createRootRoute, Outlet } from "@tanstack/react-router"
 
export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
    </>
  ),
})
index.tsx
import { createFileRoute } from "@tanstack/react-router"
import App from "../App.tsx"
 
export const Route = createFileRoute("/")({
  component: App,
})

На основе этих двух файлов автоматически генерируется routeTree.gen.ts, и нам остаётся подключить всё это в точке входа приложения

main.tsx
import { createRoot } from "react-dom/client"
import "./styles/reset.css"
import "./styles/index.css"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
 
import { routeTree } from "../routes/routeTree.gen"
import { createRouter, RouterProvider } from "@tanstack/react-router"
 
// Create a new router instance
const router = createRouter({ routeTree })
 
// Register the router instance for type safety
declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router
  }
}
 
createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <RouterProvider router={router} />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>,
)

Ещё некоторые преобразования:

1. Создадим рутовый лэйаут

app/layouts/root-layout.tsx
import { Outlet } from "@tanstack/react-router"
 
export const RootLayout = () => (
  <>
    <Outlet />
  </>
)
__root.tsx
import { createRootRoute } from "@tanstack/react-router"
import { RootLayout } from "../layouts/root-layout.tsx"
 
export const Route = createRootRoute({
  component: RootLayout,
})

2. Создадим страницу с плейлистами

Переименуем App.tsx в playlists-page.tsx и переместим её в папку src/pages

index.tsx
import { createFileRoute } from "@tanstack/react-router"
import PlayListsPage from "../playlists-page.tsx"
 
export const Route = createFileRoute("/")({
  component: PlayListsPage,
})

3. И ещё несколько страниц:

Плейлист пользователя:

pages/my-playlists-page.tsx
import { Playlists } from "./playlists-page.tsx"
 
function MyPlaylistsPage() {
  // Show usages
 
  return (
    <>
      <h2>My Playlists</h2>
      <Playlists />
    </>
  )
}
 
export default MyPlaylistsPage
routes/my-playlists.tsx
import { createFileRoute } from "@tanstack/react-router"
import MyPlaylistsPage from "../../pages/my-playlists-page.tsx"
 
export const Route = createFileRoute("/my-playlists")({
  component: MyPlaylistsPage,
})

Благодаря кодогенерации в routeTree.gen.ts так же произойдут изменения

4. oauth

pages/auth/oauth-callback-page.tsx
export function OAuthCallbackPage() {
  // no usages
 
  return (
    <>
      <h2>OAuth2 Callback page</h2>
    </>
  )
}
routes/oauth/callback.tsx
import { createFileRoute } from "@tanstack/react-router"
import { OAuthCallbackPage } from "../../../pages/auth/oauth-callback-page.tsx"
 
export const Route = createFileRoute("/oauth/callback")({
  component: OAuthCallbackPage,
})

5. Header

tsx
import { Link } from "@tanstack/react-router"
import styles from "./header.module.css"
import type { ReactNode } from "react"
 
type Props = {
  renderAccountBar: () => ReactNode
}
 
export const Header = ({ renderAccountBar }: Props) => {
  return (
    <header className={styles.header}>
      <div className={styles.container}>
        <div className={styles.linksBlock}>
          <Link to="/">Playlists</Link>
          <Link to="/my-playlists">My Playlists</Link>
          <Link to="/oauth/callback">temp page</Link>
        </div>
 
        <div>{renderAccountBar()}</div>
      </div>
    </header>
  )
}
app/layouts/root-layout.tsx
import { Outlet } from "@tanstack/react-router"
import { Header } from "../shared/ui/header.tsx"
 
export const RootLayout = () => {
  return (
    <>
      <Header renderAccountBar={() => <div>Account</div>} />
      <Outlet />
    </>
  )
}

6. Рефакторинг - вынесем компонент Playlists из playlists-page.tsx в самостоятельный файл

features/playlists.tsx
export const Playlists = () => {
  const query = useQuery({
    queryKey: ["playlists"],
    queryFn: () => client.GET("/playlists"),
  })
 
  console.log("status:" + query.status)
  console.log("fetchStatus:" + query.fetchStatus)
 
  return (
    <div>
      <ul>
        {query.data?.data?.data.map((playlist) => (
          <li>{playlist.attributes.title}</li>
        ))}
      </ul>
    </div>
  )
}

Preloader / skeleton / status / fetchStatus

Ещё одна из возможностей предоставляемая нам TanStack - корректное отображение "крутилок" Preloaders

О статусах можно почитать в разделе Queries

status - основное состояние запроса

Возможные значения:

  • 'pending' - запрос выполняется впервые (нет кэшированных данных)
  • 'error' - запрос завершился с ошибкой
  • 'success' - запрос успешно завершен и есть данные

fetchStatus - состояние фетчинга

Возможные значения:

  • 'fetching' - запрос активно выполняется
  • 'paused' - запрос приостановлен (нет сети)
  • 'idle' - запрос не выполняется

Ключевая разница: status отражает наличие данных, fetchStatus - активность сетевых запросов.

features/playlists.tsx
export const Playlists = () => {
  const query = useQuery({
    queryKey: ["playlists"],
    queryFn: () => client.GET("/playlists"),
  })
 
  console.log("status:" + query.status)
  console.log("fetchStatus:" + query.fetchStatus)
 
  if (query.status === "pending") return <span>Loading...</span>
 
  return (
    <div>
      if (query.fetchStatus === 'fetching') return <span>⏳</span>
      <ul>
        {query.data?.data?.data.map((playlist) => (
          <li>{playlist.attributes.title}</li>
        ))}
      </ul>
    </div>
  )
}
⚠️
Устаревание данных не зависит от потребителя данных

query - это объект и у него есть свойства к которым мы можем обратиться и заменить:

query.status === 'pending' => query.isPending

query.fetchStatus === 'fetching' => query.isFetching

Проверка на наличие данных возвращаемых запросом:

features/playlists.tsx
export const Playlists = () => {
  const query = useQuery({
    queryKey: ["playlists"],
    queryFn: () => client.GET("/playlists"),
  })
 
  console.log("status:" + query.status)
  console.log("fetchStatus:" + query.fetchStatus)
 
  if (query.isPending) return <span>Loading...</span>
  if (query.isError) return <span>{JSON.stringify(query.error.message)}</span>
 
  return (
    <div>
      if (query.isFetching) return <span>⏳</span>
      <ul>
        {query.data.data?.data.map((playlist) => (
          <li>{playlist.attributes.title}</li>
        ))}
      </ul>
    </div>
  )
}
⚠️
query.data - больше не требует проверки

Метод client.GET

Метод client.GET Метод client.GET возвращает нам объект у которого есть Data и response Responce является сложным объектом и мы хотим от него избавиться

features/playlists.tsx
export const Playlists = () => {
  const query = useQuery({
    queryKey: ["playlists"],
    queryFn: async () => {
      const response = await client.GET("/playlists")
      return response.data! //сейчас будем считать что у нас точно есть данные
      // и ошибка не упадёт
    },
  })
 
  console.log("status:" + query.status)
  console.log("fetchStatus:" + query.fetchStatus)
 
  if (query.isPending) return <span>Loading...</span>
  if (query.isError) return <span>{JSON.stringify(query.error.message)}</span>
 
  return (
    <div>
      if (query.isFetching) return <span>⏳</span>
      <ul>
        {query.data.data?.data.map((playlist) => (
          <li>{playlist.attributes.title}</li>
        ))}
      </ul>
    </div>
  )
}

Теперь в query.data.data? data? становится избыточной, так как мы раскрыли лишний уровень вложенности в queryFn

⚠️

Если всё же при запросе возникает ошибка TanStack будет пытаться получить данные снова и снова - retry

Этот перезапуск также можно остановить:

main.tsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 0,
      staleTime: Infinity,
      refetchOnMount: false,
      refetchOnWindowFocus: false,
      refetchOnReconnect: false,
      gcTime: 5 * 1000,
    },
  },
})

Pagination

shared/ui/pagination/pagination-nav/pagination-nav.tsx
type Props = {
  current: number
  pagesCount: number
  onChange: (page: number) => void
}
 
const SIBLING_COUNT = 1
 
export const PaginationNav = ({ current, pagesCount, onChange }: Props) => {
  const pages = getPaginationPages(current, pagesCount, SIBLING_COUNT)
 
  return (
    <div className={s.pagination}>
      {pages.map((item, idx) =>
        item === "..." ? (
          <span className={s.ellipsis} key={`ellipsis-${idx}`}>
            ...
          </span>
        ) : (
          <button
            key={item}
            className={item === current ? `${s.pageButton} ${s.pageButtonActive}` : s.pageButton}
            onClick={() => item !== current && onChange(Number(item))}
            disabled={item === current}
            type="button"
          >
            {item}
          </button>
        ),
      )}
    </div>
  )
}
shared/ui/pagination/pagination.tsx
type Props = {
  current: number
  pagesCount: number
  changePageNumber: (page: number) => void
  isFetching: boolean
}
 
export const Pagination = ({ current, pagesCount, changePageNumber, isFetching }: Props) => {
  return (
    <div className={s.container}>
      <PaginationNav current={current} pagesCount={pagesCount} onChange={changePageNumber} />{" "}
      {isFetching && "X"}
    </div>
  )
}

Используем компонент пагинации в плейлисте:

features/playlists.tsx
import { keepPreviousData } from "@tanstack/react-query"
 
export const Playlists = () => {
  const [page, setPage] = useState(1)
 
  const query = useQuery({
    queryKey: ["playlists", page],
    queryFn: async () => {
      const response = await client.GET("/playlists", {
        params: {
          query: {
            pageNumber: page,
          },
        },
      })
      if (response.error) {
        throw (response as unknown as { error: Error }).error
      }
      return response.data
    },
    placeholderData: keepPreviousData,
  })
 
  console.log("status:" + query.status)
  console.log("fetchStatus:" + query.fetchStatus)
 
  if (query.isPending) return <span>Loading...</span>
  if (query.isError) return <span>{JSON.stringify(query.error.message)}</span>
 
  return (
    <div>
      <Pagination
        pageCount={query.data.meta.pagesCount}
        currentPage={page}
        onPageNumberChange={setPage}
        isFetching={query.isFetching}
      />
      <ul>
        {query.data.data?.data.map((playlist) => (
          <li>{playlist.attributes.title}</li>
        ))}
      </ul>
    </div>
  )
}

AbortController

AbortController

AbortController в TanStack Query используется для отмены HTTP-запросов, когда они больше не нужны - паттерн кооперативной отмены

Запросы отменяются автоматически в следующих случаях:

  • Компонент размонтирован до завершения запроса
  • Новый запрос с тем же ключом начался до завершения предыдущего
  • Query инвалидирован во время выполнения
  • Компонент перерендерился с другими зависимостями
features/playlists.tsx
export const Playlists = () => {
 const [page, setPage] = useState(1)
 
 const query = useQuery({
   queryKey: ['playlists', page],
   queryFn: async ({signal}) => {
     // signal - это AbortSignal от AbortController
     const response = await client.GET('/playlists', {
      params:{
        query:{
          pageNumber: page
        }
      },
      signal
     })
     if (response.error){
       throw (response as unknown as {error:Error}).error
     }
     return response.data
   },
   placeholderData: keepPreviousData
 })

Object as queryKey

Object as queryKey - использование объектов в качестве ключей запросов в TanStack Query. TanStack Query сериализует объекты в queryKey для создания уникальных идентификаторов

features/playlists.tsx
export const Playlists = () => {
  const [page, setPage] = useState(1)
  const [search, setSearch] = useState("")
 
  const query = useQuery({
    queryKey: ["playlists", { page, search }], //при большом количестве параметров лучше объединять
    // их в объекты - это поможет избежать путаницы
    queryFn: async ({ signal }) => {
      const response = await client.GET("/playlists", {
        params: {
          query: {
            pageNumber: page,
            search,
          },
        },
        signal,
      })
      if (response.error) {
        throw (response as unknown as { error: Error }).error
      }
      return response.data
    },
    placeholderData: keepPreviousData,
  })
 
  console.log("status:" + query.status)
  console.log("fetchStatus:" + query.fetchStatus)
 
  if (query.isPending) return <span>Loading...</span>
  if (query.isError) return <span>{JSON.stringify(query.error.message)}</span>
 
  return (
    <div>
      <div>
        <input
          value={search}
          onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
          placeholder={"search..."}
        />
      </div>
      <hr />
      <Pagination
        pageCount={query.data.meta.pagesCount}
        currentPage={page}
        onPageNumberChange={setPage}
        isFetching={query.isFetching}
      />
      <ul>
        {query.data.data?.data.map((playlist) => (
          <li>{playlist.attributes.title}</li>
        ))}
      </ul>
    </div>
  )
}

OAuth2, useMutation для login

features/auth/ui/login-button.tsx
import { useMutation } from "@tanstack/react-query"
import { client } from "../../../shared/api/client.ts"
 
export const LoginButton = () => {
  const mutation = useMutation({
    mutationFn: async ({ code }: { code: string }) => {
      const response = await client.POST("/auth/login", {
        body: {
          code: code,
          redirectURI: "?????",
          rememberMe: true,
          accessTokenTTL: "1d",
        },
      })
 
      if (response.error) {
        throw new Error(response.error.message)
      }
 
      return response.data
    },
  })
  const handleLoginClick = () => {
    //...
    mutation.mutate({ code: "?????" })
  }
 
  return <button onClick={handleLoginClick}>Login with GITHUB</button>
}

Участники OAuth2 процесса:

Resource Owner (Пользователь)

  • Владелец ресурсов, который хочет авторизоваться
  • Имеет аккаунт на Authorization Server
  • Инициирует процесс входа в приложение

Authorization Server

  • Аутентифицирует пользователя
  • Выдает authorization codes и access tokens
  • Управляет разрешениями (scopes)

Resource Server / Client

Client (Веб-приложение)

  • Ваше фронтенд-приложение, которому нужны данные пользователя
  • Запрашивает разрешение на доступ к данным

OAuth flow 1

OAuth flow 2

Процесс авторизации:

1. Инициация OAuth2

Пользователь находится в приложении http://localhost:5173 и нажимает кнопку "Login"

2. Перенаправление на авторизацию

Приложение перенаправляет пользователя на Authorization Server с параметрами: https://musicfun.it-incubator.app/api/auth/oauth-redirect?callbackUrl=...

3. 301 Redirect к форме входа

Authorization Server делает редирект на собственную форму авторизации

4. Отображение формы входа

Пользователь видит форму "Sign in to your account" на сервере https://oauth.apihub.it-incubator.io/realms/apihub

5. Ввод учетных данных

Пользователь вводит Email и Password в форму APIHUB и нажимает "Sign in"

6. Валидация и создание кода

Authorization Server проверяет учетные данные и генерирует authorization code

7. 301 Redirect с кодом

Authorization Server перенаправляет обратно с кодом: https://oauth.apihub.it-incubator.io/realms/apihub/... → callback URL

8. Callback в приложение

Браузер получает URL с authorization code: http://localhost:5173/oauth/callback?code=abc123...

9. Извлечение кода

JavaScript приложение извлекает параметр code из URL

10. Обмен кода на токены

Приложение отправляет серверный запрос с кодом на Authorization Server и получает JWT токены

11. Сохранение токенов и авторизация

Приложение сохраняет refresh-jwt-token и access-jwt-token, теперь может обращаться к защищенному API https://musicfun.it-incubator.app/api

Процесс реализации

1.

features/auth/ui/login-button.tsx
export const LoginButton = () => {
 const mutation = useMutation({...})
 const handleLoginClick = () => {
    const callbackUrl = 'http://localhost:5173/oauth/callback';
    window.open(`https://musicfun.it-incubator.app/api/1.0/auth/oauth-redirect?callbackUrl=${callbackUrl}`, 'apihub-oauth2', 'width=500, height=600');
 
return <button onClick={handleLoginClick}>Login with GITHUB</button>
}
LoginButton - инициация OAuth2
  • Открывает popup окно с OAuth2 провайдером
  • Передает callbackUrl для обратного перенаправления
  • Popup размером 500x600px для удобства ввода данных
app/layouts/root-layout.tsx
import { Outlet } from "@tanstack/react-router"
import { Header } from "../shared/ui/header/header.tsx"
import styles from "./root-layout.module.css"
import { LoginButton } from "../features/auth/ui/login-button.tsx"
 
export const RootLayout = () => {
  return (
    <>
      <Header renderAccountBar={() => <LoginButton />} />
      <div className={styles.container}>
        <Outlet />
      </div>
    </>
  )
}
RootLayout - размещение кнопки входа
  • Интегрирует LoginButton в header приложения
  • Кнопка входа доступна на всех страницах через renderAccountBar
pages/auth/oauth-callback-page.tsx
export function OAuthCallbackPage() {
  // no usages
 
  useEffect(() => {
    const url = new URL(window.location.href)
    const code = url.searchParams.get("code")
 
    if (code && window.opener) {
      window.opener.postMessage({ code }, window.location.origin)
    }
 
    window.close()
  }, [])
 
  return (
    <>
      <h2>OAuth2 Callback page</h2>
    </>
  )
}
OAuthCallbackPage - обработка результата авторизации
  • Извлекает code из URL параметров после успешной авторизации
  • Отправляет код в родительское окно через postMessage
  • Автоматически закрывает popup после передачи кода

2.

features/auth/ui/login-button.tsx
export const LoginButton = () => {
 const callbackUrl = 'http://localhost:5173/oauth/callback';
 mutationFn: async ({code}: {code: string}) => {
     const response = await client.POST('/auth/login', {
       body: {
         code: code,
         redirectURI: callbackUrl,
         rememberMe: true,
         accessTokenTTL: '1d'
       }
     })
...
   }
 const handleLoginClick = () => {
   window.addEventListener('message', handleOAuthMessage)
   window.open(`https://musicfun.it-incubator.app/api/1.0/auth/oauth-redirect?callbackUrl=${callbackUrl}`, 'apihub-oauth2', 'width=500, height=600');
   const handleOAuthMessage = (event: MessageEvent) => {
     window.removeEventListener('message', handleOAuthMessage)
     if (event.origin !== document.location.origin) {
       console.warn('origin not match')
       return;
     }
 
   const code = event.data.code;
     if (!code) {
       console.warn('no code in message')
       return;
     }
 
   mutation.mutate({code})
...
}
  • Межоконная коммуникация

Добавлен механизм получения authorization code из popup окна через postMessage - теперь код передается обратно в основное окно.

  • Безопасность

Добавлена проверка origin сообщения, чтобы принимать коды только от доверенных источников.

  • Автоматический запуск авторизации

После получения кода автоматически запускается мутация для обмена кода на токены - пользователь сразу авторизуется без дополнительных действий.

Результат: Полностью рабочий OAuth2 flow через popup без перезагрузки страницы.

Middleware для добавления токена

Для автоматической отправки accessToken с каждым запросом к API Music Fun, в openapi-fetch клиент добавляется middleware, который перехватывает запросы и добавляет заголовок Authorization: Bearer <token>.

⚠️

Важно!

Не храним токены и прочие чувствительные данные в localStorage на рабочих проектах

features/auth/ui/login-button.tsx
import { useMutation } from "@tanstack/react-query"
import { client } from "../../../shared/api/client.ts"
 
export const LoginButton = () => {
  const mutation = useMutation({
    mutationFn: async ({ code }: { code: string }) => {
      const response = await client.POST("/auth/login", {
        body: {
          code: code,
          redirectURI: "?????",
          rememberMe: true,
          accessTokenTTL: "1d",
        },
      })
 
      if (response.error) {
        throw new Error(response.error.message)
      }
 
      return response.data
    },
    onSuccess: (data: { refreshToken: string; accessToken: string }) => {
      localStorage.setItem("musicfun-refresh-token", data.refreshToken)
      localStorage.setItem("musicfun-access-token", data.accessToken)
    },
  })
  const handleLoginClick = () => {
    //...
    mutation.mutate({ code: "?????" })
  }
 
  return <button onClick={handleLoginClick}>Login with GITHUB</button>
}
api/client.ts
import createClient, {type Middleware} from "openapi-fetch"
import type { paths } from "./schema"
 
const myMiddleware: Middleware = {
 async onRequest({ request, options }: MiddlewareCallbackParams) {
   const accessToken = localStorage.getItem('musicfun-access-token');
   if (accessToken) {
     request.headers.set("Authorization", "Bearer " + accessToken);
   }
 
   return request;
 }
};
 
export const client = createClient <paths>
  {
    baseUrl: "https://musicfun.it-incubator.app/api/1.0/",
    headers: {
      "api-key": "72e3121c-c679-4c0e-9131-2d3f35e6a3bd",
    },
  }
 
client.use(authMiddleware);

Авторизованный пользователь

Скроем кнопку логинизации если пользователь уже залогинен

features/auth/ui/account-bar.tsx
import { LoginButton } from "./login-button.tsx"
import { useQuery } from "@tanstack/react-query"
import { client } from "../../../shared/api/client.ts"
 
export const AccountBar = () => {
  const query = useQuery({
    queryKey: ["auth", "me"],
    queryFn: async () => {
      const clientResponse = await client.GET("/auth/me")
      return clientResponse.data
    },
  })
 
  return (
    <div>
      {!query.data && <LoginButton />}
      {/*{query.data && <CurrentUser />}*/}
    </div>
  )
}

Обработчик "ошибки" (на самом деле не ошибки, а response отличного от 200):

api/client.ts
const myMiddleware: Middleware = {
 async onRequest({ request, options }: MiddlewareCallbackParams) {...}
 onResponse({ response }: MiddlewareCallbackParams & { response: Re... }) {
 if (!response.ok) {
   throw new Error(`${response.url}: ${response.status} ${response.statusText}`)
 }
}
};
 

Заменим LoginButton на AccountBar в root-layout.tsx

app/layouts/root-layout.tsx
import { Outlet } from "@tanstack/react-router"
import { Header } from "../shared/ui/header/header.tsx"
import styles from "./root-layout.module.css"
//import { LoginButton } from "../features/auth/ui/login-button.tsx"
import { AccountBar } from "../features/auth/ui/account-bar.tsx"
 
export const RootLayout = () => {
  return (
    <>
      <Header renderAccountBar={() => <AccountBar />} />
      <div className={styles.container}>
        <Outlet />
      </div>
    </>
  )
}

CurrentUser

Для начала вынесем хук с me запросом:

features/auth/api/use-me-query.ts
import { useQuery } from "@tanstack/react-query"
import { client } from "../../../shared/api/client.ts"
 
export const useMeQuery = () => {
  const query = useQuery({
    queryKey: ["auth", "me"],
    queryFn: async () => {
      const clientResponse = await client.GET("/auth/me")
      return clientResponse.data
    },
    retry: false,
  })
 
  return query
}

И избавимся от дублирования кода в новом файле CurrentUser:

features/auth/ui/current-user/current-user.tsx
import { Link } from "@tanstack/react-router"
import styles from "./account-bar.module.css"
import { useMeQuery } from "../api/use-me-query.ts"
 
export const CurrentUser = () => {
  const query = useMeQuery()
 
  if (!query.data) return <span>...</span>
 
  return (
    <div className={styles.meInfoContainer}>
      <Link to="/my-playlists" activeOptions={{ exact: true }}>
        {query.data!.login}
      </Link>
    </div>
  )
}

И AccountBar:

features/auth/ui/account-bar.tsx
import { LoginButton } from "./login-button.tsx"
import { useMeQuery } from "../api/use-me-query.ts"
 
export const AccountBar = () => {
  const query = useMeQuery()
 
  if (query.isPending) return <></>
 
  return (
    <div>
      {!query.data && <LoginButton />}
      {query.data && <CurrentUser />}
    </div>
  )
}

Инвалидация и сброс кэша

invalidateQueries(queryKey): Помечает данные в кэше как "устаревшие", заставляя Tanstack Query перезапросить их при следующем использовании или поведении refetch. Используется после успешного логина для обновления информации о пользователе.

resetQueries(queryKey): Полностью сбрасывает данные в кэше для указанного queryKey до начального состояния, что приводит к состоянию pending при следующем запросе. Используется при логауте.

features/auth/ui/login-button.tsx
export const LoginButton = () => {
 
 const callbackUrl = 'http://localhost:5173/oauth/callback';
 
 const queryClient = useQueryClient();
 
 const mutation = useMutation({
   mutationFn: async ({code}: {code: string}) => {
     const response = await client.POST('/auth/login', {
       body: {
         code: code,
         redirectURI: callbackUrl,
         rememberMe: true,
         accessTokenTTL: '1d'
       }
     })
 
     if (response.error) {
       throw new Error(response.error.message)
     }
 
     return response.data;
   },
   onSuccess: (data: {refreshToken: string; accessToken: stri...}) => {
     localStorage.setItem('musicfun-refresh-token', data.refreshToken)
     localStorage.setItem('musicfun-access-token', data.accessToken)
     queryClient.invalidateQueries({
       queryKey: ['auth', 'me']
     })
   }
 })
}

LogOut

Вынесем логику из ui части компоненты

features/auth/api/use-login-mutation.tsx
export const callbackUrl = 'http://localhost:5173/oauth/callback';
 
export const useLoginMutation = () => {
 const queryClient = useQueryClient();
 
 const mutation = useMutation({
   mutationFn: async ({code}: {code: string}) => {
     const response = await client.POST('/auth/login', {
       body: {
         code: code,
         redirectURI: callbackUrl,
         rememberMe: true,
         accessTokenTTL: '1d'
       }
     })
 
     if (response.error) {
       throw new Error(response.error.message)
     }
 
     return response.data;
   },
   onSuccess: (data: {refreshToken: string; accessToken: stri...}) => {
     localStorage.setItem('musicfun-refresh-token', data.refreshToken)
     localStorage.setItem('musicfun-access-token', data.accessToken)
     queryClient.invalidateQueries({
       queryKey: ['auth', 'me']
     })
   }
 })
 
 return mutation
}

И файл LoginButton сильно сокращается:

features/auth/ui/login-button.tsx
import { callbackUrl, useLoginMutation } from "../api/use-login-mutation.tsx"
 
export const LoginButton = () => {
  const mutation = useLoginMutation()
 
  const handleLoginClick = () => {
    window.addEventListener("message", handleOauthMessage)
    window.open(
      `https://musicfun.it-incubator.app/api/1.0/auth/oauth-redirect?callbackUrl=${callbackUrl}`,
      "apihub-oauth2",
      "w",
    )
  }
 
  const handleOauthMessage = (event: MessageEvent) => {
    window.removeEventListener("message", handleOauthMessage)
    if (event.origin !== document.location.origin) {
      console.warn("origin not match")
      return
    }
 
    const code = event.data.code
    if (!code) {
      console.warn("no code in message")
      return
    }
 
    mutation.mutate({ code })
  }
 
  return <button onClick={handleLoginClick}>Login with APIHUB</button>
}

Логика для кнопки логаута:

features/auth/api/use-logout-mutation.tsx
export const useLogoutMutation = () => {
  const queryClient = useQueryClient()
 
  const mutation = useMutation({
    mutationFn: async () => {
      const response = await client.POST("/auth/logout", {
        body: {
          refreshToken: localStorage.getItem("musicfun-refresh-token")!,
        },
      })
      return response.data
    },
    onSuccess: () => {
      localStorage.removeItem("musicfun-refresh-token")
      localStorage.removeItem("musicfun-access-token")
      queryClient.resetQueries({
        queryKey: ["auth", "me"],
      })
    },
  })
 
  return mutation
}

Кнопка логаута:

features/auth/ui/logout-button.tsx
import { useLogoutMutation } from "../api/use-logout-mutation.tsx"
 
export const LogoutButton = () => {
  const mutation = useLogoutMutation()
 
  const handleLogoutClick = () => {
    mutation.mutate()
  }
 
  return <button onClick={handleLogoutClick}>Logout</button>
}

Используем в CurrentUser

features/auth/ui/current-user/current-user.tsx
export const CurrentUser = () => {
  const query = useMeQuery()
 
  if (!query.data) return <span>...</span>
 
  return (
    <div className={styles.meInfoContainer}>
      <Link to="/my-playlists" activeOptions={{ exact: true }}>
        {query.data!.login} <LogoutButton />
      </Link>
    </div>
  )
}

Ротация токенов (Refresh Token)

Для повышения безопасности accessToken имеет короткий срок жизни (например, 10 секунд для демонстрации). При получении ошибки 401 Unauthorized (истекший accessToken):

  1. Вызывается функция makeRefreshToken, которая отправляет refreshToken на специальный эндпоинт /refresh.
  2. Получается новая пара accessToken/refreshToken.
  3. Токены обновляются в localStorage.
  4. Оригинальный запрос повторяется с новым accessToken. Эта логика реализуется в middleware клиента, что делает процесс бесшовным для UI. Используется механизм блокировки (refreshTokenPromise) для предотвращения множественных одновременных запросов на обновление токена.

Первым делом создадим функцию, создающую refresh token:

shared/api/client.ts
// mutex
export const baseUrl = "https://musicfun.it-incubator.app/api/1.0/"
export const apiKey = "72c3121c-c679-4c0e-9131-2d3f35e6a3bd"
 
// mutex
let refreshPromise: Promise<void> | null = null
 
function makeRefreshToken() {
  if (!refreshPromise) {
    refreshPromise = (async (): Promise<void> => {
      const refreshToken = localStorage.getItem("musicfun-refresh-token")
      if (!refreshToken) throw new Error("No refresh token")
 
      const response = await fetch(baseUrl + "auth/refresh", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "API-KEY": apiKey,
        },
        body: JSON.stringify({
          refreshToken: refreshToken,
        }),
      })
    })()
 
    refreshPromise.finally(() => {
      refreshPromise = null
    })
  }
 
  return refreshPromise
}
 
const authMiddleware: Middleware = {
  onRequest({ request }: MiddlewareCallbackParams) {
    // set "foo" header
    const accessToken = localStorage.getItem("musicfun-access-token")
    if (accessToken) {
      request.headers.set("Authorization", "Bearer " + accessToken)
    }
 
    // @ts-expect-error hot fix
    request._retryRequest = request.clone()
 
    return request
  },
 
  async onResponse({ request, response }) {
    if (response.ok) return response
    if (!response.ok && response.status !== 401) {
      throw new Error(`${response.url}: ${response.status} ${response.statusText}`)
    }
 
    try {
      await makeRefreshToken()
      // @ts-expect-error ignore it
      const originalRequest: Request = request._retryRequest
      const retryRequest = new Request(originalRequest, {
        headers: new Headers(originalRequest.headers),
      })
      retryRequest.headers.set(
        "Authorization",
        "Bearer " + localStorage.getItem("musicfun-access-token"),
      )
      return fetch(retryRequest)
    } catch {
      return response
    }
  },
}

My playlists

Теперь ссылки на My Playlists и temp page нам не нужны как заглушки на стартовой странице

tsx
import { Link } from "@tanstack/react-router"
import styles from "./header.module.css"
import type { ReactNode } from "react"
 
type Props = {
  renderAccountBar: () => ReactNode
}
 
export const Header = ({ renderAccountBar }: Props) => {
  return (
    <header className={styles.header}>
      <div className={styles.container}>
        <div className={styles.linksBlock}>
          <Link to="/">Playlists</Link>
        </div>
 
        <div>{renderAccountBar()}</div>
      </div>
    </header>
  )
}

Отобразим только свои плейлисты для залогиненного пользователя:

pages/my-playlists-page.tsx
import { Playlists } from "../features/playlists.tsx"
import { useMeQuery } from "../features/auth/api/use-me-query.ts"
import { Navigate } from "@tanstack/react-router"
 
export function MyPlaylistsPage() {
  const { data, isPending } = useMeQuery()
 
  if (isPending) return <div>Loading...</div>
 
  if (!data) {
    return <Navigate to="/" replace />
  }
 
  return (
    <div>
      <h2>My Playlists</h2>
      <Playlists userId={data.userId} />
    </div>
  )
}

Не забудем передать пропсы:

features/playlists.tsx
type Props = {
  userId?: string
}
 
export const Playlists = () => {
  const [page, setPage] = useState(1)
  const [search, setSearch] = useState("")
 
  const query = useQuery({
    queryKey: ["playlists", { page, search }],
    queryFn: async ({ signal }) => {
      const response = await client.GET("/playlists", {
        params: {
          query: {
            pageNumber: page,
            search,
            userId,
          },
        },
        signal,
      })
      if (response.error) {
        throw (response as unknown as { error: Error }).error
      }
      return response.data
    },
    placeholderData: keepPreviousData,
  })
    ...
}

CRUD, Optimistic Update

useMutation используется для операций, которые изменяют данные на сервере.

  • mutationFn: Асинхронная функция, выполняющая POST/PUT/DELETE запрос.

  • onSuccess: Callback при успешном завершении мутации.

  • onError: Callback при ошибке мутации. • onSettled: Callback, срабатывающий после onSuccess или onError.

  • mutate (функция, возвращаемая useMutation): Запускает мутацию императивно.

  • mutateAsync: Асинхронная версия mutate, возвращающая промис, что позволяет использовать async/await и try/catch в компоненте.

Add

pages/my-playlists-page.tsx
export function MyPlaylistsPage() {
  ...
 
  return (
    <div>
      <h2>My Playlists</h2>
      <hr />
      <AddPlaylistForm />
      <hr />
      <Playlists userId={data.userId} />
    </div>
  )
}

AddPlaylistForm: (на react hook form)

main.tsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
      refetchOnMount: true,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      gcTime: 5 * 60 * 1000,
    },
  },
})
features/playlists/add-playlist/ui/add-playlist-form.tsx
import { useForm } from "react-hook-form"
import type { SchemaCreatePlaylistRequestPayload } from "../../../../shared/api/schema.ts"
import { useAddPlaylistMutation } from "../api/use-add-playlist-mutation.ts"
import { type JsonApiErrorDocument } from "../../../../shared/util/json-api-error.ts"
import { queryErrorHandlerForRHFFactory } from "../../../../shared/ui/util/query-error-handler-for-rhf-factory.ts"
 
export const AddPlaylistForm = () => {
  const {
    register,
    handleSubmit,
    reset,
    setError,
    formState: { errors },
  } = useForm<SchemaCreatePlaylistRequestPayload>()
 
  const { mutateAsync } = useAddPlaylistMutation()
 
  const onSubmit = async (data: SchemaCreatePlaylistRequestPayload) => {
    try {
      await mutateAsync(data)
      reset()
    } catch (error) {
      queryErrorHandlerForRHFFactory({ setError })(error as unknown as JsonApiErrorDocument)
    }
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Add New Playlist</h2>
      <p>
        <input {...register("title")} />
      </p>
      {errors.title && <p>{errors.title.message}</p>}
      <p>
        <textarea {...register("description")}></textarea>
      </p>
      {errors.description && <p>{errors.description.message}</p>}
 
      <button type={"submit"}>Create</button>
      {errors.root?.server && <p>{errors.root?.server.message}</p>}
    </form>
  )
}
features/playlists/add-playlist/api/use-add-playlist-mutation.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"
import type { SchemaCreatePlaylistRequestPayload } from "../../../../shared/api/schema.ts"
import { client } from "../../../../shared/api/client.ts"
import { playlistsKeys } from "../../../../shared/api/keys-factories/playlists-keys-factory.ts"
 
export const useAddPlaylistMutation = () => {
  const queryClient = useQueryClient()
 
  return useMutation({
    mutationFn: async (data: SchemaCreatePlaylistRequestPayload) => {
      const response = await client.POST("/playlists", {
        body: data,
      })
      return response.data
    },
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: playlistsKeys.lists(),
        refetchType: "all",
      })
    },
    meta: { globalErrorHandler: "on" },
  })
}

refetchType в TanStack Query определяет, какие queries должны быть обновлены при использовании методов refetchQueries() или invalidateQueries().

Возможные значения:

  • 'active' (по умолчанию): Обновляет только активные queries (которые имеют подписчиков/observers):

  • 'inactive': Обновляет только неактивные queries (без подписчиков, но еще в кэше):

  • 'all': Обновляет все queries независимо от статуса:

Delete

features/playlists/add-playlist/ui/delete-playlist.tsx
import { useDeleteMutation } from "../api/use-delete-mutation.ts"
 
type Props = {
  playlistId: string
  onDeleted: (playlistId: string) => void
}
 
export const DeletePlaylist = ({ playlistId, onDeleted }: Props) => {
  const { mutate } = useDeleteMutation()
 
  const handleDeleteClick = () => {
    mutate(playlistId)
    onDeleted?.(playlistId)
  }
 
  return <button onClick={handleDeleteClick}>🗑️</button>
}
features/playlists/add-playlist/api/use-delete-mutation.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"
import type { SchemaGetPlaylistsOutput } from "../../../../shared/api/schema.ts"
import { client } from "../../../../shared/api/client.ts"
import { playlistsKeys } from "../../../../shared/api/keys-factories/playlists-keys-factory.ts"
 
export const useDeleteMutation = () => {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async (playlistId: string) => {
      const response = await client.DELETE("/playlists/{playlistId}", {
        params: { path: { playlistId } },
      })
      return response.data
    },
    onSuccess: (_, playlistId) => {
      queryClient.setQueriesData(
        { queryKey: playlistsKeys.lists() },
        (oldData: SchemaGetPlaylistsOutput) => {
          return {
            ...oldData,
            data: oldData.data.filter((p) => p.id !== playlistId),
          }
        },
      )
      queryClient.removeQueries({ queryKey: playlistsKeys.detail(playlistId) })
    },
  })
}

Напоминаем, что мы перенесли плейлисты в папку wigets

src/wigets/playlists/ui/playlists.tsx
export const Playlists = () => {
  ...
  return (
    <div>
      <div>
        <input
          value={search}
          onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
          placeholder={"search..."}
        />
      </div>
      <hr />
      <Pagination
        pageCount={query.data.meta.pagesCount}
        currentPage={page}
        onPageNumberChange={setPage}
        isFetching={query.isFetching}
      />
      <ul>
        {query.data..data.map((playlist) => (
          <li key={playlist.id}>
            {playlist.attributes.title} <DeletePlaylist playlistId={playlist.id}
          </li>
        ))}
      </ul>
    </div>
  )
}

setQueriesData в TanStack Query позволяет программно устанавливать данные для нескольких queries одновременно.

javascript
//Основной синтаксис:
queryClient.setQueriesData(
    filters,           // фильтры для поиска queries
    updaterOrValue,    // новые данные или функция обновления
    options?           // опциональные настройки
)

Полезен для массовых обновлений кэша, когда одна операция влияет на множество связанных queries.

Edit

pages/my-playlists-page.tsx
export function MyPlaylistsPage() {
  const [ editingPlaylistId, setEditingPlaylistId ] = useState<string|null>(null)
 
  ...
 
  return (
    <div>
      <h2>My Playlists</h2>
      <hr />
      <AddPlaylistForm />
      <hr />
      <Playlists userId={data.userId} onPlaylistSelected={setEditingPlaylistId}/>
      <hr />
      <EditPlaylistForm playlistId={editingPlaylistId}/>
    </div>
  )
}
src/wigets/playlists/ui/playlists.tsx
type Props = {
  userId?: string
  onPlaylistSelected?: (playlistId: string) => void
  }
 
export const Playlists = () => {
    ...
  const handleSelectPlaylistClick = (playlistId: string) => {
   onPlaylistSelected?.(playlistId);
  }
 
  return (
    <div>
      <div>
        <input
          value={search}
          onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
          placeholder={"search..."}
        />
      </div>
      <hr />
      <Pagination
        pageCount={query.data.meta.pagesCount}
        currentPage={page}
        onPageNumberChange={setPage}
        isFetching={query.isFetching}
      />
      <ul>
        {query.data..data.map((playlist) => (
          <li key={playlist.id} onClick={handleSelectPlaylistClick}>
            {playlist.attributes.title} <DeletePlaylist playlistId={playlist.id}
          </li>
        ))}
      </ul>
    </div>
  )
}
features/playlists/add-playlist/ui/edit-playlist-form.tsx
import { useForm } from "react-hook-form"
import type { SchemaUpdatePlaylistRequestPayload } from "../../../../shared/api/schema.ts"
import { useEffect } from "react"
import { usePlaylistQuery } from "../api/use-playlist-query.tsx"
import { useUpdatePlaylistMutation } from "../api/use-update-playlist-mutation.ts"
import { queryErrorHandlerForRHFFactory } from "../../../../shared/ui/util/query-error-handler-for-rhf-factory.ts"
 
type Props = {
  playlistId: string | null
  onCancelEditing: () => void
}
 
export const EditPlaylistForm = ({ playlistId, onCancelEditing }: Props) => {
  const {
    register,
    handleSubmit,
    reset,
    setError,
    formState: { errors },
  } = useForm<SchemaUpdatePlaylistRequestPayload>()
 
  useEffect(() => {
    reset()
  }, [playlistId])
 
  const { data, isPending, isError } = usePlaylistQuery(playlistId)
 
  const { mutate } = useUpdatePlaylistMutation({
    onSuccess: () => {
      onCancelEditing()
    },
    onError: queryErrorHandlerForRHFFactory({ setError }),
  })
 
  const onSubmit = (data: SchemaUpdatePlaylistRequestPayload) => {
    mutate({ ...data, playlistId: playlistId! })
  }
 
  const handleCancelEditingClick = () => {
    onCancelEditing()
  }
 
  if (!playlistId) return <></>
  if (isPending) return <p>Loading...</p>
  if (isError) return <p>Error...</p>
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Edit Playlist</h2>
      <p>
        <input {...register("title")} defaultValue={data.data.attributes.title} />
      </p>
      {errors.title && <p>{errors.title.message}</p>}
      <p>
        <textarea
          {...register("description")}
          defaultValue={data.data.attributes.description!}
        ></textarea>
      </p>
      {errors.description && <p>{errors.description.message}</p>}
      <button type={"submit"}>Save</button>
      <button type={"reset"} onClick={handleCancelEditingClick}>
        Cancel
      </button>
      {errors.root?.server && <p>{errors.root?.server.message}</p>}
    </form>
  )
}
features/playlists/add-playlist/api/use-update-playlist-mutation.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"
import type {
  SchemaGetPlaylistsOutput,
  SchemaUpdatePlaylistRequestPayload,
} from "../../../../shared/api/schema.ts"
import { client } from "../../../../shared/api/client.ts"
import { playlistsKeys } from "../../../../shared/api/keys-factories/playlists-keys-factory.ts"
import type { JsonApiErrorDocument } from "../../../../shared/util/json-api-error.ts"
 
type MutationVariables = SchemaUpdatePlaylistRequestPayload & { playlistId: string }
 
export const useUpdatePlaylistMutation = ({
  onSuccess,
  onError,
}: {
  onSuccess?: () => void
  onError?: (error: JsonApiErrorDocument) => void
}) => {
  const queryClient = useQueryClient()
 
  const key = playlistsKeys.myList()
 
  return useMutation({
    mutationFn: async (variables: MutationVariables) => {
      const { playlistId, ...rest } = variables
      const response = await client.PUT("/playlists/{playlistId}", {
        params: { path: { playlistId: playlistId } },
        body: { ...rest, tagIds: [] },
      })
      return response.data
    },
    onMutate: async (variables: MutationVariables) => {
      // Cancel any outgoing refetches
      // (so they don't overwrite our optimistic update)
      await queryClient.cancelQueries({ queryKey: playlistsKeys.all })
      // Snapshot the previous value
      const previousMyPlaylists = queryClient.getQueryData(key)
      // Optimistically update to the new value
      queryClient.setQueryData(key, (oldData: SchemaGetPlaylistsOutput) => {
        return {
          ...oldData,
          data: oldData.data.map((p) => {
            if (p.id === variables.playlistId)
              return {
                ...p,
                attributes: {
                  ...p.attributes,
                  description: variables.description,
                  title: variables.title,
                },
              }
            else return p
          }),
        }
      })
 
      // Return a context with the previous and new todo
      return { previousMyPlaylists }
    },
    // If the mutation fails, use the context we returned above
    onError: (error, __: MutationVariables, context) => {
      queryClient.setQueryData(key, context!.previousMyPlaylists)
      onError?.(error as unknown as JsonApiErrorDocument)
    },
    onSuccess: () => {
      onSuccess?.()
    },
    // Always refetch after error or success:
    onSettled: (_, __, variables: MutationVariables) => {
      queryClient.invalidateQueries({
        queryKey: playlistsKeys.lists(),
        refetchType: "all",
      })
      queryClient.invalidateQueries({
        queryKey: playlistsKeys.detail(variables.playlistId),
        refetchType: "all",
      })
    },
  })
}

Optimistic Update

Предварительное обновление UI, предполагая, что мутация на сервере пройдет успешно. Если произойдет ошибка, изменения откатываются.

  • onMutate: Callback, который срабатывает перед выполнением mutationFn.

Используется для:

  1. Отмены текущих запросов (queryClient.cancelQueries): Чтобы избежать перезаписи оптимистичных изменений.
  2. Сохранения "снимка" предыдущих данных (queryClient.getQueryData): Для отката в случае ошибки. Этот снимок передается в context onError и onSettled.
  3. Оптимистичного обновления кэша (queryClient.setQueryData): Изменяем данные в кэше, чтобы UI немедленно отобразил результат.
  • onError: Callback для обработки ошибок. Используется для отката оптимистичных изменений, восстанавливая сохраненный снимок данных.
  • onSettled: Callback, срабатывающий независимо от успеха/ошибки. Используется для инвалидации кэша, чтобы получить актуальные данные с сервера.
src/wigets/playlists/ui/playlists.tsx
type Props = {
  userId?: string
  onPlaylistSelected?: (playlistId: string) => void
  isSearchActive?: boolean
  }
 
export const Playlists = ({userId, onPlaylistSelected, isSearchActive}: Props) => {
  const key = userId ? ['playlists', 'my', userId] : ['playlists', {search, page}]
 const queryParams = userId ? {
   userId
 } : {
   pageNumber:
     page,
     search
 }
 
 const query = useQuery({
   queryKey: key,
   queryFn: async ({signal}: {client: QueryClient; queryKey: (string...}) => {
     const response = await client.GET('/playlists', {
       params: {
         query: queryParams
       },
       signal
     })
   }
 })
 
    ...
 
  return (
    <div>
        {isSearchActive && <>
          <div><input
            value={search}
            onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
            placeholder={"search..."}
          /></div>
          <hr />
        </>}
      <Pagination
        pageCount={query.data.meta.pagesCount}
        currentPage={page}
        onPageNumberChange={setPage}
        isFetching={query.isFetching}
      />
      <ul>
        {query.data..data.map((playlist) => (
          <li key={playlist.id} onClick={handleSelectPlaylistClick}>
            {playlist.attributes.title} <DeletePlaylist playlistId={playlist.id}
          </li>
        ))}
      </ul>
    </div>
  )
}
src/pages/playlists-page.tsx
import { Playlists } from "../widgets/playlists/ui/playlists.tsx"
 
export function PlaylistsPage() {
  return (
    <div>
      <h2>hello it-incubator!!!</h2>
      <Playlists isSearchActive={true} />
    </div>
  )
}

Key Factory

Key Factory - паттерн организации query keys в TanStack Query для создания консистентных и типизированных ключей.

Преимущества

  • Консистентность - единообразные ключи во всем приложении
  • Типизация - автокомплит и проверка типов
  • Рефакторинг - легко изменить структуру ключей
  • Инвалидация - простое управление кэшем
  • Масштабируемость - четкая иерархия для больших приложений

Key Factory особенно важен в больших приложениях, где много связанных queries и сложная логика инвалидации кэша.

shared/api/keys-factories/playlists-keys-factories.ts
import type { SchemaGetPlaylistsRequestPayload } from "../schema.ts"
 
export const playlistsKeys = {
  all: ["playlists"],
  lists: () => [...playlistsKeys.all, "lists"],
  myList: () => [...playlistsKeys.lists(), "my"],
  list: (filters: Partial<SchemaGetPlaylistsRequestPayload>) => [...playlistsKeys.lists(), filters],
  details: () => [...playlistsKeys.all, "details"],
  detail: (id: string) => [...playlistsKeys.details(), id],
}
shared/api/keys-factories/auth-keys-factories.ts
export const authKeys = {
  all: ["auth"],
  me: () => [...authKeys.all, "me"],
}

queryOptions

queryOptions - функция в TanStack Query v5 для создания переиспользуемых конфигураций query с полной типизацией.

Основное назначение - создание типизированных опций query, которые можно переиспользовать в useQuery, useSuspenseQuery, queryClient.fetchQuery и других методах.

Преимущества

  • Типизация - полная поддержка TypeScript
  • Переиспользование - одни опции для разных методов
  • Централизация - вся конфигурация query в одном месте
  • Консистентность - одинаковые настройки везде
  • Рефакторинг - легко изменить поведение всех связанных queries

queryOptions особенно полезны в больших приложениях для создания чистой архитектуры работы с данными.

features/playlists/add-playlist/api/use-playlist-query.tsx
import { useQuery } from "@tanstack/react-query"
import { client } from "../../../../shared/api/client.ts"
 
export const usePlaylistQuery = (playlistId: string | null) => {
  return useQuery({
    queryKey: ["playlists", "details", playlistId],
    queryFn: async () => {
      const response = await client.GET("/playlists/{playlistId}", {
        params: { path: { playlistId: playlistId! } },
      })
      return response.data!
    },
    enabled: !!playlistId,
  })
}
src/wigets/playlists/ui/playlists.tsx
import { Pagination } from "../../../shared/ui/pagination/pagination.tsx"
import { useState } from "react"
import { DeletePlaylist } from "../../../features/playlists/delete-playlist/ui/delete-playlist.tsx"
import { usePlaylistsQuery } from "../api/use-playlists-query.ts"
 
type Props = {
  userId?: string
  onPlaylistSelected?: (playlistId: string) => void
  onPlaylistDeleted?: (playlistId: string) => void
  isSearchActive?: boolean
}
 
export const Playlists = ({
  userId,
  onPlaylistSelected,
  onPlaylistDeleted,
  isSearchActive,
}: Props) => {
  const [pageNumber, setPageNumber] = useState(1)
  const [search, setSearch] = useState("")
 
  const query = usePlaylistsQuery(userId, { search, pageNumber })
 
  const handleSelectPlaylistClick = (playlistId: string) => {
    onPlaylistSelected?.(playlistId)
  }
 
  const handleDeletePlaylist = (playlistId: string) => {
    onPlaylistDeleted?.(playlistId)
  }
 
  if (query.isPending) return <span>Loading...</span>
  if (query.isError) return <span>Error: {JSON.stringify(query.error.message)}</span>
 
  return (
    <div>
      {isSearchActive && (
        <>
          <div>
            <input
              value={search}
              onChange={(e) => setSearch(e.currentTarget.value)}
              placeholder={"search..."}
            />
          </div>
          <hr />
        </>
      )}
 
      <Pagination
        pagesCount={query.data.meta.pagesCount}
        currentPage={pageNumber}
        onPageNumberChange={setPageNumber}
        isFetching={query.isFetching}
      />
      <ul>
        {query.data.data.map((playlist) => (
          <li key={playlist.id}>
            <span onClick={() => handleSelectPlaylistClick(playlist.id)}>
              {playlist.attributes.title}
            </span>{" "}
            <DeletePlaylist playlistId={playlist.id} onDeleted={handleDeletePlaylist} />
          </li>
        ))}
      </ul>
    </div>
  )
}

Config / env-vars / LocalStorage keys

Вынесем переменные (найдите и замените их в коде самостоятельно - потренируйте поиск по проекту):

.env
VITE_BASE_URL=https://musicfun.it-incubator.app/api/1.0/
.env.local
VITE_API_KEY='72c3121c-c679-4c0e-9131-2d3f35e6a3bd"
src/shared/config/api-config.ts
export const baseUrl = import.meta.env.VITE_BASE_URL
export const apiKey = import.meta.env.VITE_API_KEY
src/shared/config/localstorage-keys.ts
export const localStorageKeys = {
  refreshToken: "musicfun-refresh-token",
  accessToken: "musicfun-access-token",
}

Продолжаем refactoring

src/pages/my-playlists-page.tsx
import { Playlists } from "../widgets/playlists/ui/playlists.tsx"
import { useMeQuery } from "../features/auth/api/use-me-query.ts"
import { Navigate } from "@tanstack/react-router"
import { AddPlaylistForm } from "../features/playlists/add-playlist/ui/add-playlist-form.tsx"
import { EditPlaylistForm } from "../features/playlists/edit-playlist/ui/edit-playlist-form.tsx"
import { useState } from "react"
 
export function MyPlaylistsPage() {
  const { data, isPending } = useMeQuery()
  const [editingPlaylistId, setEditingPlaylistId] = useState<string | null>(null)
 
  const handlePlaylistDelete = (playlistId: string) => {
    if (playlistId === editingPlaylistId) {
      setEditingPlaylistId(null)
    }
  }
 
  if (isPending) return <div>Loading...</div>
 
  if (!data) {
    return <Navigate to="/" replace />
  }
 
  return (
    <div>
      <h2> My Playlists </h2>
      <hr />
      <AddPlaylistForm />
      <hr />
      <Playlists
        userId={data.userId}
        onPlaylistSelected={(playlistId) => setEditingPlaylistId(playlistId)}
        onPlaylistDeleted={handlePlaylistDelete}
      />
      <hr />
      <EditPlaylistForm
        playlistId={editingPlaylistId}
        onCancelEditing={() => setEditingPlaylistId(null)}
      />
    </div>
  )
}

Server error format

src/shared/util/json-api-error.ts
export interface JsonApiError {
  status: string
  code?: string | number
  title?: string
  detail?: string
  source?: { pointer?: string; parameter?: string }
  meta?: Record<string, unknown>
}
 
export interface JsonApiErrorDocument {
  errors: JsonApiError[]
  meta?: Record<string, unknown>
}
 
export type ExtractError<T> = T extends { error?: infer E } ? E : unknown
 
/* --- типы ошибок, совпадающие с фильтром -------------------------------- */
export interface JsonApiError {
  status: string
  code?: string | number
  title?: string
  detail?: string
  source?: { pointer?: string; parameter?: string }
  meta?: Record<string, unknown>
}
 
export interface JsonApiErrorDocument {
  errors: JsonApiError[]
  meta?: Record<string, unknown>
}
 
export function isJsonApiErrorDocument(error: unknown): error is JsonApiErrorDocument {
  return (
    typeof error === "object" &&
    error !== null &&
    // @ts-expect-error type no matter
    Array.isArray(error.errors)
  )
}
 
export function parseJsonApiErrors(errorDoc: JsonApiErrorDocument): {
  fieldErrors: Record<string, string>
  globalErrors: string[]
} {
  const fieldErrors: Record<string, string> = {}
  const globalErrors: string[] = []
 
  for (const err of errorDoc.errors) {
    const msg = err.detail ?? err.title ?? "Unknown error"
    const ptr = err.source?.pointer
    if (ptr) {
      // убираем префикс JSON:API
      const field = ptr.replace(/^\/data\/attributes\//, "")
      fieldErrors[field] = msg
    } else {
      globalErrors.push(msg)
    }
  }
 
  return { fieldErrors, globalErrors }
}

Global error handling, mutationCache, meta

src/shared/ui/util/query-error-handler-for-rhf-factory.ts
import type { FieldValues, Path, UseFormSetError } from "react-hook-form"
import {
  isJsonApiErrorDocument,
  type JsonApiErrorDocument,
  parseJsonApiErrors,
} from "../../util/json-api-error.ts"
import { toast } from "react-toastify"
 
export const queryErrorHandlerForRHFFactory = <T extends FieldValues>({
  setError,
}: {
  setError?: UseFormSetError<T>
}) => {
  return (err: JsonApiErrorDocument) => {
    // 400 от сервера в JSON:API формате
    if (isJsonApiErrorDocument(err)) {
      const { fieldErrors, globalErrors } = parseJsonApiErrors(err)
 
      // полевые ошибки
      for (const [field, message] of Object.entries(fieldErrors)) {
        setError?.(field as Path<T>, { type: "server", message })
      }
 
      // «глобальные» (без pointer)
      if (globalErrors.length > 0) {
        setError?.("root.server", {
          type: "server",
          message: globalErrors.join("\n"),
        })
      }
    }
  }
}
 
export const mutationGlobalErrorHandler = (
  error: Error,
  _: unknown,
  __: unknown,
  mutation: unknown,
) => {
  // @ts-expect-error look at MutationMeta type
  if (mutation.meta.globalErrorHandler === "off") {
    return
  }
 
  if (isJsonApiErrorDocument(error)) {
    const { globalErrors } = parseJsonApiErrors(error)
 
    // «глобальные» (без pointer)
    if (globalErrors.length > 0) {
      toast(globalErrors.join("\n"))
    }
  }
}

react-toastify

Популярная библиотека для отображения уведомлений (toast notifications) в React приложениях.

bash
npm install react-toastify
src/app/layouts/root-layout.tsx
import { Outlet } from "@tanstack/react-router"
import { Header } from "../../shared/ui/header/header.tsx"
import styles from "./root-layout.module.css"
import { AccountBar } from "../../features/auth/ui/account-bar.tsx"
import { ToastContainer } from "react-toastify"
import "react-toastify/dist/ReactToastify.css"
 
export const RootLayout = () => (
  <>
    <Header renderAccountBar={() => <AccountBar />} />
    <div className={styles.container}>
      <Outlet />
      <ToastContainer />
    </div>
  </>
)

queryClientInstance

src/app/entrypoint/main.tsx
import { createRoot } from "react-dom/client"
import "../styles/reset.css"
import "../styles/index.css"
import { QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { RouterProvider } from "@tanstack/react-router"
import { queryClientInstance } from "../tanstack-query/query-client-instance.tsx"
import { routerInstance } from "../tanstack-router/router-instance.tsx"
 
createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClientInstance}>
    <RouterProvider router={routerInstance} />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>,
)
src/app/tanstack-query/query-client-instance.tsx
import { MutationCache, QueryClient } from "@tanstack/react-query"
import { mutationGlobalErrorHandler } from "../../shared/ui/util/query-error-handler-for-rhf-factory.ts"
 
export type MutationMeta = {
  /**
   * Если 'off' — глобальный обработчик ошибок пропускаем,
   * если 'on' (или нет поля) — вызываем.
   */
  globalErrorHandler?: "on" | "off"
}
declare module "@tanstack/react-query" {
  interface Register {
    /**
     * Тип для поля `meta` в useMutation(...)
     */
    mutationMeta: MutationMeta
  }
}
export const queryClientInstance = new QueryClient({
  mutationCache: new MutationCache({
    onError: mutationGlobalErrorHandler,
  }),
  defaultOptions: {
    queries: {
      staleTime: Infinity,
      refetchOnMount: true,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      gcTime: 5 * 60 * 1000,
    },
  },
})
src/app/tanstack-router/router-instance.tsx
import { createRouter } from "@tanstack/react-router"
import { routeTree } from "../routes/routeTree.gen.ts"
 
export const routerInstance = createRouter({ routeTree })
// Register the router instance for type safety
declare module "@tanstack/react-router" {
  interface Register {
    router: typeof routerInstance
  }
}

Musicfun: React + Tanstack Query + FSD + OAuth2

Видеоурок - 1 видео из 1