Предисловие
Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык
Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык
⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.
API в React: Swagger, кодогенерация и углублённая типизация
Документация API
Фронтенд и бэкенд обычно пишут разные люди, а иногда и разные команды. Чтобы фронтенд правильно отправлял запросы, нужно договориться: какие адреса есть у API, какие данные они принимают, что возвращают и какие ошибки бывают. Такая договорённость называется контрактом API.
Контракт записывают в документации. Хорошая документация API отвечает на вопросы:
какие эндпоинты существуют и какие методы они поддерживают;
какие параметры принимает каждый эндпоинт и какие из них обязательны;
какая форма у тела запроса и у ответа: названия полей, их типы, какие поля могут быть
null;какие статус-коды возвращает эндпоинт и что они означают;
как сервер узнаёт пользователя.
Если документация написана вручную, она со временем расходится с настоящим API: разработчик бэкенда добавил поле и забыл его описать. Поэтому документацию часто создают автоматически из кода сервера. Тогда описание всегда совпадает с тем, как сервер работает на самом деле.
На шестом занятии мы читали документацию DummyJSON: это текстовая страница с примерами запросов. Сервер курса описан по стандарту OpenAPI, и с таким описанием можно сделать гораздо больше, чем прочитать его.
Почитать ещё
Дока: API
OpenAPI и Swagger
OpenAPI — стандарт описания HTTP API. Описание хранится в файле JSON или YAML и содержит все эндпоинты, параметры, схемы данных и ответы в формате, который понимают программы.
Фрагмент описания API курса выглядит так:
{
"paths": {
"/api/cards": {
"get": {
"summary": "Все карточки доски",
"parameters": [{ "name": "sort", "in": "query", "schema": { "$ref": "#/components/schemas/CardSort" } }],
"responses": {
"200": { "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Card" } } } } }
}
}
}
}
}
Читать такой файл неудобно, поэтому по нему строят интерфейсы. Самый известный — Swagger UI: страница, на которой эндпоинты сгруппированы по разделам, у каждого видны параметры и схемы ответов, а запрос можно отправить прямо из браузера.
С описанием OpenAPI можно:
читать документацию в Swagger UI;
отправлять тестовые запросы, не написав ни строчки кода;
генерировать типы TypeScript для данных API;
генерировать готовые функции запросов или моки для тестов.
Сервер курса написан на FastAPI. Этот фреймворк создаёт описание OpenAPI из кода сервера автоматически, поэтому документация всегда совпадает с тем, что сервер принимает и возвращает.
Почитать ещё
Что такое OpenAPI (англ.)
Swagger UI (англ.)
API курса
Для ITAM Board развёрнут учебный сервер:
адрес API:
https://courses.salut.uno/example-backend/frontend-itam. Все эндпоинты начинаются с/api;документация Swagger: courses.salut.uno/example-backend/frontend-itam/docs;
описание OpenAPI:
https://courses.salut.uno/example-backend/frontend-itam/openapi.json.
У каждого потока курса своя доска. Все студенты потока видят карточки друг друга, голосуют за них и оставляют комментарии. Студенты других потоков этих карточек не видят.
Сервер узнаёт пользователя по токену — строке, которую передают в заголовке каждого запроса:
X-Course-Token: exb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Регистрироваться и входить в приложение не нужно. Ваш токен находится на странице курса во вкладке «API проекта». Там же токен можно сбросить, если он попал к посторонним: старый токен перестанет работать, и нужно будет вставить в проект новый.
Токен связан с вашим аккаунтом на платформе курса. По нему сервер определяет ваше имя, аватар и поток, а в ответах отмечает ваши карточки и комментарии.
Обычно токены и пароли не хранят в коде приложения: любой пользователь сайта может найти их в собранных файлах. Токен курса даёт доступ только к учебному API, поэтому в проекте ITAM Board его допустимо записать прямо в код клиента. В настоящих приложениях токен получают после входа пользователя и хранят иначе.
Почитать ещё
Как читать Swagger
Откройте документацию API курса. Эндпоинты на странице сгруппированы по разделам: board, cards, comments, users, me.
Нажмите на эндпоинт GET /api/cards, чтобы раскрыть его описание:
Parameters — параметры запроса. У этого эндпоинта это фильтры
column,type,author_id, строка поискаqи сортировкаsort. Для каждого параметра указан тип и допустимые значения.Responses — возможные ответы. Для статуса
200указана схема тела: массив объектовCard. Ниже перечислены ответы с ошибками:401, если токен не передан или не подошёл,422, если параметр передан неправильно.
В самом низу страницы раздел Schemas описывает все структуры данных. Раскройте схему Card: у каждого поля указан тип и пояснение. Именно отсюда фронтенд-разработчик узнаёт, что date — Unix-время в секундах, description может быть null, а column принимает одно из пяти значений.
Swagger позволяет отправить настоящий запрос:
Нажмите кнопку Authorize вверху страницы и вставьте свой токен.
Раскройте эндпоинт
GET /api/meи нажмите Try it out, а затем Execute.Ниже появится ответ сервера: статус, заголовки и тело с вашим профилем.
Отправлять запросы через Swagger удобно, чтобы понять, как работает эндпоинт, до того как писать код. Например, можно проверить, какую ошибку вернёт сервер, если создать карточку без заголовка.
Запросы из Swagger выполняются по-настоящему. Созданная карточка появится на доске вашего потока, и её увидят одногруппники.
Почитать ещё
Эндпоинты ITAM Board API
Вот эндпоинты, которые понадобятся приложению:
Метод | Путь | Что делает |
|---|---|---|
|
| Все карточки доски потока, с фильтрами и сортировкой |
|
| Создать карточку |
|
| Одна карточка вместе с комментариями |
|
| Изменить свою карточку |
|
| Удалить свою карточку |
|
| Проголосовать «за» или «против» |
|
| Отозвать свой голос |
|
| Комментарии к карточке |
|
| Написать комментарий |
|
| Удалить свой комментарий |
|
| Ваш профиль |
|
| Изменить свой профиль |
|
| Участник доски со счётчиками активности |
Фигурные скобки в пути обозначают параметр: в запросе вместо {card_id} подставляют идентификатор карточки.
У API есть правила, которые проверяет сервер:
изменять и удалять можно только свои карточки и комментарии. На попытку изменить чужую карточку сервер ответит
403;за свою карточку голосовать нельзя, сервер тоже ответит
403;адрес почты в профиле не может совпадать с адресом другого участника: если адрес занят, сервер ответит
409;карточка переезжает в колонку «Принято» или «Отклонено», когда наберёт нужное число голосов. Колонку вычисляет сервер, фронтенд её только показывает;
у API есть ограничение на число запросов в секунду. Если его превысить, сервер ответит
429 Too Many Requests.
Ответы на PUT и DELETE для голоса возвращают обновлённую карточку: новый счёт, ваш голос и колонку. Фронтенду не нужно пересчитывать счёт самому: он заменяет карточку в своих данных той, что пришла от сервера.
Почитать ещё
Ошибки API курса
На любую ошибку API курса отвечает в одном формате. В теле ответа есть поле detail с сообщением, которое можно показать пользователю:
{ "detail": "Карточка не найдена. Возможно, её удалили." }
Если ошибка связана с конкретными полями запроса, сервер добавляет список errors. Так выглядят ошибки проверки данных со статусом 422 и конфликт 409:
{
"detail": "email: Этот email уже занят",
"errors": [{ "field": "email", "message": "Этот email уже занят" }]
}
Каждый элемент errors содержит имя поля и сообщение. На следующем занятии мы будем показывать эти сообщения под нужными полями формы.
Статус-коды, которые встречаются в API курса:
Статус | Когда | Что показать пользователю |
|---|---|---|
| Токен не передан или не подошёл | Сообщение о проблеме с токеном вместо страницы |
| Действие запрещено: чужая карточка, голос за свою | Текст из |
| Карточка, комментарий или пользователь не найдены | «Не найдено» на месте данных |
| Конфликт данных, например занятая почта | Сообщение под полем формы |
| Данные не прошли проверку | Сообщения под полями формы |
| Слишком много запросов | Предложение повторить позже |
| Удаление прошло успешно | Тела у ответа нет |
Ответ 204 No Content приходит на успешное удаление. У него нет тела, поэтому и данных в таком ответе не будет: функции удаления нечего возвращать.
Почитать ещё
MDN: Коды ответа HTTP
Генерация типов из схемы
На девятом занятии мы описали тип карточки вручную. У такого подхода два недостатка: легко ошибиться в названии или типе поля, и при изменении API типы в проекте незаметно устаревают.
Описание OpenAPI уже содержит все типы. Утилита openapi-typescript превращает его в файл с типами TypeScript:
npx openapi-typescript https://courses.salut.uno/example-backend/frontend-itam/openapi.json -o src/shared/api/schema.d.ts
Команда скачает описание по адресу и создаст файл src/shared/api/schema.d.ts. Расширение .d.ts означает, что в файле только описания типов, без кода.
Команду сохраняют в скриптах package.json, чтобы обновлять типы одной командой:
{
"scripts": {
"api:types": "npx --yes openapi-typescript@7 https://courses.salut.uno/example-backend/frontend-itam/openapi.json -o src/shared/api/schema.d.ts"
}
}
npm run api:types
npx --yesзапускает пакет без установки в проект и без вопроса о подтверждении.@7фиксирует основную версию утилиты, чтобы результат генерации не изменился неожиданно после выхода новой версии.
Сгенерированный файл большой, и читать его целиком не нужно. Его не редактируют вручную: при следующей генерации изменения пропадут. Файл сохраняют в проекте, чтобы проект собирался без доступа к сети.
Главное преимущество генерации проявляется, когда API меняется. Если разработчик бэкенда переименует поле, после npm run api:types TypeScript покажет ошибку в каждом месте проекта, где используется старое имя.
Почитать ещё
openapi-typescript (англ.)
Короткие имена для типов
В файле schema.d.ts схемы данных лежат внутри типа components. Обращаться к ним по полному пути неудобно: components['schemas']['Card']. Поэтому рядом создают файл, который даёт схемам короткие имена:
// src/shared/api/types.ts
import type { components } from './schema';
type Schemas = components['schemas'];
export type Card = Schemas['Card'];
export type CardCreate = Schemas['CardCreate'];
export type CardUpdate = Schemas['CardUpdate'];
export type CardType = Schemas['CardType'];
export type CardColumn = Schemas['CardColumn'];
export type CardSort = Schemas['CardSort'];
export type VoteValue = Schemas['VoteValue'];
export type Comment = Schemas['Comment'];
export type User = Schemas['User'];
export type UserDetail = Schemas['UserDetail'];
export type Profile = Schemas['Profile'];
export type ProfileUpdate = Schemas['ProfileUpdate'];
export type FieldError = Schemas['FieldError'];
Запись
Schemas['Card']— индексный доступ к типу. Она работает как чтение свойства объекта, но с типами: берёт тип свойстваCardиз типаSchemas.Остальной код проекта импортирует типы из
@/shared/apiи не знает, что они сгенерированы.
Сравните сгенерированный тип Card с тем, что мы писали вручную на девятом занятии. В нём больше полей: upvotes, downvotes, votes_to_accept, is_accepted, updated_at. А CardCreate описывает только поля, которые можно передать при создании: обязательные title и type и необязательные description, preview и date.
После перехода на сгенерированные типы удалите старый файл с типами из моков и исправьте импорты. TypeScript покажет все места, где ручные типы расходились с настоящими.
Почитать ещё
TypeScript: Indexed Access Types (англ.)
Клиент API
На седьмом занятии мы написали функцию request на fetch: она подставляла адрес сервера, проверяла статус, разбирала тело и превращала ошибку в понятный текст. В проектах эту работу обычно не пишут руками, а берут библиотеку HTTP-запросов. Самая распространённая — axios.
npm install axios
Что axios делает сам, без кода в проекте:
подставляет базовый адрес ко всем путям;
превращает объект в JSON, ставит
Content-Type, а тело ответа разбирает обратно в объект;отклоняет промис на любом ответе не из группы
2xx. Уfetchответ404считается успешным, и статус приходилось проверять самим;собирает строку запроса из объекта
params:{ q: 'хакатон' }превращается в?q=%D1%85...;ограничивает время ожидания ответа;
умеет отменять запрос по сигналу — разберём в разделе про отмену;
даёт перехватчики: общий код до запроса и после ответа.
Клиент API проекта — это один настроенный экземпляр axios:
// src/shared/api/client.ts
import axios from 'axios';
const API_URL = 'https://courses.salut.uno/example-backend/frontend-itam';
const COURSE_TOKEN = 'exb_...'; // ваш токен со страницы курса
export const api = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: { 'X-Course-Token': COURSE_TOKEN },
});
axios.createсоздаёт экземпляр со своими настройками. Весь проект обращается к нему, а не к глобальномуaxios, поэтому адрес сервера и токен записаны в одном месте.baseURLподставляется ко всем путям:api.get('/api/cards')уйдёт наhttps://courses.salut.uno/example-backend/frontend-itam/api/cards.timeout— 10 секунд. Если сервер молчит дольше, запрос завершается ошибкой, а не висит бесконечно.Заголовок с токеном указан в
headersэкземпляра, поэтому уйдёт с каждым запросом.
Запрос возвращает не данные, а ответ целиком: данные лежат в его поле data.
const response = await api.get<Card[]>('/api/cards');
console.log(response.status); // 200
console.log(response.data); // Card[]
Тип в угловых скобках задаёт тип поля
data. Как и приведение типа в прошлой версии клиента, он ничего не проверяет во время работы: это обещание, основанное на схеме API.Кроме
dataв ответе естьstatus,headersиconfig— конфигурация, с которой запрос ушёл.Ответ
204 No Contentприходит без тела. Разбирать его не нужно: axios оставит вdataпустое значение и ошибку не выбросит.
Если сервер ответил ошибкой, промис отклоняется ошибкой axios. В ней есть response с ответом сервера, а если ответа не было вовсе — code: 'ERR_NETWORK', когда сеть недоступна, и 'ECONNABORTED', когда вышло время ожидания. Разбирать эту ошибку в каждом запросе не придётся: её превратит в нашу собственную ошибку перехватчик, а сам класс ошибки разберём в следующем разделе.
Почитать ещё
axios: Instance, Request Config (англ.)
Класс ошибки ApiError
Когда запрос завершается ошибкой, коду, который его вызвал, нужны подробности: статус ответа, сообщение для пользователя, ошибки отдельных полей. Встроенный класс Error хранит только сообщение. Поэтому для ошибок API создают свой класс, который наследует Error и добавляет нужные свойства.
// src/shared/api/client.ts
export class ApiError extends Error {
readonly status: number;
readonly fieldErrors: FieldError[];
constructor(status: number, message: string, fieldErrors: FieldError[] = []) {
super(message);
this.name = 'ApiError';
this.status = status;
this.fieldErrors = fieldErrors;
}
}
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : 'Что-то пошло не так';
}
class ApiError extends Errorобъявляет класс, который получает всё, что умеетError, и добавляет своё.constructorвызывается при создании ошибки черезnew ApiError(...). Вызовsuper(message)передаёт сообщение конструкторуError.Свойства
statusиfieldErrorsпомеченыreadonly: после создания ошибки их нельзя изменить.Функция
getErrorMessageдостаёт текст из любой ошибки. Её используют там, где нужно показать сообщение и не важно, откуда пришла ошибка.
Ошибку API можно отличить от других ошибок проверкой instanceof:
try {
await createCard({ title: '', type: 'idea' });
} catch (error) {
if (error instanceof ApiError && error.status === 422) {
console.log(error.fieldErrors);
}
console.log(getErrorMessage(error));
}
Внутри блока if TypeScript знает, что error имеет тип ApiError, и разрешает обращаться к status и fieldErrors. Снаружи error остаётся unknown.
Клиент и типы экспортируют через публичный API слайса shared/api:
// src/shared/api/index.ts
export { api, ApiError, getErrorMessage } from './client';
export type * from './types';
Почитать ещё
Перехватчики запросов и ответов
Перехватчик (interceptor) — функция, через которую проходит каждый запрос или каждый ответ экземпляра axios. Это одно место, где можно описать поведение, общее для всех запросов сразу.
Перехватчиков два вида:
запроса — выполняется перед отправкой и получает конфигурацию запроса. Его обычно используют, чтобы добавить заголовки;
ответа — выполняется, когда ответ получен, и получает либо ответ, либо ошибку. Его обычно используют, чтобы привести ошибки к одному виду.
Клиент ITAM Board с перехватчиками выглядит так:
// src/shared/api/client.ts
import axios from 'axios';
export const api = axios.create({ baseURL: API_URL, timeout: 10000 });
api.interceptors.request.use((config) => {
config.headers.set('X-Course-Token', COURSE_TOKEN);
return config;
});
api.interceptors.response.use(
(response) => response,
(error: unknown) => {
if (!axios.isAxiosError(error) || axios.isCancel(error)) {
return Promise.reject(error);
}
if (!error.response) {
const message =
error.code === 'ECONNABORTED'
? 'Сервер долго не отвечает. Попробуйте ещё раз.'
: 'Сервер не отвечает. Проверьте интернет и попробуйте ещё раз.';
return Promise.reject(new ApiError(0, message));
}
const { status, data } = error.response;
return Promise.reject(new ApiError(status, data?.detail ?? `Ошибка ${status}`, data?.errors ?? []));
},
);
useпринимает две функции: первая получает успешный результат, вторая — ошибку. Перехватчику запроса вторая функция обычно не нужна.Перехватчик запроса обязан вернуть конфигурацию, а перехватчик ответа — ответ. Если ничего не вернуть, запрос уйдёт без данных.
Перехватчик ошибок должен вернуть отклонённый промис. Если просто вернуть значение, ошибка превратится в успешный ответ, и код, который вызвал запрос, не заметит её.
Ошибка axios разбирается один раз: сначала отсеиваются отмена и чужие ошибки, потом случай без ответа (сеть недоступна или вышло время ожидания), потом ответ сервера. Наружу всегда выходит
ApiError.Ни одна функция запроса и ни один компонент теперь не знают про
error.responseи про axios вообще.Перехватчики привязаны к экземпляру: глобальный
axiosи другие экземпляры их не видят.
Заголовок с токеном можно было задать и в headers при создании экземпляра, как в прошлом разделе. Перехватчик запроса нужен, когда значение заголовка известно не заранее: в настоящих приложениях токен появляется после входа пользователя и меняется, а перехватчик выполняется в момент отправки и всегда берёт свежее значение.
Что ещё обычно делают перехватчиками:
уводят пользователя на страницу входа, когда сервер ответил
401;обновляют просроченный токен и повторяют запрос;
пишут запросы и ошибки в лог или в систему мониторинга;
показывают общий индикатор загрузки, пока есть незавершённые запросы.
Перехватчик влияет сразу на все запросы, поэтому ошибка в нём ломает всё приложение целиком. Если перехватчиков несколько, запросные выполняются в порядке, обратном добавлению, а ответные — в порядке добавления. Ненужный перехватчик убирают по номеру, который вернул use:
const logId = api.interceptors.request.use(logRequest);
api.interceptors.request.eject(logId);
Почитать ещё
axios: Interceptors, Handling Errors (англ.)
Запросы сущностей
Функции запросов кладут в сегмент api той сущности, к которой они относятся. Каждая функция соответствует одному эндпоинту, использует типы из схемы и возвращает только данные, без остального ответа.
// src/entities/card/api/cards-api.ts
import { api, type Card, type CardCreate, type CardUpdate, type VoteValue } from '@/shared/api';
type RequestOptions = {
signal?: AbortSignal;
};
export async function getCards(options?: RequestOptions) {
const { data } = await api.get<Card[]>('/api/cards', options);
return data;
}
export async function createCard(body: CardCreate) {
const { data } = await api.post<Card>('/api/cards', body);
return data;
}
export async function updateCard(cardId: string, changes: CardUpdate) {
const { data } = await api.patch<Card>(`/api/cards/${cardId}`, changes);
return data;
}
export async function deleteCard(cardId: string) {
await api.delete(`/api/cards/${cardId}`);
}
export async function voteCard(cardId: string, value: VoteValue) {
const { data } = await api.put<Card>(`/api/cards/${cardId}/vote`, { value });
return data;
}
export async function removeVote(cardId: string) {
const { data } = await api.delete<Card>(`/api/cards/${cardId}/vote`);
return data;
}
У методов
post,putиpatchтело запроса идёт вторым аргументом, а конфигурация — третьим. Уgetиdeleteтела нет, и конфигурация идёт вторым аргументом.const { data } = await ...достаёт данные из ответа. Наружу функция отдаётCardилиCard[], поэтому остальной код о существовании axios не знает.Тип в угловых скобках задаёт тип
data, а возвращаемый тип функции выводится сам:createCardвозвращаетPromise<Card>.deleteCardничего не возвращает: на удаление сервер отвечает204без тела.RequestOptions— это то немногое из конфигурации axios, что нужно вызывающему коду. Пока там только сигнал отмены, о нём следующий раздел.
Так же устроены запросы других сущностей: entities/comment/api/comments-api.ts с getComments, addComment и deleteComment, entities/session/api/session-api.ts с getMe и updateMe.
Компоненты не вызывают api напрямую и не знают адресов эндпоинтов. Если адрес эндпоинта изменится, поправить нужно будет одну функцию.
Почитать ещё
Отмена запросов
Ответ бывает не нужен ещё до того, как он пришёл: пользователь ушёл со страницы, сменил параметры, начал набирать в поиске новое слово. Такой запрос стоит не просто проигнорировать, а отменить: браузер закроет соединение, а сервер не будет занят лишней работой.
Отмену в браузере описывает AbortController. У него есть свойство signal, которое передают в запрос, и метод abort, который его прерывает. Это встроенный объект, тот же самый, что отменяет fetch и снимает обработчики событий.
axios принимает сигнал в конфигурации любого запроса, поэтому своего кода отмены писать не нужно:
const controller = new AbortController();
getCards({ signal: controller.signal }).then((cards) => console.log(cards.length));
controller.abort(); // запрос прерван, промис отклонён
Что происходит при отмене:
браузер закрывает соединение, и во вкладке Network запрос помечается
(canceled);промис отклоняется ошибкой
CanceledErrorс кодом'ERR_CANCELED';перехватчик ответа пропускает её как есть: это не ошибка сервера, показывать пользователю нечего.
Чтобы отличить отмену от настоящей ошибки, рядом с ApiError держат маленькую функцию:
// src/shared/api/client.ts
export function isCanceled(error: unknown) {
return axios.isCancel(error);
}
// src/shared/api/index.ts
export { api, ApiError, getErrorMessage, isCanceled } from './client';
export type * from './types';
Так про axios знает только слайс shared/api. Если проект однажды переедет на другую библиотеку запросов, остальной код это не заметит.
Один сигнал можно передать в несколько запросов — тогда abort отменит их все. А время ожидания, которое мы задали экземпляру через timeout, работает так же: axios сам прерывает слишком долгий запрос, только ошибка будет не про отмену, а про недоступный сервер.
Дальше мы передадим сигнал из эффекта React: очистка эффекта отменит запрос, ответ которого уже некуда записывать.
Почитать ещё
MDN: AbortController
axios: Cancellation (англ.)
Загрузка данных в компоненте
Загрузить карточки при открытии доски можно с помощью useState и useEffect. Сигнал отмены приходит из эффекта: его очистка прерывает запрос, ответ которого уже некуда записывать.
export function BoardPage() {
const [cards, setCards] = useState<Card[]>([]);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
getCards({ signal: controller.signal })
.then((data) => {
setCards(data);
setStatus('ready');
})
.catch((err) => {
if (isCanceled(err)) return;
setError(getErrorMessage(err));
setStatus('error');
});
return () => controller.abort();
}, []);
if (status === 'loading') {
return <Loader />;
}
if (status === 'error') {
return <ErrorMessage message={error ?? 'Не удалось загрузить карточки'} />;
}
return <BoardColumns cards={cards} onOpenCard={setOpenCardId} />;
}
Статусы загрузки работают так же, как на седьмом занятии, только вместо функции рендера по статусу выбирается разметка.
Отмена защищает от устаревших ответов. Представьте страницу пользователя, которая загружает профиль по userId из адреса. Пользователь открыл профиль Анны, а до прихода ответа перешёл к профилю Бориса. Эффект перезапустится, но сначала выполнится его очистка: запрос про Анну будет прерван, и её профиль уже не окажется на странице Бориса.
Раньше для этого заводили флаг ignore: очистка эффекта ставила его в true, а обработчик ответа проверял флаг и ничего не делал. Состояние это защищало, но запрос всё равно доходил до конца. Сигнал решает обе задачи, и поддержка у него встроенная — ни флага, ни своих проверок в каждой загрузке.
Отменённый запрос попадает в catch, поэтому там стоит проверка isCanceled: отмена — это не ошибка, и сообщение пользователю показывать не нужно. Это единственное место, где компонент вообще вспоминает про отмену.
В режиме разработки StrictMode запускает эффект дважды. В консоли превью видно, что первый запрос сразу отменяется, а данные приходят от второго. В Network рядом с отменённым запросом появится пометка (canceled) — так и должно быть.
Кнопка «Повторить» для ошибки загрузки должна запустить запрос ещё раз. Для этого загрузку выносят в функцию, которую вызывают и из эффекта, и из обработчика кнопки. На пятнадцатом занятии загрузка переедет в стор, и эта задача решится проще.
Почитать ещё
React: Загрузка данных в эффектах
Отправка данных из компонента
Когда пользователь создаёт карточку, компонент отправляет запрос, а после ответа добавляет карточку из ответа в состояние:
type Props = {
onCreated: (card: Card) => void;
};
export function QuickCardForm({ onCreated }: Props) {
const [title, setTitle] = useState('');
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setError(null);
try {
const card = await createCard({ title: title.trim(), type: 'idea' });
onCreated(card);
setTitle('');
} catch (err) {
setError(getErrorMessage(err));
} finally {
setPending(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(event) => setTitle(event.target.value)} aria-label="Заголовок" />
<button type="submit" disabled={pending}>
{pending ? 'Создаём…' : 'Создать'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
}
// На странице доски
<QuickCardForm onCreated={(card) => setCards((prev) => [card, ...prev])} />;
SubmitEventимпортируют изreact. Это тип события отправки формы.Поле ввода связано с состоянием через
valueиonChange. Подробно такие поля разберём на следующем занятии.Состояние карточек меняется после ответа сервера, а новая карточка берётся из ответа: у неё есть
id, автор, время создания и колонка, которые назначил сервер.setCards((prev) => [card, ...prev])добавляет карточку в начало списка иммутабельно.Ошибка сервера показывается рядом с формой. Если отправить пустой заголовок, сервер ответит
422, перехватчик ответа превратит этот ответ вApiError, аgetErrorMessageвозьмёт из неё текстdetail.
Голосование устроено так же: voteCard возвращает обновлённую карточку, и её заменяют в состоянии через map. Счёт при этом не нужно пересчитывать на клиенте.
Почитать ещё
React:
<form>
Литеральные типы и Record
В сгенерированных типах много литеральных объединений: CardType — 'event' | 'idea' | 'question', CardSort — 'new' | 'old' | 'top', VoteValue — 'up' | 'down'. С ними удобно работать через тип Record.
Record<Ключи, Значение> описывает объект, у которого есть все перечисленные ключи, и значение каждого ключа имеет указанный тип:
import type { Card, CardSort, CardType } from '@/shared/api';
export const TYPE_LABELS: Record<CardType, string> = {
event: 'Событие',
idea: 'Идея',
question: 'Вопрос',
};
const COMPARE: Record<CardSort, (a: Card, b: Card) => number> = {
new: (a, b) => b.created_at - a.created_at,
old: (a, b) => a.created_at - b.created_at,
top: (a, b) => b.score - a.score || b.created_at - a.created_at,
};
const sorted = cards.toSorted(COMPARE[sort]);
TYPE_LABELSхранит подпись для каждого типа карточки. Если убрать одну из подписей, TypeScript сообщит, что в объекте не хватает ключа.COMPAREхранит функцию сравнения для каждого способа сортировки. ВыражениеCOMPARE[sort]выбирает нужную функцию безswitch.В функции сравнения для
topпри равном счётеb.score - a.scoreдаёт0, и оператор||переходит к сравнению по времени создания.
Сортировку на доске мы включим на четырнадцатом занятии вместе с остальными фильтрами, и Record с функциями сравнения пригодится там как есть.
Если в API появится новый тип карточки, после генерации типов TypeScript покажет все объекты Record<CardType, ...>, в которые нужно добавить значение. Объект-словарь с Record защищён от пропущенных вариантов так же, как тщательно написанный switch, но записывается короче.
Почитать ещё
TypeScript: Record (англ.)
Утилитарные типы
TypeScript содержит утилитарные типы, которые создают новые типы из существующих. Они позволяют не описывать заново формы данных, которые отличаются от готовых на несколько полей.
import type { Card, CardCreate } from '@/shared/api';
type CardFilters = {
search: string;
onlyMine: boolean;
sort: CardSort;
};
type FiltersChanges = Partial<CardFilters>;
type CardSummary = Pick<Card, 'id' | 'title' | 'score'>;
type CardDraft = Omit<CardCreate, 'type'>;
type ReadonlyCards = Readonly<Card[]>;
Partial<T>делает все поля типа необязательными.Partial<CardFilters>подходит для функции, которая меняет часть фильтров:setFilters({ search: 'хакатон' }).Pick<T, 'a' | 'b'>оставляет только перечисленные поля.Omit<T, 'a'>оставляет все поля, кроме перечисленных.Readonly<T>запрещает изменять значение. У массива типаReadonly<Card[]>нет методовpushиsplice.
function setFilters(changes: Partial<CardFilters>) {
filters = { ...filters, ...changes };
}
setFilters({ onlyMine: true });
setFilters({ sort: 'top', search: '' });
setFilters({ sort: 'best' });
// Ошибка: Type '"best"' is not assignable to type 'CardSort | undefined'.
Утилитарные типы выводятся из исходного типа. Если в CardFilters добавится новое поле, Partial<CardFilters> сразу будет его содержать.
Почитать ещё
TypeScript: Utility Types (англ.)
Кортежи, as const, typeof и keyof
Кортеж — массив фиксированной длины, у каждого элемента которого свой тип:
export function plural(count: number, [one, few, many]: [string, string, string]) {
const tens = count % 100;
const units = count % 10;
if (units === 1 && tens !== 11) return one;
if (units >= 2 && units <= 4 && (tens < 12 || tens > 14)) return few;
return many;
}
plural(5, ['голос', 'голоса', 'голосов']);
plural(5, ['голос', 'голоса']);
// Ошибка: Source has 2 element(s) but target requires 3.
Тип [string, string, string] требует ровно три строки: формы слова для 1, 2 и 5. Кортежем является и результат useState: [значение, функция изменения].
Утверждение as const фиксирует значение: массив становится кортежем только для чтения, а строки — литеральными типами.
const FIELDS = ['title', 'type', 'description'] as const;
// тип: readonly ['title', 'type', 'description']
type FieldName = (typeof FIELDS)[number];
// тип: 'title' | 'type' | 'description'
Оператор
typeofв позиции типа возвращает тип значения:typeof FIELDS— тип константыFIELDS.Запись
[number]берёт тип элементов массива. Вместе получается объединение всех строк из массива.
Оператор keyof возвращает объединение имён полей типа:
type CardKey = keyof Card;
// 'id' | 'title' | 'type' | 'description' | ...
Эти приёмы позволяют держать константы и типы в одном месте. Список полей формы записан один раз, а тип с именами полей выводится из него автоматически.
Почитать ещё
TypeScript: Tuple Types, Typeof Type Operator, Keyof Type Operator (англ.)