Привет! Меня зовут Владислав Панов, я фронтенд-разработчик в KODE. Два с половиной года назад, когда мы выбирали стек для проектов, чтобы хранить все пользовательские данные, решили использовать Redux. Он до сих пор хорошо справляется с управлением состоянием, связанным с клиентской логикой и UI, но при хранении статусов, ошибок и прочей информации о результатах выполнения запросов появляется много бойлерплейта.
Несмотря на появление Redux Toolkit от авторов Redux, который сильно упрощает жизнь, мы к нему так и не вернулись. Почему? Рассказываю в статье.
В чём заключается проблема
При использовании Redux наша архитектура становилась всё более громоздкой из-за бойлерплейта, дополнительное время уходило на ручной ввод схожего по функционалу кода. Нам приходилось вручную добавлять типизацию и информацию о запросах из Swagger'а или клиентских требований. При ручном вводе всегда высока вероятность появления ошибок из-за человеческого фактора или желания внедрить новый подход на существующей кодовой базе. Дошло до того, что на некоторых проектах вырос технический долг, возврат которого может затянуться в лучшем случае на месяцы.
Чтобы не повторять этих ошибок, перед стартом нового проекта с долгосрочной поддержкой мы стали искать способы улучшить процесс разработки и разгрузить себя от рутины.
Наш путь и выбор вариантов улучшения стека
Чтобы решить проблему ручного ввода, мы хотели использовать кодогенерацию по схеме от бэкенда, чтобы получить типизацию и готовый код, который упростит работу с запросами и сэкономит время разработчиков.
Важно учитывать, что сама спецификация должна быть хорошо составлена, не содержать ошибок и следовать стандартам, описанным в официальной документации OpenAPI.
В качестве основного инструмента для кодогенерации мы выбрали openapi-generator от OpenAPI Tools. Его просто использовать и можно гибко настраивать. Ключевая особенность — широкий выбор «шаблонов», которые позволяют создать код для множества языков и используемых в них библиотек, например:
javascript-flowtyped
,typescript-fetch
,typescript-axios
.
Для работы с запросами и хранения статусов об их выполнении решили найти новую библиотеку, которая возьмёт на себя управление кэшированием и избавит от бойлерплейта.
Сейчас существует множество таких вариантов. Команда Redux предлагает набор инструментов Redux Toolkit с RTK Query, которые избавляют от кучи шаблонного кода. При этом всё ещё придётся, пусть в меньшем количестве, но создавать reducer'ы, middleware, а нам хотелось отойти от этого. Другой способ — useSWR, который не зависит ни от какого стейт-менеджера и целиком использует хуки для вызова запроса.
Наш выбор пал на библиотеку React Query, которая также применяет хуки для работы с API. И вот почему:
Гибкость в настройке и больший функционал по сравнению с useSWR, RTK Query.
Собственные удобные Devtools.
Удобство работы с бесконечными списками, пагинацией прямиком из коробки.
Таким образом у OpenAPI Generator и React Query свои зоны ответственности: OpenAPI Generator помогает нам с типизацией и избавляет от ручного написания кода для вызова запросов, а React Query — за кэширование, работу с запросами, их инвалидацию и не только. Забегая вперёд, скажу, что сейчас использование этих инструментов стало стандартом на всех наших проектах.
Переход от старых инструментов к новым
Поскольку мы перешли на новую библиотеку, нам нужно было перестроить своё мышление и писать по-другому. У инструментов, которые мы выбрали, есть свои особенности.
Из всех шаблонов для кодогенерации мы остановились на typescript-axios
по двум причинам:
Типизация — это круто, так как мы можем всегда посмотреть в коде актуальную информацию об ответах на запросы.
Axios — мощная обёртка при работе с запросами, которая позволяет использовать интерсепторы (interceptors) для перехвата некоторых запросов и специальный конфиг, генерируемый шаблоном, который позволяет удобным образом проставлять заголовки, основной адрес запросов и подставлять токены или API-ключи в зависимости от параметра
securitySchemes
в OpenAPI-спецификации.
В отличие от Redux и связанных с ней подходов у React Query есть ряд особенностей:
React Query использует хуки, потому на место компонентов высшего порядка (HOC) приходят обычные функциональные компоненты.
По умолчанию данные отделены от любого из хранилищ (
localStorage
,sessionStorage
) и остаются только в рантайме. При желании их можно удобным образом персистить в любом из удобных клиентских хранилищ.
Одним из популярных подходов по разделению ответственности является создание «умных» и «глупых» компонентов. «Глупый» компонент содержит только вёрстку и принимает значения, на основе которых понимает, что и как рендерить. «Умный» — управляет состоянием, содержит в себе необходимую бизнес-логику и передаёт форматированные данные в виде пропсов в «глупый» компонент.
На каждый запрос мы будем создавать отдельный хук, где будем указывать ключ, по которому должен храниться ответ запроса и задавать конфигурацию. Например, можем настроить хук так, чтобы он вызывал запрос каждые несколько минут.
Настраиваем кодогенерацию
В качестве примера попробуем создать небольшое приложение на основе спецификации petstore. Нам нужно будет отобразить список животных и добавить возможность удалять их из списка.
Для создания интерфейса будет использоваться MUI, чтобы приложение выглядело более-менее приемлемо.
Создав приложение через CRA или Vite, настроим кодогенерацию. Для этого нам необходимо установить библиотеку openapi-generator-cli
:
npm install @openapitools/openapi-generator-cli -D
или через yarn
:
yarn add -D @openapitools/openapi-generator-cli
Данный CLI является nodejs-обёрткой для работы с генератором, изначально написанным на Java. Потому нам потребуется ещё установить Java (не меньше 8-й версии). Если её нет, скачайте с официального сайта.
После установки всех зависимостей в корне проекта необходимо создать файл openapitools.json
, чтобы сконфигурировать кодогенерацию. Внутри конфига укажем адрес схемы, шаблон, в какой папке должен оказаться сгенерированный код и дополнительные параметры.
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "6.0.1",
"generators": {
"v1.0": {
"generatorName": "typescript-axios",
"output": "./src/shared/api/axios-client",
"skipValidateSpec": true,
"inputSpec": "https://petstore3.swagger.io/api/v3/openapi.json",
"additionalProperties": {
"supportsES6": true,
"useSingleRequestParameter": true
}
}
}
}
}
Сама генерация запускается максимально просто, одной командой:
yarn openapi-generator-cli generate
После её выполнения у нас подтягивается схема от бэка по адресу в inputSpec
и создаётся папка src/shared/api/axios-client
, в которой будут сгенерированы типы моделей и созданы классы для вызова запросов.
Для каждой группы методов по тегу создаётся свой класс, принимающий в качестве одного из параметров объект конфигурации. Внутри неё мы можем задать домен, передать конфиг axios
или указать, откуда брать токен для каждого запроса. В данном примере мы обойдёмся без конфигураций, так как запросы petstore
можно вызывать без авторизационного токена.
Прежде чем начать работу, создадим экземпляр класса PetApi
, чтобы через него вызывать запросы с тегом pet
в спецификации.
const petApi = new PetApi()
Теперь, когда у нас есть экземпляр класса с методами, остаётся написать хуки, которые будут вызывать запросы и возвращать информацию о статусах и результатах.
Здесь нам и поможет React Query. Прежде чем начать работу, нужно обернуть приложение в QueryClientProvider
и передать в него экземпляр queryClient
. Именно queryClient
будет отвечать за хранение полученных данных и позволит вручную изменять их или помечать как неактуальные.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './app';
const queryClient = new QueryClient();
export const AppConnector = () => {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
};
После этого, чтобы получить список животных от сервера, разберёмся с хуком useQuery
(документация). Первым параметром useQuery
принимает массив ключей, по которым будут храниться данные. В качестве ключей могут быть любые сериализуемые типы данных. Ключи желательно выносить в отдельные переменные, потому что по ним можно вручную удалять или обновлять нужные нам данные. Вторым параметром задаётся функция, которая будет получать данные от сервера.
import { PetStatusEnum } from '@shared/api/axios-client';
import { petApi } from '@shared/api/user-client';
import { useQuery } from '@tanstack/react-query';
export const useGetPetsByStatus = (status: PetStatusEnum) => {
const query = useQuery(['pets'], () => petApi.findPetsByStatus({ status }));
return query;
};
Данный хук мы будем импортировать в коннекторе, чтобы передать данные в UI. Результат получается весьма минималистичным и простым. Ранее написанный useGetPetsByStatus
получает данные и статус по запросу, а getPetListData
преобразовывает данные в удобный формат для «глупых» UI-компонентов.
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
getPetListData,
useGetPetsByStatus,
} from '@entities/pet';
export const PetListWidgetConnector = () => {
const { data, isLoading, isError } = useGetPetsByStatus('available');
const items = getPetListData(data?.data) ?? [];
return (
<PetList
items={items}
isLoading={isLoading}
hasError={isError}
onClick={onClickPet}
/>
);
};
Но что делать, если нам хочется вызывать некоторые запросы программно, а не по мере отображения компонентов? Например, мы хотим показывать модалку с данными о животном, чтобы по нажатию кнопки удалить его из списка.
Именно для этих целей используется хук useMutation
(документация). На первый взгляд он может показаться аналогичным useQuery
, но одной из его особенностей является возможность указывать колбэки, связанные с жизненным циклом мутации. Мы можем указать onError
или onSuccess
, чтобы показывать различные уведомления при успешном или провальном запросе.
Для примера напишем хук, который позволит удалять животного и при завершении будет перезапрашивать данные для списка всех животных. Мы воспользуемся колбэком onSettled
, который будет отрабатывать при любом завершении запроса. Внутри него будем вызывать у queryClient
метод invalidateQueries
, который позволяет пометить некоторые данные как неактуальные, чтобы потом их перезапросить. Чтобы не сбросить абсолютно весь кэш, invalidateQueries
принимает ключи, по которым можно пометить только некоторые запросы как неактуальные.
import { useMutation } from '@tanstack/react-query';
import { petApi } from '@shared/api/user-client';
export const useDeletePet = () => {
const queryClient = useQueryClient();
const mutation = useMutation((petId: number) => petApi.deletePet({ petId }), {
onSettled: () => {
queryClient.invalidateQueries(['pets']);
},
});
return mutation;
};
Затем внутри коннектора вызовем хук, чтобы получить из него mutateAsync
и вызывать запрос при нажатии на кнопку:
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { PetList, PET_QUERY_KEYS } from '@entities/pet';
import {
PetDetailsModal,
useDeletePet,
} from '@features/pet-details-modal';
export const PetListWidgetConnector = () => {
…
const queryClient = useQueryClient();
const { mutateAsync, isLoading: isFetchingDeletion } = useDeletePet();
const onDeletePet = () => {
if (selectedPetId) {
mutateAsync(selectedPetId);
}
};
…
return (
<>
<PetDetailsModal
isOpen={isModalOpen}
petDetails={selectedPetDetails}
onClose={onCloseModal}
onDeletePet={onDeletePet}
isFetchingDeletion={isFetchingDeletion}
/>
<PetList …/>
</>
);
};
С кодом проекта можно ознакомиться на Гитхабе. А сам пример проекта — посмотреть по ссылке.
Наш опыт использования
Генерация напрямую зависит от составленной OpenAPI схемы, поэтому важно тесное взаимодействие с бэкендом, так как вся типизация напрямую зависит от её оформления. Если в схеме допущены ошибки, не указаны обязательные параметры, а бэк, к примеру, на стороне заказчика не спешит с обновлением спецификации, то стоит задуматься, нужна ли вам такая боль, когда быстрее всё сделать вручную.
При таком подходе теряется гибкость и всё завязывается вокруг шаблонов генератора. Если у вас возникнут проблемы с генерируемым кодом и захочется поменять его структуру или оформление, вы можете создать свой шаблон и даже генератор.
Используя данные решения, мы столкнулись со следующими сложностями и нюансами:
Из-за React Query получается сильная привязка к UI, в который может выноситься часть логики.
Из-за сильной привязки к UI тяжелее тестировать логику, потому что она может быть разделена между несколькими компонентами.
Редактируя вручную спецификацию, опечатки или неправильные отступы можно сломать всю кодгенерацию. Потому важно использовать линтеры спецификаций вроде Spectral.
Но даже несмотря на всё это, используя кодгенерацию и React Query, нам удалось достигнуть нескольких улучшений в процессе разработки:
У нас появился единый источник правды по запросам и упростилось общение с бэком.
Мы стали меньше времени тратить на типизацию и создание обёрток для работы с запросами.
Образовался единый подход по работе с API.
Возникло чёткое отделение всего сетевого слоя приложения.
artalar
Проблема отдельных либ обработки состояния сетевого кеша в том что они не очень дружелюбны к другим стейтам (есть множество неприятных краевых случаев).
Поэтому я сначала написал современный и легкий стейт-менеджер, а потом сверху накрутил либу для обработки асинхронных запросов: https://www.reatom.dev/packages/async
Фич больше, а разница: 8.87KB против 44.61KB