Donations 🙌
BTC: bc1qs0sq7agz5j30qnqz9m60xj4tt8th6aazgw7kxrETH: 0x1D834755b5e889703930AC9b784CB625B3cd833E
USDT(Tron): TPrCq8LxGykQ4as3o1oB8V7x1w2YPU2o5n
TON: EQAtBuFWI3H_LpHfEToil4iYemtfmyzlaJpahM3tFSoxojvV
DOGE: D7GMQdKhKC9ymbT9PtcetSFTQjyPRRfkwT
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/*.jsminified: 15.3 kB gzipped: 6.75 kB 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. |
Normalized
{
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 *"}
}
}
}Not normalized
{
// 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 *"}
}
}
}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-equalInitialization 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).
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>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`
banks: Bank,
}
// `withTypenames` is only needed to provide proper Typenames for normalization.
// `createCache` can be imported directly without `withTypenames`.
export const cache = withTypenames<CacheTypenames>().createCache({
name: 'test', // Used for logging and as prefix for actions.
cacheStateKey: 'cache', // Used to find cache state in the root state. Pass '.' or '' if cache state is the root.
queries: {
getUsers: { query: getUsers },
getUser: {
query: getUser,
// For each query `secondsToLive` option can be set, which is used to set expiration date of a cached result when query response is received.
// After expiration query result is considered invalidated and will be refetched on the next useQuery mount.
// Can also be set globally in `globals`.
secondsToLive: 5 * 60 // Here cached result is valid for 5 minutes.
},
},
mutations: {
updateUser: { mutation: updateUser },
removeUser: { mutation: removeUser },
},
})Cache need to be initialized for Redux or Zustand with initializeForRedux or initializeForZustand, that also return reducer, actions and utils.
Redux:
const {actions, asyncActions, reducer} = initializeForRedux(cache)
// Create store as usual, passing the new cache reducer under the cache state key.
const store = configureStore({
reducer: {
[cache.config.cacheStateKey]: reducer,
...
}
})
// If cache state key is '.' or '', then reducer is considered as root.
const store = configureStore({
reducer
})Zustand:
const initialState = cache.utils.getInitialState()
const useStore = create((set, get) => initialState)
// `actions` are the same synchronous functions as used by RRC to manage the cache.
const {actions} = initializeForZustand(cache, useStore)Only React is supported right now. Hooks for React can be created with initializeForReact.
const {
hooks: {
useQuery,
useMutation,
useSelectEntityById,
useEntitiesByTypename,
}
} = initializeForReact(cache)Please check example/ folder (npm run example to run). There are examples for both Redux and Zustand, with and without normalization.
export const UserScreen = () => {
const {id} = useParams()
// useQuery fetches data if query not cached already (with default FetchPolicy.NoCacheOrExpired).
// Infers all types from created cache so expects params of type `number`.
const [{result: user, loading, error}] = useQuery({
query: 'getUser',
params: Number(id),
})
// Globally tracks loading state for mutation.
const [updateUser, {loading: updatingUser}] = useMutation({
mutation: 'updateUser',
})
// This selector used only with normalization and returns entities with proper types - User and Bank.
const user = useSelectEntityById(userId, 'users')
const bank = useSelectEntityById(user?.bankId, 'banks')
if (loading) {
return ...
}
return ...
}For huge collections (> 1000 items, see benchmark) immutable approach may be a bottleneck - every merge of entity, query or mutation state is O(n). There is an option mutableCollections that makes it O(1) by using mutable approach when working with collections, while still keeping separate entities, query and mutation states immutable.
Benchmark results of adding item to collection depending on collection size, in microseconds (Macbook M1, less is better):
| Collection size | 0 | 1000 | 10000 | 100000 | 1000000 |
|---|---|---|---|---|---|
| immutable | 1.57 | 1.81 | 7.62 | 103.82 | 1457.89 |
| mutable | 1.4 | 1.15 | 0.65 | 1.03 | 0.76 |
Well written code should not subcribe to whole collections, so just enabling this options most of the times should not break anything. But if it is still needed, you should subscribe to both collection (it may still change e.g. when clearing state) and to its _changeKey.
const Component = () => {
// It is usually a bad idea to subscribe to whole collections, consider using order of ids and subscribe to a single entity in each cell.
const allUsers = useSelector((state) => selectEntitiesByTypename(state, 'users'))
const allUsersChangeKey = useSelector((state) => selectEntitiesByTypename(state, 'users')._changeKey) // <-- Add this line while subscribing to collections.
// For memoized components you should also pass it as extra prop to cause its re-render.
return <List data={allUsers} extra={allUsersChangeKey}/>
}
// Or just use existing hook.
const Component = () => {
const allUsers = useEntitiesByTypename('users')
return <List data={allUsers} extra={allUsers._changeKey}>
}Queries and mutations are wrapped in try/catch, so any error will lead to cancelling of any updates to the state except loading state and the caught error. If you still want to make some state updates, or just want to use thrown errors only for unexpected cases, consider returning expected errors as a part of the result:
export const updateBank = (async (bank) => {
const {httpError, response} = ...
return {
result: {
httpError, // Error is a part of the result, containing e.g. map of not valid fields and threir error messages.
bank: response?.bank // Bank still can be returned from the backend with error e.g. when only some of fields were udpated.
}
}
}) satisfies Mutation<Partial<Bank>>If global error handling is needed for errors, not handled by query / mutation onError callback, global onError can be used:
export const cache = createCache({
name: 'cache',
globals: {
onError: (error, key) {
console.log('Not handled error', { error, key })
}
},
queries: {
getUsers: { query: getUsers },
},
...
})FetchPolicy.NoCacheOrExpired (default) skips fetching on fetch triggers if result is already cached, but we can invalidate cached query results using invalidateQuery action to make it run again on a next mount.
export const cache = createCache({
...
mutations: {
updateUser: {
mutation: updateUser,
onSuccess(_, __, {dispatch}, {invalidateQuery}) {
// Invalidate getUsers after a single user update (can be done better by updating getUsers state with updateQueryStateAndEntities).
dispatch(invalidateQuery([{query: 'getUsers'}]))
},
},
},
})Fetch policy determines if useQuery fetch triggers should start fetching. They are: 1) component mount 2) cache key change (=params by default) 3) skipFetch change to false.
FetchPolicy.NoCacheOrExpired (default) skips fetching if result is already cached, but sometimes it can't determine that we already have result in some other's query result or in normalized entities cache. In that case we can use skipFetch parameter of a query:
export const UserScreen = () => {
...
const user = useSelectEntityById(userId, 'users')
const [{loading, error}] = useQuery({
query: 'getUser',
params: userId,
skipFetch: !!user // Disable fetches if we already have user cached by some other query, e.g. getUsers.
})
...
}But if more control is needed, e.g. checking if entity is full, custom fetch policy can be provided:
...
getFullUser: {
query: getUser,
fetchPolicy(expired, id, _, {getState}, {selectEntityById}) {
if (expired) {
return true // Fetch if expired.
}
// Fetch if user is not full.
const user = selectEntityById(getState(), id, 'users')
return !user || !('name' in user) || !('bankId' in user)
},
},
...One more approach is to set skipFetch: true by default and manually run fetch. onlyIfExpired option can be also used:
export const UserScreen = () => {
const screenIsVisible = useScreenIsVisible()
const [{result, loading, error}, fetchUser] = useQuery({
query: 'getUser',
params: userId,
skipFetch: true
})
useEffect(() => {
if (screenIsVisible) {
// Expiration happens if expiresAt was set before e.g. by secondsToLive option or invalidateQuery action.
// If result is not cached yet, it is also considered as expired.
fetchUser({ onlyIfExpired: true })
}
}, [screenIsVisible])
...
}Here is an example of getUsers query configuration with pagination support. You can check full implementation in /example folder.
// createCache
...
} = createCache({
...
queries: {
getUsers: {
query: getUsers,
getCacheKey: () => 'feed', // Single cache key is used for all pages.
mergeResults: (oldResult, {result: newResult}) => {
if (!oldResult || newResult.page === 1) {
return newResult
}
if (newResult.page === oldResult.page + 1) {
return {
...newResult,
items: oldResult.items.concat(newResult.items),
}
}
return oldResult
},
},
},
...
})
// Component
export const GetUsersScreen = () => {
const [{result: usersResult, loading, error, params}, fetchUsers] = useQuery({
query: 'getUsers',
params: 1 // page
})
const refreshing = loading && params === 1
const loadingNextPage = loading && !refreshing
const onLoadNextPage = () => {
const lastLoadedPage = usersResult?.page ?? 0
fetchUsers({
params: lastLoadedPage + 1,
})
}
const renderUser = (userId: number) => (
<UserRow key={userId} userId={userId}>
)
...
return (
<div>
{refreshing && <div className="spinner" />}
{usersResult?.items.map(renderUser)}
<button onClick={() => fetchUsers()}>Refresh</button>
{loadingNextPage ? (
<div className="spinner" />
) : (
<button onClick={loadNextPage}>Load next page</button>
)}
</div>
)
}Here is a simple redux-persist configuration:
// Removes `loading` and `error` from persisted state.
// '_changeKey' is needed only when `mutuableCollections` enabled.
function stringifyReplacer(key: string, value: unknown) {
return key === 'loading' || key === 'error' || key === '_changeKey' ? undefined : value
}
const persistedReducer = persistReducer(
{
key: 'cache',
storage,
whitelist: ['entities', 'queries'], // Mutation states are ignored.
throttle: 1000, // ms
serialize: (value: unknown) => JSON.stringify(value, stringifyReplacer),
},
reducer
)Cache key is used for storing the query state and for performing a fetch when it changes. Queries with the same cache key share their state.
Default implementation for getCacheKey is:
export const defaultGetCacheKey = <P = unknown>(params: P): Key => {
switch (typeof params) {
case 'string':
case 'symbol':
return params
case 'object':
return JSON.stringify(params)
default:
return String(params)
}
}It is recommended to override it when default implementation is not optimal or when keys in params object can be sorted in random order. In second case you can also consider using array to pass params.
As example, can be overridden when implementing pagination.
Queries: Queries are deduplicated: queries with the same cache key (generated from params by default) use existing fetch promise if already fetching.
Mutations: Mutations are cancelled: previous similar mutation is aborted if it was running when the new one started. Third argument in mutations is AbortSignal, which can be used e.g. for cancelling http requests.