Библиотека RRC для управления запросами и кэшем на базе Redux: [лучшая] альтернатива RTK-Query и другим решениям [14.09.2024]

Вашему вниманию представляется 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 QueryApollo ClientRTK-QueryRRC
Полный доступ хранилищу--+-*+
Поддержка 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
)

Другие примеры использования можно найти в документации.

Заключение

Хоть проект и находится на стадии развития, но уже готов к использованию. Конструктивная критика и квалифицированная помощь приветствуется.

npmgithubобсудить: githubобсудить: хабр