Skip to content

Pouria-Rezaeii/rxjs-response-cache

Repository files navigation

RxJS Response Cache

Have you ever struggled with efficiently caching API responses retrieved through observables?

It gets trickier, especially when dealing with dynamic filters like pagination in APIs. Or consider scenarios where you're confident that a response is unlikely to change during a user's visit to your website. However, deciding when to expire the cache becomes a challenge, potentially leading to users receiving outdated information or setting such a short expiration time that it becomes impractical.

In many cases, these challenges lead us to skip caching, resulting in less satisfying user experience and performance.

Now, let's dive into RxJS Response Cache and see how it streamlines caching.

The package automatically stores a diverse range of responses, assigning them unique keys generated from a combination of the URL and alphabetically sorted query parameters. This ensures that each distinct response is effortlessly stored and managed.

Also, with its use-stale-while-refreshing feature, not only does it reduce wait times, but it guarantees users consistently have the most up-to-date data at their fingertips.

Check the Live Demo

Main Features

  • Global accessibility throughout the application.
  • Uses stale data during refresh.
  • Accelerates data access.
  • Reduces network requests.
  • Simplifies prefetching.
  • Includes clear timeouts for precise caching control.
  • Integrated DevTool for visual cache event inspection.
  • Designed for easy use.

Document Main Sections

Usage Examples

Install the package:

npm install rxjs-response-cache --save

or

yarn add rxjs-response-cache

Instantiate the cache service at the root of your application or any other location within the components tree.

import ResponseCache from 'rxjs-response-cache';

const cache = new ResponseCache({
   isDevMode: process.env.MODE === "development",
   devtool: {
      show: true,
   },
});

See Configuration Available Parameters

Supply it as needed and start using it as follows:

Please Note that you can use the get() method (which returns a new observable) in 2 ways:

  • Using arrangedUrl
  • Ignoring arrangedUrl

arrangedUrl is a part of the auto-generated key used by the service to store data. It's a combination of provided url, string query parameters (if they exist in url parameter), defaultParams and params. The values are alphabetically sorted, and empty strings, undefined, and null values are automatically removed (null value removal can be configured).

For a deeper understanding, refer to the Cache Structure and Auto-Generated Keys section.

Method 1 ( Using arrangedUrl ):

const getPosts = () => {
   return cache.get<Post[]>({
      url: "posts",
      defaultParams: {page: 1, "page-size": 20},
      params: urlParamsObject,
      observable: ({arrangedUrl}) => observable<Post[]>(arrangedUrl),
   }).pipe(your_operations);
}

Method 2 ( Ignoring arrangedUrl argument and working with your own data ):

const getPosts = () => {
   const url = "posts";
   const params = urlParamsObject;
   return cache.get<Post[]>({
      url: url,
      defaultParams: {page: 1, "page-size": 20},
      params: params,
      observable: () => observable<Post[]>(url, {params}),
   }).pipe(your_operations);
}

Read the following section to understand when to use each method?

Best practice: Chain the pipe()s to the get() method, not the passed observable. This ensures that the actual API response, not a potentially modified version, is stored in the cache, and prevents potential bugs when working with the same API but different operations in separate modules.

Important Hint: Ensure that you also provide the parameters (if they exist) to the get() method. This is essential as the service uses all query parameters to generate unique keys.

Additionally, to achieve the best possible results from the service, always include your API default parameters when they can be altered by the end-user. This prevents the generation of two different keys for /posts and /posts?page=1, even though they are essentially the same.

Read the Cache Structure and Auto-Generated Keys section for more details.

See Get Method Available Parameters

And then:

getPost().subscribe();

Determining When to Use Second Method

You may opt for the second method only when there's a specific requirement that is ignored in arrangedUrl. In arrangedUrl, all empty strings, undefined, and null values are automatically removed (ignoring null values can be configured). Additionally, duplicated query parameters are overwritten, and you should concatenate them with commas if you genuinely need all of them. If this behavior doesn't meet your needs, consider using the second method and work with your own data.

Usage Example in Angular

Hint: Ensure you have read the Usage Example section first.

import ResponseCache from 'rxjs-response-cache';

function cacheFactory() {
   return new ResponseCache({
      isDevMode: isDevMode(),
      devtool: your_desired_options,
   });
}

@NgModule({
   providers: [
      {provide: ResponseCache, useFactory: cacheFactory},
   ],
})

And start using it in your services:

getPosts = () => {
   return this.cache.get<Post[]>({
      url: "posts",
      observable: ({arrangedUrl}) => this._httpClient.get<Post[]>(arrangedUrl),
      ...other_params,
   });
}

And then in your components:

getPost().subscribe();

Cache Structure and Auto-Generated Keys

The cache is a map of auto-generated keys and the data. For example, a code snippet like this:

const getPosts = () => {
   return cache.get<Post[]>({
      url: "posts",
      defaultParams: {page: 1 },
      params: {
         page: url.query.page, 
         "start-date": some_date, 
         "end-date": some_date,
         "some-other-param": is_undefined_for_now 
      },
      observable: ({arrangedUrl}) => observable<Post[]>(arrangedUrl),
   }).pipe(your_operations);
}

Will update the cache to this:

const cache = {
   "posts? end-date=some_date & page=some_number & start-date=some_date": res
}

arrangedUrl passed as an argument to your observable is essentially this auto-generated key.

Please note that the query parameters are sorted and undefined value is removed.

Best practice: Chain the pipe()s to the get() method, not the passed observable. This ensures that the actual API response, not a potentially modified version, is stored in the cache, and prevents potential bugs when working with the same API but different operations in separate modules.

Determining When to Use a Unique Identifier

This value, if present, will be added to the auto-generated key for storing the data. In most cases (99.99%), it's unnecessary. Consider using it only if you must differentiate between two data types which are going to generate the exact same key.

Prefetching

Simply subscribe to your API handler, and the result will be stored in the cache for later use.

getPost().subscribe();

Cleaning the Data

The clean() method allows you to remove specific data or multiple entries from the cache.

Hint: if you used uniqueIdentifier, make sure to include it in the second parameter.

Note: The default behavior for queries is NOT based on an exact match.

Examples

Picture the cache in this state:

const cache = {
    "posts?page=1" : data,
    "posts?page=1&comments=true" : data,
    "posts?page=2": data,
    "tweaked_posts__posts?page=1" : tweakedData,
}

To clean all the keys containing "posts" & page=1 (matches the 2 first keys):

cache.clean('posts',{ queryParams: { page: 1} })

To clean one key, containing "posts" & page=1 (exact match):

cache.clean('posts',{ queryParams: { page: 1}, exact: true })

Please note that neither of the above examples removes the fourth and fifth keys because uniqueIdentifier is not included in the options.

To clean all the keys containing "posts" & uid=tweaked_posts (matches only the forth key):

cache.clean('posts',{ uniqueIdentifier: "tweaked_posts", queryParams: { comments: true} })

See Clean Method Available Parameters

Resetting the Cache

The reset() method clears the entire cache.

cache.reset();

Updating the Cache

Coming Soon: Update functionality is slated for the next minor version release!

How Refreshing Works with RxJS Subscribers

If the data is not present in the cache, subscriber.next() and subscriber.complete() are triggered when the request is resolved.

If the data is already present in the cache, subscriber.next() is immediately triggered with the stale data. By default, once the request is resolved, the newly fetched data is compared to the stale data. If they differ, subscriber.next() is invoked again with the fresh data, and ultimately, subscriber.complete() is triggered.

This equality check can be disabled in the configuration, causing subscriber.next() to be called twice, even if the data is identical to the cached version.

Please note that you should stop rendering spinners and skeletons into the next() function not the complete(), when using the refresh feature.

Multiple Instances

Using multiple instances of the service is supported, but the devtool should be used with one instance at a time.

Bulk Operations

The get() method returns a new observable, so use it with bulk operations as usual. Example:

const res = forkJoin({ foo: cache.get(), bar: cache.get() })

Null Values in Query Params

Null values are ignored from query parameters by default. This behavior can be changed in the cache configuration at instantiation.

See Configuration Available Parameters

Developer Tool

The integrated developer tool allows you to inspect the last state of the cache and its history of changes. Additionally, every event related to the cache will be logged in the tool.

See Devtool Available Parameters

API Reference

Configuration Parameters

Name Type Description
isDevMode boolean In dev mode, clear timeout IDs will be stored in local storage to be cleared in possible hot-reloads. This ensures that the devtool does not display incorrect information from previous loads during development.
Additionally, the devtool is available only in dev mode.
paramsObjectOverwrites-
UrlQueries
boolean [=true] Determines how the service should behave if a query parameter is accidentally present in both the url parameter and the params parameter.
Example: cache.get({url: "/posts?page=2", params: {page: 3}, observable:() => observable}) by default will be resolved to "/post?page=3".
preventSecondCall
IfDataIsUnchanged
boolean [=true] Determines whether the observable.next() should be invoked again when the refreshed data is identical to the stale data.
By default, the observable.next() is invoked only once in such cases, optimizing to prevent unnecessary rerenders in applications.
If desired, you can pass false and perform your own check within your application.
For a detailed explanation, please refer to the How Refreshing Works with RxJS Subscribers section.
removeNullValues boolean [=true] Determines whether null values should be removed from query parameters or not.
devtool object [:?] Developer tool configuration. See Devtool Available Parameters.



Service Instance Methods & Properties

Name Type Description
get() function Fetches data and stores the expected result in the cache.
clean() function Allows you to remove specific data or multiple entries from the cache.
reset() function Clears the entire cache.
config object Configuration passed to the service.
data object Stored data.
observables object Stored observables.
clearTimeouts object Active clear timeouts.



Get Method Parameters

Name Type Description
url string The endpoint address (may include query parameters or not).
observable () => function The callback function that returns an observable. It receives an object containing the arrangedUrl as input.
See Cache Structure and Auto-Generated Keys for details.
uniqueIdentifier string [:?] This value, if present, will be added to the auto-generated key for storing the data.
See When to Use Unique Identifier .
defaultParams object [:?] The API's default query parameters.
To optimize cache results, ensure to include them if they can be altered by the end-user.
params object [:?] The queryParams will overwrite the defaultParams, and by default (configurable), any query strings in the url parameter will also be overwritten.
refresh boolean [=false] Determines if the data should be refreshed on the next calls or noDetermines if the data should refresh on subsequent calls.
By default, the API will be called only once.
Passing true is especially useful when you are unsure if the data will remain the same. This way, users receive the old data immediately and then see the newly fetched data if there are any changes.
clearTimeout number [?:] The time in milliseconds used to remove the data from the cache.



Clean Method Parameters

Name Type Description
url string The endpoint address (may include query parameters or not).
DO NOT include the uniqueIdentifier part here.
options object [?:] Extra options for cleaning.
options.exact boolean [?:] Determines if the query should be based on an exact match or not.
options.uniqueIdentifier string [?:] Unique identifier.
Note: If the key includes a unique identifier, you should pass it here, even if the query is not based on an exact match.
options.queryParams object [?:] Query Parameters. They will be sorted and truncated if they contain an empty string, undefined, or null (null is configurable).

See Cleaning the data for examples.

Devtool Parameters

type DevtoolConfig = {
   show?: boolean; // default = isDevMode && true
   isOpenInitially?: boolean; // default = false
   styles?: {
      zIndex?: number; // default = 5000 
      toggleButtonPosition?: {
         right?: number; // default = 25
         bottom?: number; // default = 25
      };
   };
}