Вашему вниманию представляется react-redux-cache (RRC) - легковесная библиотека для загрузки и кэширования данных в React приложениях, которая поддерживает нормализацию, в отличие от React Query и RTK Query, при этом имеет похожий, но очень простой интерфейс. Построена на базе Redux, покрыта тестами, полностью типизирована и написана на Typescript.
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 | + | - | + | + |
Нормализация | - | + | - | + |
Бесконечная пагинация | + | + | - | + |
Не переусложнена | + | - | - | + |
Популярность | + | + | - | - |
Почему только 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": {loading: false, error: undefined, result: 2, params: 2},
"3": {loading: true, params: 3}
},
getUsers: {
// Пример состояния с пагинацией под переопределенным ключом кэша (см. далее в пункте про пагинацию)
"all-pages": {
loading: false,
result: {items: [0,1,2], page: 1},
params: {page: 1}
}
}
},
mutations: {
// Каждая мутация так же имеет свое состояния
updateUser: {
loading: false,
result: 1,
params: {id: 1, name: "User 1 *"}
}
}
}
Пример состояния redux без нормализации
{
// Словарь сущностей используется только для нормализации, и здесь пуст
entities: {},
queries: {
// Каждый запрос имеет свой словарь состояний, хранящихся по ключу кэша, генерируемого из параметров запроса
getUser: {
"2": {
loading: false,
error: undefined,
result: {id: 2, bank: {id: "2", name: "Bank 2"}, name: "User 2"},
params: 2
},
"3": {loading: true, params: 3}
},
getUsers: {
// Пример состояния с пагинацией под переопределенным ключом кэша (см. далее в пункте про пагинацию)
"all-pages": {
loading: false,
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: {
loading: false,
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
export const {
cache,
reducer,
hooks: {useClient, useMutation, useQuery},
} = createCache({
// Используется как префикс для экшенов и в селекторе выбора состояния кэша из состояния redux
name: 'cache',
// Словарь соответствия нормализованных сущностей их типам TS
// Можно оставить пустым, если нормализация не нужна
typenames: {
users: {} as User, // здесь сущности `users` будут иметь тип `User`
banks: {} as Bank,
},
queries: {
getUsers: { query: getUsers },
getUser: { query: getUser },
},
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
Результат запроса должен быть типа QueryResponse
, результат мутации — типа 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 ...
}
Продвинутые возможности
Расширенная политика кэширования
По умолчанию политика cache-first
не загружает данные, если результат уже закэширован, но иногда она не может определить, что данные уже присутствуют в ответе другого запроса или нормализованном кэше. В этом случае можно использовать параметр skip:
export const UserScreen = () => {
...
const user = useSelectEntityById(userId, 'users')
const [{loading, error}] = useQuery({
query: 'getUser',
params: userId,
skip: !!user // Пропускаем запрос, если пользователь уже закэширован ранее, например, запросом getUsers
})
...
}
Мы можем дополнительно проверить, достаточно ли полный объект, или, например, время его последнего обновления:
skip: !!user && isFullUser(user)
Другой подход — установить skip: true и вручную запускать запрос, когда это необходимо:
export const UserScreen = () => {
const screenIsVisible = useScreenIsVisible()
const [{result, loading, error}, fetchUser] = useQuery({
query: 'getUser',
params: userId,
skip: true
})
useEffect(() => {
if (screenIsVisible) {
fetchUser()
}
}, [screenIsVisible])
...
}
Бесконечная прокрутка с пагинацией
Вот пример конфигурации запроса 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
)
Заключение
Хоть проект и находится на стадии развития, но уже готов к использованию. Конструктивная критика и квалифицированная помощь приветствуется.