Rrc
RRC - Powerful yet lightweight and performant data fetching and caching library for immutable stores (Redux, Zustand) that supports normalization.
Install / Use
/learn @gentlee/RrcREADME
RRC
Supported stores: Redux and Zustand (check /example)
Supported UI libs: React
Powerful, performant yet lightweight data fetching and caching library for immutable stores that supports normalization unlike TanStack Query and RTK-Query, while having similar but not over-engineered, simple interface, with full control over underlying store. Covered with tests, fully typed and written on Typescript.
|Principle|Description|
|--|--|
|Full access to the store|You choose the store (Redux / Zustand) and embed the cache into it, having full access to its state, actions, hooks, selectors and utils.|
|Supports all kinds of queries / mutations|REST, GraphQL, databases - any async operations can be cached.|
|Fully typed|Written on TypeScript, everything is checked by compiler.|
|Not overengineered|Simplicity is the main goal.|
|Performance|Every function is heavily optimized. Immer is not used (RTK performance issue). Supports mutable collections (O(n) > O(1)).|
|Reliability|High test coverage, zero issue policy.|
|Lightweight|Supports tree shaking. npx minified-size dist/esm/*.js<br/>minified: 15.3 kB<br/>gzipped: 6.75 kB<br/>brotlied: 5.98 kB|
|Feature|Description|
|--|--|
|De-duplication of queries / mutations|Similar parallel queries are combined into one, mutations - aborted.|
|Time to live & Invalidation & Clear|Choose how long query result can be used before expired and refetched, or invalidate / clear it manually.|
|Deep comparison|Rendering is much heavier than deep comparison of incoming data, so it is enabled by default to prevent excess renders.|
|Infinite pagination|Easily implemented.|
|Error handling|No need to use try / catch, errors are returned from functions, passed to callbacks and / or can be handled from single global callback.|
|Fetch policies|Decide if data is full and fresh enough or need to be fetched.|
|Normalization|Consistent state accross the app - better UX, minimum loading states and lower traffic consumption.|
|Minimal state|Default values such as undefined or default query states are removed from the state tree.|
|BETA: Mutable collections|Optimizes state merges from O(n) to O(1) by using mutable collections. Separate entities, query and mutation states are still immutable.|
Examples of states, generated by RRC from /example project:
<details>
<summary>
Normalized
</summary>
{
entities: {
// Each typename has its own map of entities, stored by 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: {
// Each query has its own map of query states, stored by cache key, which is generated from query params.
getUser: {
"2": {result: 2, params: 2, expiresAt: 1727217298025},
"3": {loading: Promise<...>, params: 3}
},
getUsers: {
// Example of paginated state under custom cache key.
"feed": {
result: {items: [0,1,2], page: 1},
params: {page: 1}
}
}
},
mutations: {
// each mutation has its own state as well
updateUser: {
result: 1,
params: {id: 1, name: "User 1 *"}
}
}
}
</details>
<details>
<summary>
Not normalized
</summary>
{
// entities map is used for normalization and is empty here
entities: {},
queries: {
// each query has its own map of query states, stored by cache key, which is generated from query params
getUser: {
"2": {
result: {id: 2, bank: {id: "2", name: "Bank 2"}, name: "User 2"},
params: 2,
expiresAt: 1727217298025
},
"3": {loading: Promise<...>, params: 3}
},
getUsers: {
// example of paginated state under custom cache key
"feed": {
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: {
// each mutation has its own state as well
updateUser: {
result: {id: 1, bank: {id: "1", name: "Bank 1"}, name: "User 1 *"},
params: {id: 1, name: "User 1 *"}
}
}
}
</details>
Table of contents
Installation
react, react-redux and fast-deep-equal are optional peer dependencies:
reactrequired ifinitializeForReactis used.react-reduxrequired ifreduxCustomStoreHooksis not provided while initilizing Redux cache for React. Not needed for Zustand.fast-deep-equalrequired ifdeepComparisonEnabledcache option is enabled (default is true). Option fallbacks tofalseif not installed.
# Basic with deep comparison (e.g. Zustand)
npm i rrc fast-deep-equal
# React + Redux without custom hooks
npm i rrc react react-redux fast-deep-equal
# React + Redux with custom hooks
npm i rrc react fast-deep-equal
Initialization
Initialization is done in three steps:
- Create cache by providing initial config with all queries and mutations.
- Initialize with the store (Zustand or Redux).
- [Optional] Initialize with UI lib (React).
1.1 Queries and mutations
Functions that return result should be used for querues and mutations when creating cache if you don't need normalization:
// Example of query without normalization, with selecting access token from the store.
export const getBank = (async (id, {getState}) => {
const token = tokenSelector(getState())
const result: Bank = ...
return {result}
}) satisfies Query<string>
For normalization two things are required:
- Set proper typenames while creating the cache - mapping of all entities and their corresponding TS types.
- Return an object from queries and mutations that contains the following fields (besides
result):
type EntityChanges<T extends Typenames> = {
merge?: PartialEntitiesMap<T> /** Entities that will be merged with existing. */
replace?: Partial<EntitiesMap<T>> /** Entities that will replace existing. */
remove?: EntityIds<T> /** Ids of entities that will be removed. */
entities?: EntityChanges<T>['merge'] /** Alias for `merge` to support normalizr. */
}
For normalization normalizr package is used in this example, but any other tool can be used if query result is of proper type.
Perfect implementation is when the backend already returns normalized data.
// Example of query with normalization
// 1. Result can be get by any way - fetch, axios etc, even with database connection. There is no limitation here.
// 2. `satisfies` keyword is used here for proper typing of params and returned value.
export const getUser = (async (id) => {
const response = await ...
return normalize(response, getUserSchema)
}) satisfies NormalizedQuery<CacheTypenames, number>
// Example of mutation with normalization.
export const removeUser = (async (id, _, abortSignal) => {
await ...
return {
remove: { users: [id] },
}
}) satisfies NormalizedQuery<CacheTypenames, number>
1.2 Cache
First function that needs to be called is either withTypenames, which is needed for normalization, or directly createCache if it is not needed. createCache creates cache object, containing fully typed selectors and utils to be used in the app. You can create as many caches as needed, but keep in mind that normalization is not shared between them.
All queries and mutations should be passed while initializing the cache for proper typing.
// Mapping of all typenames to their entity types, which is needed for proper normalization typing.
// Not needed if normalization is not used.
export type CacheTypenames = {
users: User, // here `users` entities will have type `User`
