Введение
Популярная библиотека для работы с состоянием веб-приложений на react-js это redux. Однако у нее есть ряд недостатков такие как многословность(даже в связке с redux-toolkit), необходимость выбирать дополнительный слой(redux-thunk, redux-saga, redux-observable). Возникает ощущение, что как-то это все слишком сложнои уже давно появились хуки и в частности хук useContext.. Так что я попробовал другое решение.
Приложение для теста
У меня было простое веб приложение Прогноз погоды написанное с помощью create react app, typescript, redux-toolkit, redux saga. Потом я заменил весь redux на context + react-query. Это очень маленькое, однако рабочее приложение, которым я сам пользуюсь, позволило мне использовать react-query для описания уже существующей логики. Т.е. не делать абстрактный нерабочий проект, который просто раскрывает базовые возможности библиотеки.. В приложении есть выбор городов, получение текущей погоды и прогноза. Т.е. максимум три последовательных запроса к серверу.
Скрины тестового приложенияНовый стэйт
Библиотека react-query позволяет работать запросами к серверу, предоставляет доступ данным, позволяет задавать порядок запросов.. Однако для того чтобы с этим работать надо разделить весь стэйт который есть в redux на 2 части. Первая это как раз данные, полученные с сервера. Вторая это все остальное, в моем случае это города выбранные пользователем.
Вторую часть реализовал с помощью react-context. Примерно так:
export const CitiesProvider = ({ children,}: { children: React.ReactNode;}): JSX.Element => { const [citiesState, setCitiesState] = useLocalStorage<CitiesState>( 'citiesState', citiesStateInitValue, ); const addCity = (id: number) => { if (citiesState.citiesList.includes(id)) { return; } setCitiesState( (state: CitiesState): CitiesState => ({ ...state, citiesList: [...citiesState.citiesList, id], }), ); }; // removeCity.., setCurrentCity.. return ( <СitiesContext.Provider value={{ currentCity: citiesState.currentCity, cities: citiesState.citiesList, addCity, removeCity, setCurrentCity, }} > {children} </СitiesContext.Provider> );};
Реализация не сложная, методы setCurrentCity,
removeCity
аналогичны. Также я написал хук, который
сохраняет все в localStorage при изменении стэйта и подключил его к
провайдеру. В итоге у пользователя есть список выбранных городов,
текущий город, и возможность удалять, добавлять, выбирать
текущий.
React-query
Для загрузки, хранения, обновления данных с сервера использовал библиотеку react-query. Подключается примерно так:
import { QueryClient, QueryClientProvider } from 'react-query';import { ReactQueryDevtools } from 'react-query/devtools';import { CitiesProvider } from './store/cities/cities-provider';const queryClient = new QueryClient();ReactDOM.render(<React.StrictMode><QueryClientProvider client={queryClient}><CitiesProvider><App />
Простой пример использования:
const queryCities = useQuery('cities', fetchCitiesFunc);const cities = queryCities.data || [];
Первый параметр 'cities'
это ключ строка, которая
должна быть уникальной для каждого запроса. Второй - это функция,
которая возвращает Promise, который резолвит данные или отдает
ошибку. Также можно передать третьим параметром объект с
настройками.
useQuery возвращает объект UseQueryResult
, который
содержит данные о состоянии запроса, ошибку или данные
const { isLoading, isIdle, isError, data, error } = useQuery(..
Для выполнения последовательных запросов мне показалось удобным написать отдельный хук
export function useCurrentWeather(): WeatherCache {const { currentCity } = useContext(СitiesContext); // запрашиваем список городовconst queryCities = useQuery('cities', fetchCitiesFunc, {refetchOnWindowFocus: false,staleTime: 1000 * 60 * 1000,});const citiesRu = queryCities.data || [];// ищем идентификатор текущего города..const city = citiesRu.find((city) => {if (city === undefined) return false;const { id: elId } = city;if (currentCity === elId) return true;return false;});const { id: weatherId } = city ?? {}; // запрашиваем текущую погодуconst queryWeatherCity = useQuery(['weatherCity', weatherId],() => fetchWeatherCityApi(weatherId as number),{enabled: !!weatherId,staleTime: 5 * 60 * 1000,},);const { coord } = queryWeatherCity.data ?? {}; // запрашиваем прогноз по координатам из предыд. запросаconst queryForecastCity = useQuery(['forecastCity', coord],() => fetchForecastCityApi(coord as Coord),{enabled: !!coord,staleTime: 5 * 60 * 1000,},);return {city,queryWeatherCity,queryForecastCity,};}
staleTime
Время, по истечении которого, данные
считаются устаревшими. Устаревшие данные перезапрашиваются
автоматически при монтировании нового экземпляра, перефокусировке
или переподключении сети. Интересно, что по умолчанию
staleTime =0
.
enabled: !!weatherId
, Эта настройка позволяет
выполнять запрос только при определенном условии. Пока условие не
будет выполнено useQuery
будет возвращать состояние
isIdle
. Таким образом можно описать последовательность
выполнения запросов.
const queryWeatherCity = useQuery(['weatherCity', weatherId],..
Ключ может быть как строкой так и массивом, содержащим строку и неограниченное количество сериализуемых объектов, например строка + идентификатор.
Вот так использую этот хук в компоненте:
export function Forecast(): React.ReactElement {const {queryForecastCity: { isFetching, isLoading, isIdle, data: forecast },} = useCurrentWeather();if (isIdle) return <LoadingInfo text="Ожидание загрузки дневного прогноза" />;if (isLoading) return <LoadingInfo text="Загружается дневной прогноз" />;const { daily = [], alerts = [], hourly = [] } = forecast ?? {};const dailyForecastNext = daily.slice(1) || [];return (<><Alerts alerts={alerts} /><HourlyForecast hourlyForecast={hourly} /><DailyForecast dailyForecast={dailyForecastNext} />{isFetching && <LoadingInfo text="Обновляется дневной прогноз" />}</>);}
Есть два разных состояния isLoading это первая загрузка и isFetching - это обновление.
Инструменты разработчика
У React-query есть возможность вывести окошко инструментов разработчика. Оно немного похоже на окно Redux, но появляется в виде фиксированного окошка поверх приложения(можно закрыть и останется только кнопка)
Окно инструментов разработчикаЕсть информация о состоянии каждого запроса, также есть кнопки Actions, можно вручную производить перезапрос, очистку, удаление.. Если учитывать, что библиотека никак не модифицирует полученные данные, то многое можно увидеть и просто в инструментах разработчика браузера, в разделе сеть. Но все же эти инструменты существенно расширяют возможности отладки. Подключаются они в одну строчку:
import { ReactQueryDevtools } from 'react-query/devtools';
В документации сказано, что при process.env.NODE_ENV ===
'production'
, в релизную сборку это не попадет
автоматически. У меня в Create React App все корректно.
Другие возможности
Также у react-query есть возможности, которые мне не понадобились, однако я все же опишу некоторые из них, примеры кода будут из документации.
-
useQueries
позволяет динамически формировать массив запросов. Это нужно т.к. мы не можем опционально вызывать хукиuseQuery
.
const userQueries = useQueries(users.map(user => {return {queryKey: ['user', user.id],queryFn: () => fetchUserById(user.id),}})
-
По умолчанию настроен автоматический перезапрос данных, при получении ошибки, 3 попытки. Это можно настроить с помощью конфига
retry
. -
Для запросов на создание, обновление, удаление данных есть хук
useMutations
const mutation = useMutation(newTodo => axios.post('/todos', newTodo))
-
Можно делать постраничные запросы, для бесконечных запросов есть хук
useInfiniteQuery
-
Также есть предзагрузка, инвалидация запросов, оптимистичное обновление и еще много всего, что можно посмотреть в документации.
Заключение
После замены redux-toolkit + redux-saga и context + react-query код мне показался значительно проще и я получил из коробки больший функционал для работы с запросами к серверу. Однако часть с react-context не имеет специальных инструментов отладки и вообще вызывает опасения, но она оказалось совсем небольшой и мне вполне хватило react-devtools. В целом я доволен библиотекой react-query и вообще идея отделения кэша в отдельную сущность кажется мне интересной. Но все же это очень маленькое приложение с несколькими get запросами..