[лучшая] alternative to RTK-Query and other solutions

We present to your attention react-redux-cache (RRC) is a lightweight library for loading and caching data in React applications that supports normalization, unlike React Query And RTK Querywhile having a similar, but very simple interface. Built on Redux, covered with tests, fully typed and written in Typescript.

RRC can be thought of as ApolloClient for protocols other than GraphQL (though theoretically for it too), but with a Redux store – with the ability to write your own selectors, actions, and reducers, while having full control over the cached state.

For what?

Next comes a comparison with existing libraries for managing requests and state. Why it is worth using libraries for this at all, and not writing everything manually with useEffect / redux-saga, etc. – we will leave this topic for other articles.

  • Having full control over the storage not only gives you more options, simplifies debugging and coding, but also allows you to build less crutches if the task goes beyond the typical hello world from the documentation, without wasting a huge amount of time suffering with very dubious library interfaces and reading huge source codes.

  • Redux is a great – simple and proven tool for storing “slow” data, that is, data that does not require updating on every screen frame / every key press by the user. Entry threshold for those familiar with the library – minimal. The ecosystem offers convenient debugging and many ready-made solutionssuch as storing state on disk (redux-persist).

  • Normalization is the best way to maintain a consistent state of your application across different screens, reduces the number of requests and allows you to immediately display cached data during navigation without any problems, which is significantly improves user experience. And there are practically no analogs that support normalization – ApolloClient only supports the GraphQL protocol, and is made in a very dubious, overcomplicated OOP style.

  • Lightnessboth the size of the library and its interface – another advantage. The simpler, the better – the main rule of the engineer.

Brief comparison of libraries in the table:

React Query

Apollo Client

RTK Query

RRC

Full access to storage

+-

+

REST support

+

+

+

Normalization

+

+

Infinite pagination

+

+

+

Not overcomplicated

+

+

Popularity

+

+

Why only React?

Support for all sorts of UI libraries except the most popular one (used in React Native, among others) is a complication that I am not ready for yet.

Examples

To run examples from a folder /example use npm run example. Three examples are available:

  • With normalization (recommended).

  • Without normalization.

  • Without normalization, optimized.

These examples are the best proof of how much the user experience and server load depends on the implementation of client caching. In bad implementations, any navigation in the application:

  • the user is forced to watch spinners and other loading states, being blocked in his actions until it is finished.

  • requests are constantly being sent, even if the data is still fairly fresh.

Example of redux state with normalization

{
  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 *"}
    } 
  }
}

Example of redux state without normalization

{
  // Словарь сущностей используется только для нормализации, и здесь пуст
  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 *"}
    } 
  }
}

Installation

react, redux And react-redux are peer dependencies.

npm add react-redux-cache react redux react-redux

Initialization

The only function you need to import is createCachewhich creates fully typed reducers, hooks, actions, selectors, and utilities for use in your application. You can create as many caches as you need, but note that normalization is not shared between them. All types, queries, and mutations must be passed in when initializing the cache for proper typing.

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 },
  },
})

Normalization requires two things:

  • Specify typenames when creating a cache – a list of all entities and their corresponding TS types.

  • Return an object from the query or mutation functions that contains, in addition to the result field, data of the following type:

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

store.ts

Create the store as usual, passing in a new cache reducer under the cache name. If you want a different redux structure, you'll need to additionally pass in a cache state selector when creating the cache.

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

api.ts

The query result must be of the type QueryResponsethe result of a mutation – type MutationResponse. This example uses the normalizr package for normalization, but you can use other tools if the query result matches the desired type. Ideally, the backend returns already normalized data.

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

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) => {
  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 ...
}

Advanced Features

Extended caching policy

Default policy cache-first does not load data if the result is already cached, but sometimes it cannot detect that the data is already present in the response of another request or in a normalized cache. In this case, you can use the skip parameter:

export const UserScreen = () => {
  ...

  const user = useSelectEntityById(userId, 'users')

  const [{loading, error}] = useQuery({
    query: 'getUser',
    params: userId,
    skip: !!user // Пропускаем запрос, если пользователь уже закэширован ранее, например, запросом getUsers
  })

  ...
}

We can additionally check whether the object is complete enough, or, for example, the time of its last update:

skip: !!user && isFullUser(user)

Another approach is to set skip: true and manually run the query when needed:

export const UserScreen = () => {
  const screenIsVisible = useScreenIsVisible()

  const [{result, loading, error}, fetchUser] = useQuery({
    query: 'getUser',
    params: userId,
    skip: true
  })

  useEffect(() => {
    if (screenIsVisible) {
      fetchUser()
    }
  }, [screenIsVisible])

  ...
}

Infinite scroll with pagination

Here is an example of a query configuration getUsers with support for infinite pagination – a feature not available in RTK-Query (facepalm). The full implementation can be found in the folder /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

Here is the simplest redux-persist configuration:

// Удаляет `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
)

Conclusion

Although the project is in the development stage, it is already ready for use. Constructive criticism and qualified assistance are welcome.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *