🔶 1. Настройка приложения
Чтобы сосредоточиться на изучении RTK, сделаем базовые настройки для приложения
Настройка импортов / алиасов
Теория
В файле tsconfig.json есть параметр baseUrl для указания корневой директории, относительно
которой TypeScript будет разрешать пути к модулям.
Преимущества использования baseUrl:
использование абсолютных путей , вместо относительных;
перемещение файлов и папок упрощается благодаря стабильности путей к модулям ;
читаемый и понятный код , потому что пути к модулям явно указывают на их место в проекте.
Практика
Откройте файл tsconfig.app.json и добавьте в compilerOptions свойства baseUrl и paths:
{
"compilerOptions" : {
"baseUrl" : "." ,
"paths" : {
"@/*" : [ "src/*" ]
}
/*...*/
} ,
"include" : [ "src" ]
}
Откройте файл vite.config.ts и добавьте в конфигурацию свойство resolve с полем alias :
import path from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig ({
plugins : [ react ()] ,
resolve : {
alias : {
'@/' : ` ${ path .resolve (__dirname , 'src' ) } /` ,
} ,
} ,
})
Чтобы разрешить импорт path и переменной __dirname добавьте в devDependencies проекта типизацию node:
Роутинг / Структура приложения
Настроим базовый роутинг в проекте.
Установите библиотеку для работы с роутингом React Router :
❗ Чтобы можно было работать с роутингом, обернём всё приложение в BrowserRouter в файле main.tsx
Давайте сразу заложим базовый роутинг и создадим страницы, которые понадобятся в процессе разработки приложения и
добавим их в роутинг:
главная страница (MainPage.tsx),
страница с плейлистами (PlaylistsPage.tsx),
страница с треками (TracksPage.tsx),
страница профиля (ProfilePage.tsx),
страницу 404 (PageNotFound.tsx)
⚠️
В качестве структуры папок используем рекомендации, которые предлагает RTK
export const MainPage = () => {
return (
< div >
< h1 >Main page</ h1 >
</ div >
)
}
src/features/playlists/ui/PlaylistsPage/PlaylistsPage.tsx
export const PlaylistsPage = () => {
return (
< div >
< h1 >Playlists page</ h1 >
</ div >
)
}
src/features/tracks/ui/TracksPage/TracksPage.tsx
export const TracksPage = () => {
return (
< div >
< h1 >Tracks page</ h1 >
</ div >
)
}
src/features/auth/ui/ProfilePage/ProfilePage.tsx
export const ProfilePage = () => {
return (
< div >
< h1 >Profile page</ h1 >
</ div >
)
}
src/common/components/PageNotFound/PageNotFound.tsx
import s from './PageNotFound.module.css'
export const PageNotFound = () => {
return (
<>
< h1 className = { s .title}>404</ h1 >
< h2 className = { s .subtitle}>page not found</ h2 >
</>
)
}
src/common/components/PageNotFound/PageNotFound.module.css
.title {
text-align : center ;
font-size : 250 px ;
margin : 0 ;
}
.subtitle {
text-align : center ;
font-size : 50 px ;
margin : 0 ;
text-transform : uppercase ;
}
src/common/routing/Routing.tsx
export const Path = {
Main : '/' ,
Playlists : '/playlists' ,
Tracks : '/tracks' ,
Profile : '/profile' ,
NotFound : '*' ,
} as const
export const Routing = () => (
< Routes >
< Route path = { Path .Main} element = {< MainPage />} />
< Route path = { Path .Playlists} element = {< PlaylistsPage />} />
< Route path = { Path .Tracks} element = {< TracksPage />} />
< Route path = { Path .Profile} element = {< ProfilePage />} />
< Route path = { Path .NotFound} element = {< PageNotFound />} />
</ Routes >
)
И добавим Routing.tsx в App.tsx
export const App = () => {
return (
<>
< Routing />
</>
)
}
Результат: При изменении адреса в URL будет отображаться соответствующая страница 🚀
Реализуем Header для того, чтобы осуществлять навигацию по приложению
src/common/components/Header/Header.tsx
import { NavLink } from 'react-router'
import { Path } from '@/common/routing/Routing'
import s from './Header.module.css'
const navItems = [
{ to : Path .Main , label : 'Main' } ,
{ to : Path .Playlists , label : 'Playlists' } ,
{ to : Path .Tracks , label : 'Tracks' } ,
{ to : Path .Profile , label : 'Profile' } ,
]
export const Header = () => {
return (
< header className = { s .container}>
< nav >
< ul className = { s .list}>
{ navItems .map (item => (
< li key = { item .to}>
< NavLink
to = { item .to}
className = {({ isActive }) => `link ${ isActive ? s .activeLink : '' } ` }
>
{ item .label}
</ NavLink >
</ li >
))}
</ ul >
</ nav >
</ header >
)
}
src/common/components/Header/Header.module.css
.container {
border-bottom : 1 px solid black ;
padding-left : 100 px ;
}
.list {
display : flex ;
gap : 40 px ;
}
.activeLink {
font-weight : bold ;
}
Добавим Header.tsx в App.tsx
export const App = () => {
return (
<>
< Header />
< Routing />
</>
)
}
Результат: Теперь можем удобно осуществлять навигацию по нашему приложению 🚀
Layout
Чтобы контент всех страниц не прижимался к левому боку, создадим для него базовую обёртку.
.layout {
max-width : 1186 px ;
margin : 0 auto 200 px ;
}
export const App = () => {
return (
<>
< Header />
< div className = { s .layout}>
< Routing />
</ div >
</>
)
}
Результат: Контент основных страниц будет размещен в layout 🚀
🔶 2. RTK query
Установите Redux Toolkit и React Redux :
pnpm add @reduxjs/toolkit react-redux
Client State vs Server State
Client State Server State - Поля формы, введённые пользователем - Результаты запроса из базы данных - Выбранные фильтры на странице - Темная / светлая тема - Локальные данные для интерфейса, например, текущая страница в пагинации. - Данные, хранящиеся в локальном хранилище (LocalStorage, SessionStorage) - Временное состояние в React-компонентах (useState) - Модальные окна - Сложные формы (wizard) 🛠️Tools for Client State 🛠️Tools for Server State Redux Toolkit (slice) / useState / useReducer / Zustand / Context RTK Query / TanStack Query / SWR
Разница между Client State и Server State заключается в способах хранения,
обработки и управления информацией:
Характеристика Client State Server State Где хранится На стороне клиента (в браузере, локальном приложении). На стороне сервера. Примеры - поля формы; - выбранные фильтры; - локальные данные интерфейса (например, текущая страница); - localStorage, sessionStorage; - useState в React. - результаты запросов из БД; - состояние аутентификации; - данные о заказах; - состояние чата через WebSocket. Преимущества - быстрота доступа без запросов на сервер; - автономная работа в оффлайн-режиме (при наличии). - централизованное управление данными; - высокий уровень безопасности; - простая синхронизация между клиентами. Недостатки - ограниченный объём данных; - сложность синхронизации с сервером; - уязвимость к модификациям. - задержки из-за сетевых запросов; - зависимость от соединения; - нагрузка на сервер.
RTK query - теория
RTK Query -- библиотека для управления запросами к API и состоянием приложения в React- приложениях
с использованием Redux Toolkit. RTK Query предоставляет полезный функционал: кэширование запросов,
автоматическая обработка ошибок, управление загрузкой данных и др.
Основная идея RTK Query -- автоматическая генерация Redux Slice (набор Redux-действий и
редьюсеров) на основе API-эндпоинта, а затем предоставление хуков для использования в компонентах React.
Особенности RTK Query:
автоматически кэширует ответы на запросы и обновляет их только при необходимости;
может автоматически нормализовать данные , полученные от API, чтобы облегчить их использование в приложении;
позволяет настраивать различные параметры запросов : время ожидания, повторные попытки, тип запроса и другие;
автоматически отлавливает ошибки , связанные с запросами, и предоставляет удобные способы их обработки в приложении;
управляет состоянием данных в приложении и предоставляет способы их получения и обновления из любой части приложения.
RTK Query упрощает работу с API и управление состоянием, позволяя разработчикам сосредоточиться
на бизнес-логике приложения, а не на деталях реализации запросов и управления состоянием.
Queries
Queries используются для получения данных с сервера.
Базовое применение
// Во избежание ошибок импорт должен быть из `@reduxjs/toolkit/query/react`
import { createApi , fetchBaseQuery } from '@reduxjs/toolkit/query/react'
// `createApi` - функция из `RTK Query`, позволяющая создать объект `API`
// для взаимодействия с внешними `API` и управления состоянием приложения
export const playlistsApi = createApi ({
// `reducerPath` - имя куда будут сохранены состояние и экшены для этого `API`
reducerPath : 'playlistsApi' ,
// `baseQuery` - конфигурация для `HTTP-клиента`, который будет использоваться для отправки запросов
baseQuery : fetchBaseQuery ({
baseUrl : import . meta . env . VITE_BASE_URL ,
headers : {
'API-KEY' : import . meta . env . VITE_API_KEY ,
} ,
}) ,
// `endpoints` - метод, возвращающий объект с эндпоинтами для `API`, описанными
// с помощью функций, которые будут вызываться при вызове соответствующих методов `API`
// (например `get`, `post`, `put`, `patch`, `delete`)
endpoints : build => ({
// Типизация аргументов (<возвращаемый тип, тип query аргументов (`QueryArg`)>)
// `query` по умолчанию создает запрос `get` и указание метода необязательно
fetchPlaylists : build .query < PlaylistsResponse , FetchPlaylistsArgs >({
query : () => {
return {
method : 'get' ,
url : `playlists` ,
}
} ,
}) ,
}) ,
})
// `createApi` создает объект `API`, который содержит все эндпоинты в виде хуков,
// определенные в свойстве `endpoints`
export const { useFetchPlaylistsQuery } = playlistsApi
import type { CurrentUserReaction } from '@/common/enums'
import type { Images , Tag , User } from '@/common/types'
export type PlaylistsResponse = {
data : PlaylistData []
meta : PlaylistMeta
}
export type PlaylistData = {
id : string
type : 'playlists'
attributes : PlaylistAttributes
}
export type PlaylistMeta = {
page : number
pageSize : number
totalCount : number
pagesCount : number
}
export type PlaylistAttributes = {
title : string
description : string
addedAt : string
updatedAt : string
order : number
dislikesCount : number
likesCount : number
tags : Tag []
images : Images
user : User
currentUserReaction : CurrentUserReaction
}
// Arguments
export type FetchPlaylistsArgs = {
pageNumber ?: number
pageSize ?: number
search ?: string
sortBy ?: 'addedAt' | 'likesCount'
sortDirection ?: 'asc' | 'desc'
tagsIds ?: string []
userId ?: string
trackId ?: string
}
export type Tag = {
id : string
name : string
}
export type User = {
id : string
name : string
}
export type Images = {
main : Cover []
}
export type Cover = {
type : 'original' | 'medium' | 'thumbnail'
width : number
height : number
fileSize : number
url : string
}
export const CurrentUserReaction = {
Like : 1 ,
Dislike : - 1 ,
None : 0 ,
} as const
export type CurrentUserReaction = ( typeof CurrentUserReaction)[ keyof typeof CurrentUserReaction]
Store
1. Настройка store
В файле store.ts подключите playlistsApi, добавьте middleware для использования дополнительных функций RTK Query:
кэширование, инвалидация и pooling, и установите setupListeners для подключения слушателя событий фокуса (refetchOnFocus)
и повторного подключения (refetchOnReconnect), чтобы автоматически перезагружать данные при возвращении на страницу
или восстановлении подключения:
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { playlistsApi } from '@/features/playlists/api/playlistsApi.ts'
export const store = configureStore ({
reducer : {
[ playlistsApi .reducerPath] : playlistsApi .reducer ,
} ,
middleware : getDefaultMiddleware => getDefaultMiddleware () .concat ( playlistsApi .middleware) ,
})
setupListeners ( store .dispatch)
2. Provider
Чтобы в компонентах можно было обращаться к store, нужно обернуть приложение Provider'ом
с переданным ему store в файле main.tsx:
createRoot ( document .getElementById ( 'root' ) ! ) .render (
< BrowserRouter >
< Provider store = {store}>
< App />
</ Provider >
</ BrowserRouter >
)
Установите Redux devtools
Откройте панель разработчика и убедитесь, что playlistsApi подключен:
Query хук
В компоненте PlaylistsPage.tsx вызовите автоматически сгенерированный хук useFetchPlaylistsQuery и отрисуйте
полученные данные
export const PlaylistsPage = () => {
const { data } = useFetchPlaylistsQuery ()
return (
< div >
< h1 >Playlists page</ h1 >
< div >
{ data ?. data .map (playlist => {
return (
< div key = { playlist .id}>
< div >title: { playlist . attributes .title}</ div >
</ div >
)
})}
</ div >
</ div >
)
}
Результат: Плейлисты получены с сервера и отрисованы 🚀
Возвращаемые значения из хука
На каждое изменение статуса промиса мы получаем обновленный объект с данными о запросе :
data - данные, которые вернул запрос;
isLoading - флаг, который показывает, что запрос выполняется;
isError - флаг, который показывает, что запрос завершился с ошибкой;
error - объект ошибки;
др.
Query Hook Options
Сгенерированный хук может принимать параметры :
queryArg - данные для запроса;
queryOptions - объект с настройками для управления процессом получения данных.
Отображение плейлистов
Добавим больше информации о плейлистах в разметке и настроим базовые стили
export const PlaylistsPage = () => {
const { data } = useFetchPlaylistsQuery ()
return (
< div className = { s .container}>
< h1 >Playlists page</ h1 >
< div className = { s .items}>
{ data ?. data .map (playlist => {
return (
< div className = { s .item} key = { playlist .id}>
< div >title: { playlist . attributes .title}</ div >
< div >description: { playlist . attributes .description}</ div >
< div >userName: { playlist . attributes . user .name}</ div >
</ div >
)
})}
</ div >
</ div >
)
}
.container {
display : flex ;
flex-direction : column ;
gap : 30 px ;
}
.items {
display : flex ;
gap : 30 px ;
flex-wrap : wrap ;
}
.item {
width : 240 px ;
padding : 16 px ;
border : 1 px solid #ddd ;
border-radius : 8 px ;
}
Результат: Теперь список плейлистов выглядит гораздо привлекательнее 🚀
🔶 3. Mutation
Мутации используются для обновлений данных на сервере и в кэше на клиенте. При успешном выполнении
мутации можно сделать данные в кэше недействительными и запустить их перезапрос.
Создание плейлиста
Api
Реализуем логику создания плейлиста согласно документации .
При изменении данных будем использовать метод mutation у build:
export const playlistsApi = createApi ({
/*...*/
endpoints : build => ({
/*...*/
createPlaylist : build .mutation <{ data : PlaylistData } , CreatePlaylistArgs >({
query : body => ({
url : 'playlists' ,
method : 'post' ,
body ,
}) ,
}) ,
}) ,
})
export const { useFetchPlaylistsQuery , useCreatePlaylistMutation } = playlistsApi
/*...*/
// Arguments
export type CreatePlaylistArgs = {
title : string
description : string
}
UI
Для того чтобы создать новый плейлист, нам необходимо создать форму, куда будем вводить данные и потом передавать их
на сервер.
Для создания формы установим библиотеку react-hook-form
Создадим форму и выведем данные в консоль
src/features/playlists/ui/PlaylistsPage/CreatePlaylistForm/CreatePlaylistForm.tsx
export const CreatePlaylistForm = () => {
const { register , handleSubmit } = useForm < CreatePlaylistArgs >()
const onSubmit : SubmitHandler < CreatePlaylistArgs > = data => {
console .log (data)
}
return (
< form onSubmit = { handleSubmit (onSubmit)}>
< h2 >Create new playlist</ h2 >
< div >
< input { ... register ( 'title' )} placeholder = { 'title' } />
</ div >
< div >
< input { ... register ( 'description' )} placeholder = { 'description' } />
</ div >
< button >create playlist</ button >
</ form >
)
}
export const PlaylistsPage = () => {
const { data } = useFetchPlaylistsQuery ()
return (
< div className = { s .container}>
< h1 >Playlists page</ h1 >
< CreatePlaylistForm />
{ /*...*/ }
</ div >
)
}
Результат: Введем данные в форму и убедимся, что данные собираются и выводятся в консоль 🚀
useMutation
Сгенерированный хук useMutation возвращает массив:
const [ createPlaylist , result ] = useCreatePlaylistMutation ()
Первый параметр - функция для инициации запроса;
Второй параметр - объект с данными о запросе.
export const CreatePlaylistForm = () => {
const { register , handleSubmit } = useForm < CreatePlaylistArgs >()
const [ createPlaylist ] = useCreatePlaylistMutation ()
const onSubmit : SubmitHandler < CreatePlaylistArgs > = data => {
createPlaylist (data)
}
/*...*/
}
Теперь давайте сделаем запрос и посмотрим на результат
Эта ошибка означает, что создавать плейлисты может только авторизованный пользователь. Чтобы бэкенд понял, кто к нему
стучится, необходимо в Header передать accessToken полученный при логинизации.
⚠️
Тема авторизации довольно сложная и не хочется сейчас начинать с нее. Поэтому мы
залогинимся при помощи Swagger , получим
accessToken и прикрепим его к запросу.
❗Авторизацию в RTK query разберем позже.
Полученный accessToken положим в .env.local
VITE_BASE_URL=https://musicfun.it-incubator.app/api/1.0
VITE_API_KEY=xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx
VITE_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwibG9naW4iOiJzYWZyb25tYW4iLCJpYXQiOjE3NTIyMzk2OTIsImV4cCI6MTc1MjMyNjA5Mn0.bfQs5OgJKwKBQj8uk1uOEW1I7eIyWKtQTOXMvB0yxoo
accessToken нужно прикреплять ко всем запросам, поэтому доработаем playlistsApi.ts
export const playlistsApi = createApi ({
reducerPath : 'playlistsApi' ,
baseQuery : fetchBaseQuery ({
baseUrl : import . meta . env . VITE_BASE_URL ,
headers : {
'API-KEY' : import . meta . env . VITE_API_KEY ,
} ,
prepareHeaders : headers => {
headers .set ( 'Authorization' , `Bearer ${ import . meta . env . VITE_ACCESS_TOKEN } ` )
return headers
} ,
}) ,
/*...*/
})
Еще раз пробуем создать плейлист. Открываем панель разработчика и видим, что запрос успешно проходит
accessToken цепляется в Headers
Результат: если сделать запрос на добавление плейлиста и обновить страницу, можно убедиться в правильности
написанной логики 🚀
❗После успешного создания плейлиста, зачистим форму ввода
export const CreatePlaylistForm = () => {
const { register , handleSubmit , reset } = useForm < CreatePlaylistArgs >()
const [ createPlaylist ] = useCreatePlaylistMutation ()
const onSubmit : SubmitHandler < CreatePlaylistArgs > = data => {
createPlaylist (data) .then (() => {
reset ()
})
}
/*...*/
}
Удаление плейлиста
Реализуем логику удаления плейлиста согласно документации .
export const playlistsApi = createApi ({
/*...*/
endpoints : build => ({
/*...*/
deletePlaylist : build .mutation < void , string >({
query : playlistId => ({
url : `playlists/ ${ playlistId } ` ,
method : 'delete' ,
}) ,
}) ,
}) ,
})
В компоненте PlaylistsPage.tsx используйте сгенерированный хук useDeletePlaylistMutation:
export const PlaylistsPage = () => {
const { data } = useFetchPlaylistsQuery ()
const [ deletePlaylist ] = useDeletePlaylistMutation ()
const deletePlaylistHandler = (playlistId : string ) => {
if ( confirm ( 'Are you sure you want to delete the playlist?' )) {
deletePlaylist (playlistId)
}
}
return (
< div className = { s .container}>
< h1 >Playlists page</ h1 >
< CreatePlaylistForm />
< div className = { s .items}>
{ data ?. data .map (playlist => {
return (
< div className = { s .item} key = { playlist .id}>
< div >title: { playlist . attributes .title}</ div >
< div >description: { playlist . attributes .description}</ div >
< div >userName: { playlist . attributes . user .name}</ div >
< button onClick = {() => deletePlaylistHandler ( playlist .id)}>delete</ button >
</ div >
)
})}
</ div >
</ div >
)
}
Результат: если сделать запрос на удаление плейлиста и обновить страницу, можно убедиться в правильности
написанной логики 🚀
Обновление плейлиста
Шаг 1
Реализуем логику обновления плейлиста согласно документации .
export const playlistsApi = createApi ({
/*...*/
endpoints : build => ({
/*...*/
updatePlaylist : build .mutation < void , { playlistId : string ; body : UpdatePlaylistArgs }>({
query : ({ playlistId , body }) => ({
url : `playlists/ ${ playlistId } ` ,
method : 'put' ,
body ,
}) ,
}) ,
}) ,
})
/*...*/
// Arguments
export type CreatePlaylistArgs = {
title : string
description : string
}
export type UpdatePlaylistArgs = {
title : string
description : string
tagIds : string []
}
В компоненте PlaylistsPage:
добавим кнопку для редактирования плейлиста
воспользуемся сгенерированным хуком updatePlaylist
⚠️
В body временно будем передавать hardcode данные
export const PlaylistsPage = () => {
/*...*/
const updatePlaylistHandler = (playlistId : string ) => {
updatePlaylist ({
playlistId ,
body : {
title : '1' ,
description : '2' ,
tagIds : [] ,
} ,
})
}
return (
< div className = { s .container}>
{ /*...*/ }
< div className = { s .items}>
{ data ?. data .map (playlist => {
return (
< div className = { s .item} key = { playlist .id}>
{ /*...*/ }
< button onClick = {() => deletePlaylistHandler ( playlist .id)}>delete</ button >
< button onClick = {() => updatePlaylistHandler ( playlist .id)}>update</ button >
</ div >
)
})}
</ div >
</ div >
)
}
Результат: если сделать запрос на обновление плейлиста и обновить страницу, можно убедиться в
правильности написанной логики 🚀
Шаг 2
При нажатии на кнопку update нам необходимо показывать форму, куда мы будем вводить данные. Но формы хотим
показывать только для одного плейлиста, а не для всех. Поэтому добавим
const [playlistId, setPlaylistId] = useState<string | null>(null)
Когда мапятся плейлисты проверяем, есть ли плейлист, который нужно редактировать
const isEditing = playlistId === playlist.id и если такой находится, то отрисовываем форму редактирования
При нажатии на кнопку update (editPlaylistHandler) при помощи reset устанавливаем значения для плейлиста
При нажатии на кнопку save (onSubmit) отправляем введенные данные на сервер. Если плейлист успешно обновился,
необходимо дождаться завершения запроса и закрыть форму редактирования
При нажатии на кнопку cancel закрываем форму редактирования
export const PlaylistsPage = () => {
// 1
const [ playlistId , setPlaylistId ] = useState < string | null >( null )
const { register , handleSubmit , reset } = useForm < UpdatePlaylistArgs >()
const { data } = useFetchPlaylistsQuery ()
const [ deletePlaylist ] = useDeletePlaylistMutation ()
const [ updatePlaylist ] = useUpdatePlaylistMutation ()
const deletePlaylistHandler = (playlistId : string ) => {
if ( confirm ( 'Are you sure you want to delete the playlist?' )) {
deletePlaylist (playlistId)
}
}
// 3, 5
const editPlaylistHandler = (playlist : PlaylistData | null ) => {
if (playlist) {
setPlaylistId ( playlist .id)
reset ({
title : playlist . attributes .title ,
description : playlist . attributes .description ,
tagIds : playlist . attributes . tags .map (t => t .id) ,
})
} else {
setPlaylistId ( null )
}
}
// 4
const onSubmit : SubmitHandler < UpdatePlaylistArgs > = data => {
if ( ! playlistId) return
updatePlaylist ({ playlistId , body : data }) .then (() => {
setPlaylistId ( null )
})
}
return (
< div className = { s .container}>
< h1 >Playlists page</ h1 >
< CreatePlaylistForm />
< div className = { s .items}>
{ data ?. data .map (playlist => {
// 2
const isEditing = playlistId === playlist .id
return (
< div className = { s .item} key = { playlist .id}>
{isEditing ? (
< form onSubmit = { handleSubmit (onSubmit)}>
< h2 >Edit playlist</ h2 >
< div >
< input { ... register ( 'title' )} placeholder = { 'title' } />
</ div >
< div >
< input { ... register ( 'description' )} placeholder = { 'description' } />
</ div >
< button type = { 'submit' }>save</ button >
< button type = { 'button' } onClick = {() => editPlaylistHandler ( null )}>
cancel
</ button >
</ form >
) : (
< div >
< div >title: { playlist . attributes .title}</ div >
< div >description: { playlist . attributes .description}</ div >
< div >userName: { playlist . attributes . user .name}</ div >
< button onClick = {() => deletePlaylistHandler ( playlist .id)}>delete</ button >
< button onClick = {() => editPlaylistHandler (playlist)}>update</ button >
</ div >
)}
</ div >
)
})}
</ div >
</ div >
)
}
Результат: мы успешно реализовали CRUD операции с плейлистами 🚀
Декомпозиция PlaylistsPage
Прежде чем двигаться дальше, давайте немного зарефакторим код и создадим отдельные компоненты:
для плейлиста (PlaylistItem.tsx)
type Props = {
playlist : PlaylistData
deletePlaylist : (playlistId : string ) => void
editPlaylist : (playlist : PlaylistData ) => void
}
export const PlaylistItem = ({ playlist , editPlaylist , deletePlaylist } : Props ) => {
return (
< div >
< div >title: { playlist . attributes .title}</ div >
< div >description: { playlist . attributes .description}</ div >
< div >userName: { playlist . attributes . user .name}</ div >
< button onClick = {() => deletePlaylist ( playlist .id)}>delete</ button >
< button onClick = {() => editPlaylist (playlist)}>update</ button >
</ div >
)
}
для редактирования формы (EditPlaylistForm.tsx)
type Props = {
playlistId : string
register : UseFormRegister < UpdatePlaylistArgs >
handleSubmit : UseFormHandleSubmit < UpdatePlaylistArgs >
editPlaylist : (playlist : null ) => void
setPlaylistId : (playlistId : null ) => void
}
export const EditPlaylistForm = ({
playlistId ,
handleSubmit ,
register ,
editPlaylist ,
setPlaylistId ,
} : Props ) => {
const [ updatePlaylist ] = useUpdatePlaylistMutation ()
const onSubmit : SubmitHandler < UpdatePlaylistArgs > = data => {
if ( ! playlistId) return
updatePlaylist ({ playlistId , body : data }) .then (() => {
setPlaylistId ( null )
})
}
return (
< form onSubmit = { handleSubmit (onSubmit)}>
< h2 >Edit playlist</ h2 >
< div >
< input { ... register ( 'title' )} placeholder = { 'title' } />
</ div >
< div >
< input { ... register ( 'description' )} placeholder = { 'description' } />
</ div >
< button type = { 'submit' }>save</ button >
< button type = { 'button' } onClick = {() => editPlaylist ( null )}>
cancel
</ button >
</ form >
)
}
код после рефакторинга (PlaylistsPage.tsx)
export const PlaylistsPage = () => {
const [ playlistId , setPlaylistId ] = useState < string | null >( null )
const { register , handleSubmit , reset } = useForm < UpdatePlaylistArgs >()
const { data } = useFetchPlaylistsQuery ()
const [ deletePlaylist ] = useDeletePlaylistMutation ()
const deletePlaylistHandler = (playlistId : string ) => {
if ( confirm ( 'Are you sure you want to delete the playlist?' )) {
deletePlaylist (playlistId)
}
}
const editPlaylistHandler = (playlist : PlaylistData | null ) => {
if (playlist) {
setPlaylistId ( playlist .id)
reset ({
title : playlist . attributes .title ,
description : playlist . attributes .description ,
tagIds : playlist . attributes . tags .map (t => t .id) ,
})
} else {
setPlaylistId ( null )
}
}
return (
< div className = { s .container}>
< h1 >Playlists page</ h1 >
< CreatePlaylistForm />
< div className = { s .items}>
{ data ?. data .map (playlist => {
const isEditing = playlistId === playlist .id
return (
< div className = { s .item} key = { playlist .id}>
{isEditing ? (
< EditPlaylistForm
playlistId = {playlistId}
handleSubmit = {handleSubmit}
register = {register}
editPlaylist = {editPlaylistHandler}
setPlaylistId = {setPlaylistId}
/>
) : (
< PlaylistItem
playlist = {playlist}
deletePlaylist = {deletePlaylistHandler}
editPlaylist = {editPlaylistHandler}
/>
)}
</ div >
)
})}
</ div >
</ div >
)
}
Результат: рефакторинг завершен. Код работает 🚀
🔶 4. Обновление данных
Automated Re-fetching
После мутации данных изменения на UI не видно изменений без перегрузки страницы.
Рефетчинг данных в RTK query реализуется при помощи системы тегов :
"Tag" -- строка или объект, позволяющий именовать определенные типы данных и инвалидировать части кэша.
При инвалидации кэш-тега RTK Query автоматически повторно запрашивает данные с через эндпоинты, помеченных этим
тегом:
Для использования тегов добавьте:
Свойство tagTypes в API-slice, объявляющее массив имен тегов для типов данных, таких как "Playlist";
Массив providesTags в query-эндпоинте, перечисляющий набор тегов, описывающих получаемые в этом запросе данные;
Массив invalidatesTags в mutation-эндпоинтах, перечисляющий набор тегов, которые инвалидируются при выполнении
этих мутаций:
export const playlistsApi = createApi ({
/*...*/
tagTypes : [ 'Playlist' ] ,
endpoints : build => ({
fetchPlaylists : build .query < PlaylistsResponse , void >({
query : () => ({ url : `playlists` }) ,
providesTags : [ 'Playlist' ] ,
}) ,
createPlaylist : build .mutation <{ data : PlaylistData } , CreatePlaylistArgs >({
query : body => ({ url : 'playlists' , method : 'post' , body }) ,
invalidatesTags : [ 'Playlist' ] ,
}) ,
deletePlaylist : build .mutation < void , string >({
query : playlistId => ({ url : `playlists/ ${ playlistId } ` , method : 'delete' }) ,
invalidatesTags : [ 'Playlist' ] ,
}) ,
updatePlaylist : build .mutation < void , { playlistId : string ; body : UpdatePlaylistArgs }>({
query : ({ playlistId , body }) => ({ url : `playlists/ ${ playlistId } ` , method : 'put' , body }) ,
invalidatesTags : [ 'Playlist' ] ,
}) ,
}) ,
})
Откройте network и протестируйте выполнение CRUD-операций с плейлистами. Сперва при
мутациях идет запрос на саму мутацию, а затем на получение актуальных плейлистов.
Результат: при мутациях пользователь видит обновленную информацию без перезагрузки страницы 🚀
🔶 5. Code Splitting
RTK Query уменьшает начальный размер бандла, так как позволяет добавлять эндпоинты после настройки базового
определения сервиса. Code Splitting в RTK Query
делает приложение более оптимизированным, гибким и удобным в поддержке, особенно при работе с большим количеством API
в масштабируемых проектах.
Преимущества Code Splitting:
Уменьшение объема начального JavaScript-кода : загружаются только необходимые части приложения,
уменьшается размер бандла и сокращается время начальной загрузки;
Повышение производительности : браузеру требуется меньше времени для обработки загруженного кода;
Экономия сетевых ресурсов : логика API-запросов не грузится для страниц, на которых она не используется;
Повышение гибкости разработки : можно добавлять и изменять API-слайсы, не влияя на структуру всего приложения..
Метод injectEndpoints принимает коллекцию эндпоинтов, а также необязательный параметр overrideExisting.
Вызов injectEndpoints добавит эндпоинты в исходное API и вернёт это же API с корректными типами для всех эндпоинтов.
Создайте файл app/api/baseApi.ts с одним пустым центральным определением среза API -- baseApi:
import { createApi , fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const baseApi = createApi ({
reducerPath : 'baseApi' ,
tagTypes : [ 'Playlist' ] ,
baseQuery : fetchBaseQuery ({
baseUrl : import . meta . env . VITE_BASE_URL ,
headers : {
'API-KEY' : import . meta . env . VITE_API_KEY ,
} ,
prepareHeaders : headers => {
headers .set ( 'Authorization' , `Bearer ${ import . meta . env . VITE_ACCESS_TOKEN } ` )
return headers
} ,
}) ,
endpoints : () => ({}) ,
})
Внедрите эндпоинты playlistsApi в baseApi, используя injectEndpoints:
export const playlistsApi = baseApi .injectEndpoints ({
endpoints : build => ({
/*...*/
}) ,
})
Подключите baseApi к store:
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { baseApi } from '../api/baseApi.ts'
export const store = configureStore ({
reducer : {
[ baseApi .reducerPath] : baseApi .reducer ,
} ,
middleware : getDefaultMiddleware => getDefaultMiddleware () .concat ( baseApi .middleware) ,
})
setupListeners ( store .dispatch)
Результат: все работает с использованием преимуществ, которые дает Code Splitting 🚀