React Middle курс: Tanstack Query, OAuth2, openapi typescript, musicfun
Полезные ссылки
Введение и архитектурные принципы
Separation of Concerns (Разделение ответственностей)
Один из главных архитектурных принципов
На его основе выделяются слои (layers/tiers) приложения
Каждый слой отвечает за свою область ответственности
Основные слои архитектуры
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 (Запросы)
Mutation (Мутации)
Для изменения данных (POST, PUT, DELETE)
Запускаются императивно
Могут инвалидировать кэш после выполнения
Старт и настройка проекта
Для старта проекта мы будем использовать Vite
После выбора соответствующих настроек и запуска проекта установим инструмент для асинхронного
стейт менеджмента -
TanStack Query
Необходимые пакеты:
# Основные
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:
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
Первый запрос к API
Для работы с MusicFun API необходимо зарегистрироваться. Там же
вы найдёте ссылку на документацию в Swagger
Сделаем пробный запрос
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 в качестве скрипта
"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
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" ,
} ,
})
Воспользуемся клиентом для запроса за плейлистами
function App () {
useEffect (() => {
;( async function () {
const response = await client .GET ( "/playlists" )
const data = response .data
console .log (data)
})()
} , [])
}
Что мы получили от сгенерированной схемы и написанного клиента:
минимизирована возможность ошибки в параметре запроса
есть строгая типизацию приходящих данных
не думаем про непосредственно документацию
useQuery
В дальнейшем мы избавимся от useEffect, но не потому что не будем его использовать, а потому что он
будет спрятан внутри хуков которые нам предоставляет TanStack Query
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 > ,
)
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" )
} ,
})
}
Теперь попробуем отобразить приходящие данные
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
(когда данные будут устаревать)
const Playlists = () => {
const query = useQuery ({
staleTime : 10000 ,
queryKey : [ "playlists" ] ,
queryFn : () => {
client .GET ( "./playlists" )
} ,
})
}
Перезапрос происходит через 10 секунд
gcTime
gcTime (Garbage Collection Time) в TanStack Query - это время, через которое неиспользуемые
данные удаляются из кэша.
gcTime срабатывает только когда:
Нет активных компонентов, использующих эти данные
Все наблюдатели (observers) отписались от query
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 >
)
}
ReactQueryDevtools - это
инструмент разработчика для отладки и мониторинга React Query.
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" - всегда рефетчить при переподключении, даже если данные свежие
Это особенно полезно для мобильных приложений, где потеря соединения - частое явление.
Все эти значения удобнее всего хранить в клиенте
const queryClient = new QueryClient ({
defaultOptions : {
queries : {
staleTime : Infinity ,
refetchOnMount : false ,
refetchOnWindowFocus : false ,
refetchOnReconnect : false ,
gcTime : 5 * 1000 ,
} ,
} ,
})
defaultOptions - это глобальные настройки по умолчанию для всех queries и mutations в TanStack
Query. Позволяет настроить поведение всех запросов в приложении одним местом, избегая дублирования
настроек.
Styles
/* 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 : 100 vh ;
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 : 10 em ;
}
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block : 5 ex ;
}
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 мы установили ранее)
pnpm add -D @tanstack/router-plugin
Настроим плагин:
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
import { createRootRoute , Outlet } from "@tanstack/react-router"
export const Route = createRootRoute ({
component : () => (
<>
< Outlet />
</>
) ,
})
import { createFileRoute } from "@tanstack/react-router"
import App from "../App.tsx"
export const Route = createFileRoute ( "/" )({
component : App ,
})
На основе этих двух файлов автоматически генерируется routeTree.gen.ts, и нам остаётся подключить
всё это в точке входа приложения
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 />
</>
)
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
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
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 ,
})
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 в самостоятельный файл
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 - корректное отображение "крутилок"
О статусах можно почитать в разделе
Queries
status - основное состояние запроса
Возможные значения:
'pending' - запрос выполняется впервые (нет кэшированных данных)
'error' - запрос завершился с ошибкой
'success' - запрос успешно завершен и есть данные
fetchStatus - состояние фетчинга
Возможные значения:
'fetching' - запрос активно выполняется
'paused' - запрос приостановлен (нет сети)
'idle' - запрос не выполняется
Ключевая разница: status отражает наличие данных, fetchStatus - активность сетевых запросов.
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
Проверка на наличие данных возвращаемых запросом:
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 возвращает нам объект у которого есть Data и response Responce является
сложным объектом и мы хотим от него избавиться
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
Этот перезапуск также можно остановить:
const queryClient = new QueryClient ({
defaultOptions : {
queries : {
retry : 0 ,
staleTime : Infinity ,
refetchOnMount : false ,
refetchOnWindowFocus : false ,
refetchOnReconnect : false ,
gcTime : 5 * 1000 ,
} ,
} ,
})
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 >
)
}
Используем компонент пагинации в плейлисте:
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 в TanStack Query используется для отмены HTTP-запросов, когда они больше не
нужны - паттерн кооперативной отмены
Запросы отменяются автоматически в следующих случаях:
Компонент размонтирован до завершения запроса
Новый запрос с тем же ключом начался до завершения предыдущего
Query инвалидирован во время выполнения
Компонент перерендерился с другими зависимостями
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 для создания уникальных идентификаторов
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 (Веб-приложение)
Ваше фронтенд-приложение, которому нужны данные пользователя
Запрашивает разрешение на доступ к данным
Процесс авторизации:
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 >
}
Открывает 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 >
}
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):
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):
Вызывается функция makeRefreshToken, которая отправляет refreshToken на специальный эндпоинт
/refresh.
Получается новая пара accessToken/refreshToken.
Токены обновляются в localStorage.
Оригинальный запрос повторяется с новым accessToken. Эта логика реализуется в middleware клиента,
что делает процесс бесшовным для UI. Используется механизм блокировки (refreshTokenPromise) для
предотвращения множественных одновременных запросов на обновление токена.
Первым делом создадим функцию, создающую refresh token:
// 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 нам не нужны как заглушки на стартовой странице
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 >
)
}
Не забудем передать пропсы:
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)
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
одновременно.
//Основной синтаксис:
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.
Используется для:
Отмены текущих запросов (queryClient.cancelQueries): Чтобы избежать перезаписи оптимистичных
изменений.
Сохранения "снимка" предыдущих данных (queryClient.getQueryData): Для отката в случае ошибки.
Этот снимок передается в context onError и onSettled.
Оптимистичного обновления кэша (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
Вынесем переменные (найдите и замените их в коде самостоятельно - потренируйте поиск по проекту):
VITE_BASE_URL=https://musicfun.it-incubator.app/api/1.0/
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 >
)
}
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 }
}
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 приложениях.
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
}
}