{"id":25670124,"url":"https://github.com/gentlee/react-redux-cache","last_synced_at":"2025-04-23T02:22:19.821Z","repository":{"id":204994350,"uuid":"713148362","full_name":"gentlee/react-redux-cache","owner":"gentlee","description":"RRC - Powerful yet lightweight data fetching and caching library that supports normalization, built on top of redux","archived":false,"fork":false,"pushed_at":"2025-04-06T18:58:59.000Z","size":1346,"stargazers_count":10,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-04-06T19:35:31.102Z","etag":null,"topics":["cache","normalization","query","react","redux"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/gentlee.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-11-01T23:54:38.000Z","updated_at":"2025-04-06T18:59:03.000Z","dependencies_parsed_at":"2023-11-08T03:57:08.446Z","dependency_job_id":"389f8979-1458-4707-a760-5d968edb63ec","html_url":"https://github.com/gentlee/react-redux-cache","commit_stats":{"total_commits":99,"total_committers":1,"mean_commits":99.0,"dds":0.0,"last_synced_commit":"2ed73861c2959dc267df4a94e0c0c91bc037244e"},"previous_names":["gentlee/react-redux-cache"],"tags_count":16,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gentlee%2Freact-redux-cache","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gentlee%2Freact-redux-cache/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gentlee%2Freact-redux-cache/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gentlee%2Freact-redux-cache/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/gentlee","download_url":"https://codeload.github.com/gentlee/react-redux-cache/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":250355002,"owners_count":21416825,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cache","normalization","query","react","redux"],"created_at":"2025-02-24T11:29:38.006Z","updated_at":"2025-04-23T02:22:19.802Z","avatar_url":"https://github.com/gentlee.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdetails\u003e\n  \u003csummary\u003eDonations 🙌\u003c/summary\u003e\n  \u003cb\u003eBTC:\u003c/b\u003e bc1qs0sq7agz5j30qnqz9m60xj4tt8th6aazgw7kxr \u003cbr\u003e\n  \u003cb\u003eETH:\u003c/b\u003e 0x1D834755b5e889703930AC9b784CB625B3cd833E \u003cbr\u003e\n  \u003cb\u003eUSDT(Tron):\u003c/b\u003e TPrCq8LxGykQ4as3o1oB8V7x1w2YPU2o5n \u003cbr\u003e\n  \u003cb\u003eTON:\u003c/b\u003e EQAtBuFWI3H_LpHfEToil4iYemtfmyzlaJpahM3tFSoxojvV \u003cbr\u003e\n  \u003cb\u003eDOGE:\u003c/b\u003e D7GMQdKhKC9ymbT9PtcetSFTQjyPRRfkwT \u003cbr\u003e\n\u003c/details\u003e\n\n# react-redux-cache (RRC)\n\n### Supports both `Redux` and `Zustand` (check /example)\n\n**Powerful**, **performant** yet **lightweight** data fetching and caching library that supports **normalization** unlike `React Query` and `RTK-Query`, while having similar but not over-engineered, simple interface. Another advantage over `RTK-Query` is that it **doesn't use Immer** ([perf issue](https://github.com/reduxjs/redux-toolkit/issues/4793)). Covered with tests, fully typed and written on Typescript.\n\n**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**.\n\nCan be considered as `ApolloClient` for protocols other than `GraphQL`, but with **full control** over its storage - redux or zustand store, with ability to write custom selectors, actions and reducers to manage cached state.\n\nExamples of states, generated by cache reducer from `/example` project:\n\u003cdetails\u003e\n  \u003csummary\u003e\n    Normalized\n  \u003c/summary\u003e\n  \n  ```js\n  {\n    entities: {\n      // each typename has its own map of entities, stored by id\n      users: {\n        \"0\": {id: 0, bankId: \"0\", name: \"User 0 *\"},\n        \"1\": {id: 1, bankId: \"1\", name: \"User 1 *\"},\n        \"2\": {id: 2, bankId: \"2\", name: \"User 2\"},\n        \"3\": {id: 3, bankId: \"3\", name: \"User 3\"}\n      },\n      banks: {\n        \"0\": {id: \"0\", name: \"Bank 0\"},\n        \"1\": {id: \"1\", name: \"Bank 1\"},\n        \"2\": {id: \"2\", name: \"Bank 2\"},\n        \"3\": {id: \"3\", name: \"Bank 3\"}\n      }\n    },\n    queries: {\n      // each query has its own map of query states, stored by cache key, which is generated from query params\n      getUser: {\n        \"2\": {result: 2, params: 2, expiresAt: 1727217298025},\n        \"3\": {loading: true, params: 3}\n      },\n      getUsers: {\n        // example of paginated state under custom cache key\n        \"feed\": {\n          result: {items: [0,1,2], page: 1},\n          params: {page: 1}\n        }\n      }\n    },\n    mutations: {\n      // each mutation has its own state as well\n      updateUser: {\n        result: 1,\n        params: {id: 1, name: \"User 1 *\"}\n      } \n    }\n  }\n  ```\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e\n    Not normalized\n  \u003c/summary\u003e\n  \n  ```js\n  {\n    // entities map is used for normalization and is empty here\n    entities: {},\n    queries: {\n      // each query has its own map of query states, stored by cache key, which is generated from query params\n      getUser: {\n        \"2\": {\n          result: {id: 2, bank: {id: \"2\", name: \"Bank 2\"}, name: \"User 2\"},\n          params: 2,\n          expiresAt: 1727217298025\n        },\n        \"3\": {loading: true, params: 3}\n      },\n      getUsers: {\n        // example of paginated state under custom cache key\n        \"feed\": {\n          result: {\n            items: [\n              {id: 0, bank: {id: \"0\", name: \"Bank 0\"}, name: \"User 0 *\"},\n              {id: 1, bank: {id: \"1\", name: \"Bank 1\"}, name: \"User 1 *\"},\n              {id: 2, bank: {id: \"2\", name: \"Bank 2\"}, name: \"User 2\"}\n            ],\n            page: 1\n          },\n          params: {page: 1}\n        }\n      }\n    },\n    mutations: {\n      // each mutation has its own state as well\n      updateUser: {\n        result: {id: 1, bank: {id: \"1\", name: \"Bank 1\"}, name: \"User 1 *\"},\n        params: {id: 1, name: \"User 1 *\"}\n      } \n    }\n  }\n  ```\n\u003c/details\u003e\n    \n### Table of contents\n\n - [Installation](https://github.com/gentlee/react-redux-cache#Installation)\n - [Initialization](https://github.com/gentlee/react-redux-cache#Initialization)\n   - [cache.ts](https://github.com/gentlee/react-redux-cache#cachets) \n   - [store.ts](https://github.com/gentlee/react-redux-cache#storets) \n   - [api.ts](https://github.com/gentlee/react-redux-cache#apits) \n - [Usage](https://github.com/gentlee/react-redux-cache#usage)\n - [Advanced](https://github.com/gentlee/react-redux-cache#advanced)\n   - [Error handling](https://github.com/gentlee/react-redux-cache#error-handling)\n   - [Invalidation](https://github.com/gentlee/react-redux-cache#invalidation)\n   - [Extended \u0026 custom fetch policy](https://github.com/gentlee/react-redux-cache#extended--custom-fetch-policy)\n   - [Infinite scroll pagination](https://github.com/gentlee/react-redux-cache#infinite-scroll-pagination)\n   - [redux-persist](https://github.com/gentlee/react-redux-cache#redux-persist)\n - [FAQ](https://github.com/gentlee/react-redux-cache#faq)\n   - [What is a query cache key?](https://github.com/gentlee/react-redux-cache#what-is-a-query-cache-key)\n   - [How race conditions are handled?](https://github.com/gentlee/react-redux-cache#how-race-conditions-are-handled)\n\n### Installation\n`react` is a peer dependency.\n\n`react-redux` and `fast-deep-equal` are optional peer dependencies:\n  - `react-redux` required when `storeHooks` is not provided when creating cache. Not needed for Zustand.\n  - `fast-deep-equal` required if `deepComparisonEnabled` cache option is enabled (default is true).\n\n```sh\n# required\nnpm i react-redux-cache react\n\n# without react-redux\nnpm i react-redux-cache react fast-deep-equal\n\n# all required and optional peers\nnpm i react-redux-cache react react-redux fast-deep-equal\n```\n\n### Initialization\nThe only function that needs to be imported is either `withTypenames`, which is needed for normalization, or directly `createCache` if it is not needed. `createCache` 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.\nAll queries and mutations should be passed while initializing the cache for proper typing.\n\n#### cache.ts\n\n\u003e Zustand requires additional option - `storeHooks`.\n\n```typescript\n// Mapping of all typenames to their entity types, which is needed for proper normalization typing.\n// Not needed if normalization is not used.\nexport type CacheTypenames = {\n  users: User, // here `users` entities will have type `User`\n  banks: Bank,\n}\n\n// `withTypenames` is only needed to provide proper Typenames for normalization - limitation of Typescript.\n// `createCache` can be imported directly without `withTypenames`.\nexport const {\n  cache,\n  reducer,\n  hooks: {useClient, useMutation, useQuery},\n} = withTypenames\u003cCacheTypenames\u003e().createCache({\n  name: 'cache', // Used as prefix for actions and in default cacheStateSelector for selecting cache state from redux state.\n  queries: {\n    getUsers: { query: getUsers },\n    getUser: {\n      query: getUser,\n      // For each query `secondsToLive` option can be set, which is used to set expiration date of a cached result when query response is received.\n      // After expiration query result is considered invalidated and will be refetched on the next useQuery mount.\n      // Can also be set globally in `globals`.\n      secondsToLive: 5 * 60 // Here cached result is valid for 5 minutes.\n    },\n  },\n  mutations: {\n    updateUser: { mutation: updateUser },\n    removeUser: { mutation: removeUser },\n  },\n\n  // Required for Zustand. Just an empty object can be passed during initialization, and hooks can be set later (see `store.ts` section).\n  // Can be also used for Redux if working with multiple stores.\n  storeHooks: {},\n})\n```\n\nFor normalization two things are required:\n- Set proper typenames while creating the cache - mapping of all entities and their corresponding TS types.\n- Return an object from queries and mutations that contains the following fields (besides `result`):\n\n```typescript\ntype EntityChanges\u003cT extends Typenames\u003e = {\n  merge?: PartialEntitiesMap\u003cT\u003e         /** Entities that will be merged with existing. */\n  replace?: Partial\u003cEntitiesMap\u003cT\u003e\u003e     /** Entities that will replace existing. */\n  remove?: EntityIds\u003cT\u003e                 /** Ids of entities that will be removed. */\n  entities?: EntityChanges\u003cT\u003e['merge']  /** Alias for `merge` to support normalizr. */\n}\n```\n\n#### store.ts\n\nRedux:\n```typescript\n// Create store as usual, passing the new cache reducer under the name of the cache.\n// If some other redux structure is needed, provide custom cacheStateSelector when creating cache.\nconst store = configureStore({\n  reducer: {\n    [cache.name]: reducer,\n    ...\n  }\n})\n```\n\nZustand:\n```typescript\ntype State = {[cache.name]: ReturnType\u003ctypeof reducer\u003e}\ntype Actions = {dispatch: (action: Parameters\u003ctypeof reducer\u003e[1]) =\u003e void}\n\nconst initialState: State = {[cache.name]: reducer(undefined, {} as any)}\n\nexport const useStore = create\u003cState \u0026 Actions\u003e((set, get) =\u003e ({\n  ...initialState,\n  dispatch: (action) =\u003e set({[cache.name]: reducer(get()[cache.name], action)}),\n}))\n\nconst store = {dispatch: useStore.getState().dispatch, getState: useStore.getState}\ncache.storeHooks.useStore = () =\u003e store\ncache.storeHooks.useSelector = useStore\n```\n\n#### api.ts\nFor normalization `normalizr` package is used in this example, but any other tool can be used if query result is of proper type.\nPerfect implementation is when the backend already returns normalized data.\n```typescript\n\n// Example of query with normalization (recommended)\n\n// 1. Result can be get by any way - fetch, axios etc, even with database connection. There is no limitation here.\n// 2. `satisfies` keyword is used here for proper typing of params and returned value.\nexport const getUser = (async (id) =\u003e {\n  const response = await ...\n\n  return normalize(response, getUserSchema)\n}) satisfies NormalizedQuery\u003cCacheTypenames, number\u003e\n\n// Example of query without normalization (not recommended), with selecting access token from the store\n\nexport const getBank = (async (id, {getState}) =\u003e {\n  const token = tokenSelector(getState())\n  const result: Bank = ...\n  return {result} // result is bank object, no entities passed\n}) satisfies Query\u003cstring\u003e\n\n// Example of mutation with normalization\n\nexport const removeUser = (async (id, _, abortSignal) =\u003e {\n  await ...\n  return {\n    remove: { users: [id] },\n  }\n}) satisfies NormalizedQuery\u003cCacheTypenames, number\u003e\n```\n\n### Usage\n\nPlease check `example/` folder (`npm run example` to run). There are examples for both Redux and Zustand.\n\n#### UserScreen.tsx\n```typescript\nexport const UserScreen = () =\u003e {\n  const {id} = useParams()\n\n  // useQuery connects to redux state and if user with that id is already cached, fetch won't happen (with default FetchPolicy.NoCacheOrExpired).\n  // Infers all types from created cache, telling here that params and result are of type `number`.\n  const [{result: userId, loading, error}] = useQuery({\n    query: 'getUser',\n    params: Number(id),\n  })\n\n  const [updateUser, {loading: updatingUser}] = useMutation({\n    mutation: 'updateUser',\n  })\n\n  // This selector returns entities with proper types - User and Bank\n  const user = useSelectEntityById(userId, 'users')\n  const bank = useSelectEntityById(user?.bankId, 'banks')\n\n  if (loading) {\n    return ...\n  }\n\n  return ...\n}\n```\n\n### Advanced\n\n#### Error handling\n\nQueries and mutations are wrapped in try/catch, so any error will lead to cancelling of any updates to the state except `loading: false` 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:\n\n```typescript\nexport const updateBank = (async (bank) =\u003e {\n  const {httpError, response} = ...\n  return {\n    result: {\n      httpError,            // Error is a part of the result, containing e.g. map of not valid fields and threir error messages\n      bank: response?.bank  // Bank still can be returned from the backend with error e.g. when only some of fields were udpated\n    }\n  }\n}) satisfies Mutation\u003cPartial\u003cBank\u003e\u003e\n```\n\nIf global error handling is needed for errors, not handled by query / mutation `onError` callback, global `onError` can be used:\n\n```typescript\nexport const cache = createCache({\n  name: 'cache',\n  globals: {\n    onError: (error, key) {\n      console.log('Not handled error', { error, key })\n    }\n  },\n  queries: {\n    getUsers: { query: getUsers },\n  },\n  ...\n})\n```\n\n#### Invalidation\n\n`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.\n\n```typescript\n\nexport const cache = createCache({\n  ...\n  mutations: {\n    updateUser: {\n      mutation: updateUser,\n      onSuccess(_, __, {dispatch}, {invalidateQuery}) {\n        // Invalidate getUsers after a single user update (can be done better by updating getUsers state with updateQueryStateAndEntities)\n        dispatch(invalidateQuery([{query: 'getUsers'}]))\n      },\n    },\n  },\n})\n```\n\n#### Extended \u0026 custom fetch policy\n\nFetch 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.\n\n`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:\n\n```typescript\nexport const UserScreen = () =\u003e {\n  ...\n\n  const user = useSelectEntityById(userId, 'users')\n\n  const [{loading, error}] = useQuery({\n    query: 'getUser',\n    params: userId,\n    skipFetch: !!user // Disable fetches if we already have user cached by some other query, e.g. getUsers\n  })\n\n  ...\n}\n```\n\nBut if more control is needed, e.g. checking if entity is full, custom fetch policy can be provided:\n\n```typescript\n  ...\n  getFullUser: {\n    query: getUser,\n    fetchPolicy(expired, id, _, {getState}, {selectEntityById}) {\n      if (expired) {\n        return true // fetch if expired\n      }\n\n      // fetch if user is not full\n      const user = selectEntityById(getState(), id, 'users')\n      return !user || !('name' in user) || !('bankId' in user)\n    },\n  },\n  ...\n```\n\nOne more approach is to set `skipFetch: true` by default and manually run `fetch`. `onlyIfExpired` option can be also used:\n\n```typescript\nexport const UserScreen = () =\u003e {\n  const screenIsVisible = useScreenIsVisible()\n\n  const [{result, loading, error}, fetchUser] = useQuery({\n    query: 'getUser',\n    params: userId,\n    skipFetch: true\n  })\n\n  useEffect(() =\u003e {\n    if (screenIsVisible) {\n      fetchUser({ onlyIfExpired: true }) // 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.\n    }\n  }, [screenIsVisible])\n\n  ...\n}\n```\n\n#### Infinite scroll pagination\n\nHere is an example of `getUsers` query configuration with pagination support. You can check full implementation in `/example` folder.\n\n```typescript\n// createCache\n\n...\n} = createCache({\n  ...\n  queries: {\n    getUsers: {\n      query: getUsers,\n      getCacheKey: () =\u003e 'feed', // single cache key is used for all pages\n      mergeResults: (oldResult, {result: newResult}) =\u003e {\n        if (!oldResult || newResult.page === 1) {\n          return newResult\n        }\n        if (newResult.page === oldResult.page + 1) {\n          return {\n            ...newResult,\n            items: oldResult.items.concat(newResult.items),\n          }\n        }\n        return oldResult\n      },\n    },\n  },\n  ...\n})\n\n// Component\n\nexport const GetUsersScreen = () =\u003e {\n  const [{result: usersResult, loading, error, params}, fetchUsers] = useQuery({\n    query: 'getUsers',\n    params: 1 // page\n  })\n\n  const refreshing = loading \u0026\u0026 params === 1\n  const loadingNextPage = loading \u0026\u0026 !refreshing\n\n  const onLoadNextPage = () =\u003e {\n    const lastLoadedPage = usersResult?.page ?? 0\n    fetchUsers({\n      params: lastLoadedPage + 1,\n    })\n  }\n\n  const renderUser = (userId: number) =\u003e (\n    \u003cUserRow key={userId} userId={userId}\u003e\n  )\n\n  ...\n\n  return (\n    \u003cdiv\u003e\n      {refreshing \u0026\u0026 \u003cdiv className=\"spinner\" /\u003e}\n      {usersResult?.items.map(renderUser)}\n      \u003cbutton onClick={() =\u003e fetchUsers()}\u003eRefresh\u003c/button\u003e\n      {loadingNextPage ? (\n        \u003cdiv className=\"spinner\" /\u003e\n      ) : (\n        \u003cbutton onClick={loadNextPage}\u003eLoad next page\u003c/button\u003e\n      )}\n    \u003c/div\u003e\n  )\n}\n\n```\n\n#### redux-persist\n\nHere is a simple `redux-persist` configuration:\n\n```typescript\n// removes `loading` and `error` from persisted state\nfunction stringifyReplacer(key: string, value: unknown) {\n  return key === 'loading' || key === 'error' ? undefined : value\n}\n\nconst persistedReducer = persistReducer(\n  {\n    key: 'cache',\n    storage,\n    whitelist: ['entities', 'queries'], // mutations are ignored\n    throttle: 1000, // ms\n    serialize: (value: unknown) =\u003e JSON.stringify(value, stringifyReplacer),\n  },\n  reducer\n)\n```\n\n### FAQ\n\n#### What is a query cache key?\n\n**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.\n\nDefault implementation for `getCacheKey` is:\n```typescript\nexport const defaultGetCacheKey = \u003cP = unknown\u003e(params: P): Key =\u003e {\n  switch (typeof params) {\n    case 'string':\n    case 'symbol':\n      return params\n    case 'object':\n      return JSON.stringify(params)\n    default:\n      return String(params)\n  }\n}\n```\n\nIt is recommended to override it when default implementation is not optimal or when keys in params object can be sorted in random order.\n\nAs example, can be overridden when implementing pagination.\n\n#### How race conditions are handled?\n\n**Queries:** Queries are throttled: query with the same cache key (generated from params by default) is cancelled if already running.\n\n**Mutations:** Mutations are debounced: 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.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgentlee%2Freact-redux-cache","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fgentlee%2Freact-redux-cache","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgentlee%2Freact-redux-cache/lists"}