Package Exports
- react-redux-cache
Readme
Donations 🙌
BTC: bc1qs0sq7agz5j30qnqz9m60xj4tt8th6aazgw7kxrETH: 0x1D834755b5e889703930AC9b784CB625B3cd833E
USDT(Tron): TPrCq8LxGykQ4as3o1oB8V7x1w2YPU2o5n
TON: EQAtBuFWI3H_LpHfEToil4iYemtfmyzlaJpahM3tFSoxojvV
DOGE: D7GMQdKhKC9ymbT9PtcetSFTQjyPRRfkwT
react-redux-cache
Powerful yet lightweight data fetching and caching library that supports normalization unlike react-query and rtk-query, while having similar but very simple interface. Built on top of redux, covered with tests, fully typed and written on Typescript.
Normalization is the best way to keep the state of the app consistent between different views, reduces the number of fetches and allows to show cached data when navigating, which greatly improves user experience.
Can be considered as ApolloClient for protocols other than GraphQL, but with full control over its storage - redux store, with ability to write custom selectors, actions and reducers to manage cached state.
Examples of states, generated by cache reducer from /example project:
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": {loading: false, error: undefined, result: 2, params: 2},
"3": {loading: true, params: 3}
},
getUsers: {
// example of paginated state under custom cache key
"all-pages": {
loading: false,
result: {items: [0,1,2], page: 1},
params: {page: 1}
}
}
},
mutations: {
// each mutation has its own state as well
updateUser: {
loading: false,
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": {
loading: false,
error: undefined,
result: {id: 2, bank: {id: "2", name: "Bank 2"}, name: "User 2"},
params: 2
},
"3": {loading: true, params: 3}
},
getUsers: {
// example of paginated state under custom cache key
"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: {
// each mutation has its own state as well
updateUser: {
loading: false,
result: {id: 1, bank: {id: "1", name: "Bank 1"}, name: "User 1 *"},
params: {id: 1, name: "User 1 *"}
}
}
}Table of contents
Installation
react, redux and react-redux are peer dependencies.
npm add react-redux-cache react redux react-reduxInitialization
The only function that needs to be imported is createCache, which creates fully typed reducer, hooks, actions, 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 typenames, queries and mutations should be passed while initializing the cache for proper typing.
cache.ts
export const {
cache,
reducer,
hooks: {useClient, useMutation, useQuery},
} = createCache({
// Used as prefix for actions and in default cacheStateSelector for selecting cache state from redux state.
name: 'cache',
// Typenames provide a mapping of all typenames to their entity types, which is needed for proper typing and normalization.
// Empty objects with type casting can be used as values.
typenames: {
users: {} as User, // here `users` entities will have type `User`
banks: {} as Bank,
},
queries: {
getUsers: { query: getUsers },
getUser: { query: getUser },
},
mutations: {
updateUser: { mutation: updateUser },
removeUser: { mutation: removeUser },
},
})store.ts
// Create store as usual, passing the new cache reducer under the name of the cache.
// If some other redux structure is needed, provide custom cacheStateSelector when creating cache.
const store = configureStore({
reducer: {
[cache.name]: reducer,
...
}
})api.ts
Query result should be of type QueryResponse, mutation result should be of type MutationResponse.
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 (recommended)
export const getUser = async (id: number) => {
const result = await ...
const normalizedResult: {
// result is id of the user
result: number
// entities contain all normalized objects
entities: {
users: Record<number, User>
banks: Record<string, Bank>
}
} = normalize(result, getUserSchema)
return normalizedResult
}
// Example of query without normalization (not recommended)
export const getBank = (id: string) => {
const result: Bank = ...
return {result} // result is bank object, no entities passed
}
// Example of mutation with normalization
export const removeUser = async (id: number) => {
await ...
return {
remove: { users: [id] },
}
}Usage
Please check example/ folder (npm run example to run).
UserScreen.tsx
export const UserScreen = () => {
const {id} = useParams()
// useQuery connects to redux state and if user with that id is already cached, fetch won't happen (with default cachePolicy 'cache-first')
// Infers all types from created cache, telling here that params and result are of type `number`.
const [{result: userId, loading, error}] = useQuery({
query: 'getUser',
params: Number(id),
})
const [updateUser, {loading: updatingUser}] = useMutation({
mutation: 'updateUser',
})
// This selector is used for denormalization and returns entities with proper types - User and Bank
const user = useSelectEntityById(userId, 'users')
const bank = useSelectEntityById(user?.bankId, 'banks')
if (loading) {
return ...
}
return ...
}Advanced
Advanced cache policy
cache-first cache policy 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 skip parameter of a query:
export const UserScreen = () => {
const user = useSelectEntityById(userId, 'users')
const [{loading, error}] = useQuery({
query: 'getUser',
params: userId,
skip: !!user // we skip fetching if we already have user cached by some other query, e.g. getUsers
})
...
}We can additional check that entity is full enough:
skip: !!user && isFullUser(user)Other approach is to set skip: true and manually run fetch 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 pagination
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: () => 'all-pages', // 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, ...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({
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={() => fetchUsers()}>Refresh</button>
{loadingNextPage ? (
<div className="spinner" />
) : (
<button onClick={loadNextPage}>Load next page</button>
)}
</div>
)
}
redux-persist
Here is a simple redux-persist configuration:
// removes `loading` and `error` from persisted state
function stringifyReplacer(key: string, value: unknown) {
return key === 'loading' || key === 'error' ? undefined : value
}
const persistedReducer = persistReducer(
{
key: 'cache',
storage,
whitelist: ['entities', 'queries'], // mutations are ignored
throttle: 1000, // ms
serialize: (value: unknown) => JSON.stringify(value, stringifyReplacer),
},
reducer
)FAQ
What is a query cache key?
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.
As example, can be overriden when implementing pagination.
How mutation fetching differs from queries?
Queries: For each cache key (= unique params by default) of each query fetch is running in parallel. If fetch is already running for specific cache key, all next fetches are cancelled until it finishes.
Mutations: Only one mutation can be run for each mutation key at a time. If another one called, previous is aborted.