Вашему вниманию представляется react-redux-cache (RRC) - легковесная библиотека для загрузки и кэширования данных в React приложениях, которая поддерживает нормализацию, в отличие от React Query и RTK Query, при этом имеет похожий, но очень простой интерфейс. Построена на базе Redux, покрыта тестами, полностью типизирована и написана на Typescript.
RRC можно рассматривать как ApolloClient для протоколов, отличных от GraphQL (хотя теоретически и для него тоже), но с хранилищем Redux - с возможностью писать собственные селекторы (selector), экшены (action) и редьюсеры (reducer), имея полный контроль над кэшированным состоянием.
Зачем?
Далее пойдет сравнение с имеющимися библиотеками для управления запросами и состоянием. Почему вообще стоит пользоваться библиотеками для этого, а не писать все вручную с помощью 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-зависимостями.
npm add react-redux-cache react redux react-redux
Инициализация
Единственная функция, которую нужно импортировать — это 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
)
Заключение
Хоть проект и находится на стадии развития, но уже готов к использованию. Конструктивная критика и квалифицированная помощь приветствуется.
Комментарии (11)
rickets
14.09.2024 12:30redux....
gen1lee Автор
14.09.2024 12:30На хабре принято писать конструктивно - это же не религиозный культ очередного убийцы redux.
markelov69
14.09.2024 12:30Он имеет в виду, что использовать redux(и всё что вокруг и около него) вместо mobx в наше время, это нелепо. Да и в 2019 году уже было нелепо)
gen1lee Автор
14.09.2024 12:30Я думаю что судя по его комментам он бы сказал то же и про mobx в угоду effector, а я бы сказал так про mobx и effector в угоду redux) А в соседней статье любители хайпа отказываются от effector после года мучений и в очередной раз выбирают технологию без должного анализа)
Вот только профессионалы как использовали redux и не имели с ним никаких проблем, так и используют. Потому что это самый простой, а значит лучший инструмент.
clerik_r
А что насчет debouce?
Что насчет race condition?
А почему просто не использовать классику?
Ну и дальше в компоненте
gen1lee Автор
Под классикой вы видимо подразумеваете какой то ваш предыдущий проект, так как лично я многое здесь вижу впервые.
Что ж, по порядку:
Здесь использована магия (не все любят магию) mobx с ООП (многие считают эту парадигму неудачной), обертками компонент (увеличение vdom, стектрейса). Вынесем это за скобки ибо холивар разводить не хотелось бы.
Использована какая то неизвестная мне функция
asyncHelpers
дляdebounce
и отмены запросов.Использован неизвестный мне класс
ApiReq
(опять ООП) в тч для кэширования запросов. Причем в данной реализации нет возможности принудительно запустить обновление данных, например через Pull to refresh - данные минуту всегда будут приходить из кэша. Неприятный баг UX.fetching каждый раз переходит в true, а в конце в false, тем самым показывая на короткое время спинер даже если данные уже закэшированы - UI баг. Возможно потребуется серьезный рефакторинг чтобы это исправить, если не полный отказ от данной архитектуры. Тут нужно смотреть на реализацию ApiReq.
Отсутствует нормализация - плюс множество проблем консистенстности данных в приложении, и увеличивается количеств запросов к серверу. Возможно придется полностью переписать архитектуру, чтобы ее прикрутить.
Данные item можно показать еще пока не погрузились комментарии, но не в данном примере. Потребуется рефакторинг.
Легко ли прикрутить, например, персистентность, SSR и тп?
Итого, мы имеем очередную собственную реализацию управления состоянием запросов, с проблемами, требующими серьезный рефакторинг, либо вообще полное переписывание приложения в случае, если потребуется все их исправлять (а значит на больших проектах - нерешаемых). Именно поэтому лучше не городить "свои решения", а использовать библиотеку, где большинство моментов уже продумали.
По поводу debounce в RRC:
Для query используется throttling - пока идет запрос с определенными параметрами, другие с теми же параметрами отменяются.
Для мутаций используется debounce - каждая следующая мутация отменяет предыдущую, если та еще не завершилась. Для этого вторым параметром в мутации передается abortController.signal.
Race condition там нет.
clerik_r
Object getter/setter магия?) Ну ок) Магия так магия)))
https://stackblitz.com/edit/vitejs-vite-fkgny3?file=src%2Fmain.ts&terminal=dev
Ну да, она легко имплементируется самостоятельно, ее АПИ же видно и понятно как она устроена и работает
Ну да, просто обертка для запросов к апи, она так же имплементируется самостоятельно, там тоже все достаточно просто и понятно исходя из примера использования.
Так то вообще легко, например можно в
fetchData
добавить аргументforce
который вwithCahe
передаст null, что будет означать что кэш мы игнорируем и делаем запрос, опять же т.к. реализация своя можно всё что угодно добавлять.Т.к. MobX сконфигурирован на асинхронные реакции(включая автобатчинг), а они запускаются путём setTimeout, а внутри функции у нас промисты(микротаски), в момент испускания реакции значение fetching будет неизменным true и никаких спиннеров на короткое время не будет.
Вот как выглядит данная конфигурация
Вот как это в действии https://stackblitz.com/edit/vitejs-vite-vslbhn?file=src%2Fmain.ts&terminal=dev
Вы о чем вообще? Пример просто из потолка написан, тут полная свобода, нужна нормализация - легко, не нужна - легко. Причем тут кол-во запросов к серверу? С чего вдруг что-то переписывать надо? Обрабатывайте данные как душе угодно.
Легко, просто код чутка измените под эти нужды и всё, элементарно же.
Да, мы же полностью контролируем код который мы пишем и делаем вспомогательные вещи под наши нужды без ограничений.
Т.е. по вашему все вокруг идиоты, включая меня. И решения написанные нами это дно и их лучше не использовать. А вот если использовать библиотеку которую кто-то там написал(в том числе вы), вот уже совсем другое дело, там настоящий уровень и все так прекрасно и удобно. А если мне/Васе/Пете и т.п. не нравится то, что есть? Как быть? Ну не смешите такими выводами странными. Такие "выводы" канают только для начинающих.
Вы сами придумали "проблемы", которые кстати решаются по щелчку пальца, ибо описанное вами поведение нужно конкретно вам, а конкретно мне нужно другое поведение, а конкретно Васе нужно своё поведение и т.п.
Да любой проект, который писали опытные люди, в том числе классическое решение это функция/класс обертка для запросов к АПИ, в том числе события, на которые можно подписаться и перехватывать запросы/модицифировать их/универсальную обработку ошибок делать/ и т.д. и т.п. Так же классика это когда компоненты, которые используются в разных местах лежат в
src/components
и т.п.gen1lee Автор
Из того что вы ответили получается что вы практически реализовали свой фреймворк на базе mobx, который используете от проекта к проекту, но только не оформили его в библиотеку. Что то в этом фреймворке требует изучения далеко не очевидной конфигурации mobx (как пример где fetching всегда false), что то не реализовано совсем - нормализация, пагинация, персистентность и мн. др., тот же optimistic response, и разумеется чтобы их добавить "просто код чутка измените под эти нужды и всё, элементарно же". Вот только многое из этого не элементарно и потребует больших изменений, довольно багоемких, которые в идеале стоило бы еще и тестами покрыть.
Оформите вашу идею в библиотеку, добавьте туда многое из того что обсудили и то что есть у "конкурентов", и возможно любители mobx ее оценят.
clerik_r
Ну по сути да, за 12 лет из которых 8 лет чистого фронта и множество разных проектов, грех не обзавестись кучей решений и подходов на все случаи жизни
Не надо ничего изучать, тебе сказали 1 раз, реакции асинхронные по умолчанию, отсюда и автобатчинг, и всё, ты это услышал и понял, 5 секунд изучения)
Ну вообще да, без шуток. Любой каприз, берешь и делаешь.
Ну для вас может и больших, багоемких и т.п. А для меня они маленькие, быстрые и достаточно простые, как и для любого опытного разработчика. который привык решать задачи самостоятельно, а не заклеивать дыры библиотеками.
И лишать разработчиков самой важной вещи - думать своей головой, чтобы для них все более и более элементарные вещи становились "проблемой" и под каждый чих они искали библиотеку. Ну нет, не такое будущее разработки я хочу видеть. Эти вещи слишком тривиальные чтобы пользоваться ими в рамках библиотек.
gen1lee Автор
У меня другой опыт - я насмотрелся на опытных разработчиков, реализовывающих "каждый чих" самостоятельно, и все это многократно переписывал. В 95% случаев (если не чаще) все это работает плохо, забаговано, и на больших проектах не подлежит исправлению кроме как "выкинуть и написать с нуля". Про очень многие из лучших паттернов они даже и не в курсе (нормализация, optimistic response и мн. др.). Эти разработчики кстати чаще всего сами не видят никаких проблем в своем коде, и уж тем более не видят проблем в UX приложения.
clerik_r
Для начала с чего вы взяли что они реально опытные?) Например если человек Bob 15 лет работал на 2-3 проектах(а я таких знаю реально, прям лично), и человек Tom работал 5 лет на 10+ проектах, то Tom в 95% случаем будет на 3-4 головы лучшим разработчиком чем Bob который в опыте в годах аж на 10 лет больше работал. И это Tom построит гораздо более лучшую архитектуру на новом проекте, чем Bob, как минимум тупо за счет того что Tom видел в 3-4 раза больше проектов чем Bob.
Ну и конкретно в вашем сценарии вы видели плохих разработчиков, к великому сожалению их большинство( Так же как установщиков кондиционеров в лучшем случае 9 из 10 - плохие откровенно говоря, как тех кто ремонтирует автомобили, и т.д и т.п. Просто такова природа человека, в этом нет ничего удивительного и необычного, более того, таких людей никакие библиотеки не спасут, вот прям от слова совсем.
Откройте ваш проекты/любой проект допустим реактовский и посмотрите сколько в нем в зависимостях библиотек, а теперь внимание, по вашей логике код на этим проектах должен быть в худшем случае идеальный. а если там библиотек больше 20-30, так вообще великолепный, без вариантов, ведь люди столько идеальных готовых решений примели и у них просто нет шансов на ошибки и на плохой код.
А теперь окунемся в реальность. в лучшем случае код на этих проектах будет очень средненьким с натяжкой, а в среднем плохим, иногда ужасным.
Какая мораль? Никакие библиотеки/фреймворки и т.п. ни от чего не спасают(разве что чуть чуть), если руки из нужного места растут, то все будет отлично, как без библиотек, так и с библиотеками, а если нет, то соответственно все будет плохо.
Возможно вы сами себе придумали критерии и по ним оценили что всё работает плохо. А если посмотреть объективно со стороны, то по факту все работает как минимум нормально. а то и хорошо.
Зависит от ваших индивидуальных критериев к коду. Мало ли, может вы пишете вложенные друг в друга теранарки, или не делаете early return в функциях(в том числе реакт компонентах), или вместо async/await пишете цепочки .then и т.д. и т.п. И считаете что это нормально и правильно, а другой код плохой.
Хотя если быть честными самими с собой, то код, который ты читаешь первый раз сверху вниз и понимаешь что он делает, что будет дальше, какой будет результат его выполнения - это хороший код.
А если ты смотришь и не понимаешь, без документации, без задавания вопросов автору кода и т.п. - этот код плохой.
Но бывают и исключения из этих правил. в случаях когда очень сложная и навороченная бизнес логика, в таких случаях код будет плохим неизбежно и без вариантов. Где-то чуть менее плохим, где-то чуть более плохим, но суммарно плохим.