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

83c14f0ee9f031dd228e1d4c6fd7565a.png

Вашему вниманию представляется 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 = {  
  // Сущности, что будут объединены с имеющимися в кэше
  merge?: PartialEntitiesMap
  // Сущности что заменят имеющиеся в кэше
  replace?: Partial>
  // Идентификаторы сущностей, что будут удалены из кэша
  remove?: EntityIds
  // Алиас для `merge` для поддержки библиотеки normalizr
  entities?: EntityChanges['merge']
}

store.ts

Создайте store как обычно, передав новый редьюсер кэша под именем кэша. Если нужна другая структура redux, нужно дополнительно передать селектор состояния кэша при создании кэша.

const store = configureStore({
  reducer: {
    [cache.name]: reducer,
    ...
  }
})

api.ts

Результат запроса должен быть типа QueryResponse, результат мутации — типа MutationResponse. Для нормализации в этом примере используется пакет normalizr, но можно использовать другие инструменты, если результат запроса соответствует нужному типу. В идеале — бэкэнд возвращает уже нормализованные данные.

// Пример запроса с нормализацией (рекомендуется)

export const getUser = async (id: number) => {
  const result = await ...
  
  const normalizedResult: {
    // result - id пользователя
    result: number
    // entities содержат все нормализованные сущности
    entities: {
      users: Record
      banks: Record
    }
  } = normalize(result, getUserSchema)

  return normalizedResult
}

// Пример запроса без нормализации

export const getBank = (id: string) => {
  const result: Bank = ...
  return {result}
}

// Пример мутации с нормализацией

export const removeUser = async (id: number) => {
  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) => (
    
  )

  ...

  return (
    
{refreshing &&
} {usersResult?.items.map(renderUser)} {loadingNextPage ? (
) : ( )}
) }

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
)

Заключение

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

© Habrahabr.ru