Вашему вниманию представляется react-redux-cache (RRC) - легковесная библиотека для загрузки и кэширования данных в React приложениях, которая поддерживает нормализацию, в отличие от React Query и RTK Query, при этом имеет похожий, но очень простой интерфейс. Построена на базе Redux, покрыта тестами, полностью типизирована и написана на Typescript.
Так же, в отличие от RTK Query, без проблем поддерживается такая базовая вещь, как бесконечная прокрутка с пагинацией (см. пример далее).
RRC можно рассматривать как ApolloClient для протоколов, отличных от GraphQL (хотя теоретически и для него тоже), но с хранилищем Redux - с возможностью писать собственные селекторы (selector), экшены (action) и редьюсеры (reducer), имея полный контроль над кэшированным состоянием.
Принципы, с которыми создавалась библиотека:
- Максимальная простота, минимум ограничений. Все самые необходимые функции доступны из коробки, остальное - не проблема реализовать.
- Высокая производительность.
- Высокое покрытие тестами, 100%-ая типизация, нетерпимость к багам.
- Функциональный стиль.
Зачем?
Далее пойдет сравнение с имеющимися библиотеками для управления запросами и состоянием. Почему вообще стоит пользоваться библиотеками для этого, а не писать все вручную с помощью useEffect / redux-saga и тп - оставим эту тему для других статей.
-
Полный контроль над хранилищем не только дает больше возможностей, упрощает отладку и написание кода, но и позволяет городить меньше костылей если задача выходит за рамки типичного hello world из документации, не тратя огромное время на страдания с очень сомнительными интерфейсами библиотек и чтением огромных исходников.
-
Redux это отличный - простой и проверенный инструмент для хранения “медленных” данных, то есть тех, что не требуют обновления на каждый кадр экрана / каждое нажатие клавиши пользователем. Порог входадля тех, кто знаком с библиотекой - минимальный. Экосистема предлагает удобную отладку и множетсво готовых решений, таких как хранение состояния на диске (redux-persist). Написан в функциональномстиле.
-
Нормализация - это лучший способ поддерживать согласованное состояние приложения между различными экранами, сокращает количество запросов и без проблем позволяет сразу отображать кэшированные данные при навигации, что значительно улучшает пользовательский опыт. А аналогов, поддерживающих нормализацию, практически нет - ApolloClient поддерживает только протокол GraphQL, и сделан в весьма сомнительном, переусложненном ООП стиле.
-
Легковесность, как размера библиотеки, так и ее интерфейса - еще одно преимущество. Чем проще, тем лучше - главное правило инженера, и данной конкретной библиотеки.
Краткое сравнение библиотек в таблице:
React Query | Apollo Client | RTK-Query | RRC | |
---|---|---|---|---|
Полный доступ хранилищу | - | - | +-* | + |
Поддержка REST | + | - | + | + |
Нормализация | - | + | - | + |
Бесконечная пагинация | + | + | -** | + |
Не переусложнена*** | + | - | - | + |
Популярность | + | + | -**** | - |
* - Отсутствуют экшены для изменения состояния вручную.
** - Да, вот такой "мусор" этот RTK-Query.
*** - Моя субъективная оценка, где учитывается кривая обучения, удобство использования, сложность выполнения простых задач, таких как реализация бесконечной пагинации, сохранение данных на диске и другие аспекты.
**** - RTK в целом имеет наименьшее количество звёзд по сравнению с другими популярными библиотеками, а RTK-Query, являясь его необязательной частью, пользуется ещё меньшей популярностью.
Почему только React?
Поддержка всевозможных UI библиотек кроме самой популярной (используемой в том числе в React Native) - усложнение, на которое я пока не готов.
Примеры
Для запуска примеров из папки /example
используйте npm run example
. Доступны три примера:
- С нормализацией (рекомендуется).
- Без нормализации.
- Без нормализации, оптимизированный.
Данные примеры - лучшее доказательство того, как сильно зависит пользовательский опыт и нагрузка на серверы от реализации клиентского кэширования. В плохих реализациях на любую навигацию в приложении:
- пользователь вынужден наблюдать спинеры и прочие состояния загрузки, будучи заблокированным в своих действиях, пока она не закончится.
- запросы постоянно отправляются, даже если имеющиеся данные все еще достаточно свежие.
Пример состояния redux с нормализацией
{
entities: {
// Каждый тип имеет свой словарь сущностей, хранящихся по id
users: {
"0": {id: 0, bankId: "0", name: "User 0 *"},
"1": {id: 1, bankId: "1", name: "User 1 *"},
"2": {id: 2, bankId: "2", name: "User 2"},
"3": {id: 3, bankId: "3", name: "User 3"}
},
banks: {
"0": {id: "0", name: "Bank 0"},
"1": {id: "1", name: "Bank 1"},
"2": {id: "2", name: "Bank 2"},
"3": {id: "3", name: "Bank 3"}
}
},
queries: {
// Каждый запрос имеет свой словарь состояний, хранящихся по ключу кэша, генерируемого из параметров запроса
getUser: {
"2": {result: 2, params: 2, expiresAt: 1727217298025},
"3": {loading: true, params: 3}
},
getUsers: {
// Пример состояния с пагинацией под переопределенным ключом кэша (см. далее в пункте про пагинацию)
"all-pages": {
result: {items: [0,1,2], page: 1},
params: {page: 1}
}
}
},
mutations: {
// Каждая мутация также имеет свое состояния
updateUser: {
result: 1,
params: {id: 1, name: "User 1 *"}
}
}
}
Пример состояния redux без нормализации
{
// Словарь сущностей используется только для нормализации, и здесь пуст
entities: {},
queries: {
// Каждый запрос имеет свой словарь состояний, хранящихся по ключу кэша, генерируемого из параметров запроса
getUser: {
"2": {
result: {id: 2, bank: {id: "2", name: "Bank 2"}, name: "User 2"},
params: 2,
expiresAt: 1727217298025
},
"3": {loading: true, params: 3}
},
getUsers: {
// Пример состояния с пагинацией под переопределенным ключом кэша (см. далее в пункте про пагинацию)
"all-pages": {
result: {
items: [
{id: 0, bank: {id: "0", name: "Bank 0"}, name: "User 0 *"},
{id: 1, bank: {id: "1", name: "Bank 1"}, name: "User 1 *"},
{id: 2, bank: {id: "2", name: "Bank 2"}, name: "User 2"}
],
page: 1
},
params: {page: 1}
}
}
},
mutations: {
// Каждая мутация также имеет свое состояния
updateUser: {
result: {id: 1, bank: {id: "1", name: "Bank 1"}, name: "User 1 *"},
params: {id: 1, name: "User 1 *"}
}
}
}
Установка
react
, redux
и react-redux
являются peer-зависимостями.
fast-deep-equal
- опциональная peer-зависимость, если нужно глубокое сравнение для оптимизации ре-рендеринга (deepComparisonEnabled
, по умолчанию true
).
npm add react-redux-cache react redux react-redux fast-deep-equal
Инициализация
Единственная функция, которую нужно импортировать — это createCache
, которая создаёт полностью типизированные редьюсер, хуки, экшены, селекторы и утилиты для использования в приложении. Можно создать столько кэшей, сколько нужно, но учтите, что нормализация не переиспользуется между ними. Все типы, запросы и мутации должны быть переданы при инициализации кэша для корректной типизации.
cache.ts
// Cоответствие нормализованных сущностей их типам
// Не используется если нормализация не нужна, достаточно импортировать `createCache` вместо `withTypenames`
export type CacheTypenames = {
users: User // сущности `users` будут иметь тип `User`
banks: Bank
}
export const {
cache,
reducer,
hooks: {useClient, useMutation, useQuery},
} = withTypenames<CacheTypenames>().createCache({
// Используется как префикс для экшенов и в селекторе выбора состояния кэша из состояния redux
name: 'cache',
queries: {
getUsers: { query: getUsers },
getUser: {
query: getUser,
secondsToLive: 5 * 60 // Здесь кэш валиден в течение 5 минут.
},
},
mutations: {
updateUser: { mutation: updateUser },
removeUser: { mutation: removeUser },
},
})
Для нормализации требуется две вещи:
- Задать typenames при создании кэша - список всех сущностей и соответствующие им типы TS.
- Возвращать из функций query или mutation объект, содержащий помимо поля result данные следующего типа:
type EntityChanges<T extends Typenames> = {
// Сущности, что будут объединены с имеющимися в кэше
merge?: PartialEntitiesMap<T>
// Сущности что заменят имеющиеся в кэше
replace?: Partial<EntitiesMap<T>>
// Идентификаторы сущностей, что будут удалены из кэша
remove?: EntityIds<T>
// Алиас для `merge` для поддержки библиотеки normalizr
entities?: EntityChanges<T>['merge']
}
store.ts
Создайте store как обычно, передав новый редьюсер кэша под именем кэша. Если нужна другая структура redux, нужно дополнительно передать селектор состояния кэша при создании кэша.
const store = configureStore({
reducer: {
[cache.name]: reducer,
...
}
})
api.ts
Тип результата запроса должен быть NormalizedMutationResponse
или QueryResponse
, результата мутации — NormalizedMutationResponse
или MutationResponse
. Для нормализации в этом примере используется пакет normalizr, но можно использовать другие инструменты, если результат запроса соответствует нужному типу. В идеале - бэкэнд возвращает уже нормализованные данные.
По части race condition:
- Для query используется throttling - пока идет запрос с определенными параметрами, другие с теми же параметрами отменяются.
- Для мутаций используется debounce - каждая следующая мутация отменяет предыдущую, если та еще не завершилась. Для этого вторым параметром в мутации передается abortController.signal.
// Пример запроса с нормализацией (рекомендуется)
export const getUser = async (id: number) => {
const result = await ...
const normalizedResult: {
// result - id пользователя
result: number
// entities содержат все нормализованные сущности
entities: {
users: Record<number, User>
banks: Record<string, Bank>
}
} = normalize(result, getUserSchema)
return normalizedResult
}
// Пример запроса без нормализации
export const getBank = (id: string) => {
const result: Bank = ...
return {result}
}
// Пример мутации с нормализацией
export const removeUser = async (id: number, abortSignal: AbortSignal) => {
await ...
return {
remove: { users: [id] }, // result не задан, но указан id пользователя, что должен быть удален из кэша
}
}
UserScreen.tsx
export const UserScreen = () => {
const {id} = useParams()
// useQuery подключается к состоянию redux, и если пользователь с таким id уже закэширован,
// запрос не будет выполнен (по умолчанию политика кэширования 'cache-first')
const [{result: userId, loading, error}] = useQuery({
query: 'getUser',
params: Number(id),
})
const [updateUser, {loading: updatingUser}] = useMutation({
mutation: 'updateUser',
})
// Этот hook возвращает сущности с правильными типами — User и Bank
const user = useSelectEntityById(userId, 'users')
const bank = useSelectEntityById(user?.bankId, 'banks')
if (loading) {
return ...
}
return ...
}
Продвинутые возможности
Бесконечная прокрутка с пагинацией
Вот пример конфигурации запроса getUsers
с поддержкой бесконечной пагинации - фичи, недоступной в RTK-Query (facepalm). Полную реализацию можно найти в папке /example
.
// createCache
...
} = createCache({
...
queries: {
getUsers: {
query: getUsers,
getCacheKey: () => 'all-pages', // Для всех страниц используется единый ключ кэша
mergeResults: (oldResult, {result: newResult}) => {
if (!oldResult || newResult.page === 1) {
return newResult
}
if (newResult.page === oldResult.page + 1) {
return {
...newResult,
items: [...oldResult.items, ...newResult.items],
}
}
return oldResult
},
},
},
...
})
// Компонент
export const GetUsersScreen = () => {
const [{result: usersResult, loading, error, params}, fetchUsers] = useQuery({
query: 'getUsers',
params: 1 // страница
})
const refreshing = loading && params === 1
const loadingNextPage = loading && !refreshing
const onRefresh = () => fetchUsers()
const onLoadNextPage = () => {
const lastLoadedPage = usersResult?.page ?? 0
fetchUsers({
query: 'getUsers',
params: lastLoadedPage + 1,
})
}
const renderUser = (userId: number) => (
<UserRow key={userId} userId={userId}>
)
...
return (
<div>
{refreshing && <div className="spinner" />}
{usersResult?.items.map(renderUser)}
<button onClick={onRefresh}>Refresh</button>
{loadingNextPage ? (
<div className="spinner" />
) : (
<button onClick={onLoadNextPage}>Load next page</button>
)}
</div>
)
}
redux-persist
Вот простейшая конфигурация redux-persist:
// Удаляет `loading` и `error` из сохраняемого состояния
function stringifyReplacer(key: string, value: unknown) {
return key === 'loading' || key === 'error' ? undefined : value
}
const persistedReducer = persistReducer(
{
key: 'cache',
storage,
whitelist: ['entities', 'queries'], // Cостояние мутаций не сохраняем
throttle: 1000, // ms
serialize: (value: unknown) => JSON.stringify(value, stringifyReplacer),
},
cacheReducer
)
Другие примеры использования можно найти в документации.
Заключение
Хоть проект и находится на стадии развития, но уже готов к использованию. Конструктивная критика и квалифицированная помощь приветствуется.