DEV Community

Cover image for Ultimate Caching Guide 2: Javascript/React
CSJcode
CSJcode

Posted on

Ultimate Caching Guide 2: Javascript/React

This article is focused on caching in Javascript and React.

  • Most of the concepts in this article are applicable to other languages and frameworks.
  • Javascript is familiar to many and intertwined with the browser caching APIs, so it is a good place to start.
  • In this article, I am not going into too much detail on browser cache-related APIs and will leave that to a future article.

Also see:
Ultimate Caching Guide 1: Overview and Strategies

Javascript caching

Using caching techniques at the application level can improve performance and reduce the load on the server.

There are a number of caching methods available to Javascript developers to improve performance.

Closure caching

Returning a closure from a function that caches the result of a computation.

function calculateSum() {
  let cache = 0
  return function (num) {
    if (num === undefined) {
      return cache
    } else {
      cache += num
      return cache
    }
  }
}

const sum = calculateSum()
console.log(sum(2)) // Output: 2
console.log(sum(4)) // Output: 6
console.log(sum()) // Output: 6 (cached value)
console.log(sum(5)) // Output: 11
console.log(sum()) // Output: 11 (cached value)

Enter fullscreen mode Exit fullscreen mode

Data Structure Caching

Similar caching can be done with various data structures, such as arrays, objects, Maps, Sets, etc.

Basic object cache, usually placed inside a function:

const cache = {}

function getFromCache(key) {
  return cache[key]
}

function addToCache(key, value) {
  cache[key] = value
}

const complexCalculation = (a, b, c) =>
  Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2) + Math.pow(c, 2)) /
  (Math.pow(Math.E, a) + Math.pow(Math.E, b) + Math.pow(Math.E, c))

addToCache('mathResult', complexCalculation(1, 2, 3))
console.log(getFromCache('mathResult')) // Output: '0.12392517788687459'


Enter fullscreen mode Exit fullscreen mode

or

Set:

const cache = new Set()

function cachedData(data) {
  if (cache.has(data)) {
    console.log('Data is cached')
    return data
  }

  fetchData(data)
  cache.add(data)

  return data
}

// fetchData(data)
function fetchData(data) {
  return data
}

const data = { "a": 1, "b": 2 };
const result1 = cachedData(data)
console.log(result1) 
const result2 = cachedData(data)
console.log(result2)


Enter fullscreen mode Exit fullscreen mode

Memoization

Object caching is focused on the data structures used by a program, while memoization is focused on the results of a function.

function memoize(fn) {
  const cache = {}
  return function (...args) {
    const key = JSON.stringify(args)
    if (cache[key]) {
      console.log('Returning from cache...')
      return cache[key]
    }
    const result = fn.apply(this, args)
    cache[key] = result
    return result
  }
}

function expensiveComputation(x, y) {
  console.log('Performing expensive computation...')
  return x + y
}

const memoizedComputation = memoize(expensiveComputation)

console.log(memoizedComputation(2, 3)) // Performing expensive computation... 5
console.log(memoizedComputation(2, 3)) // 5 (returned from cache)

Enter fullscreen mode Exit fullscreen mode

Memory-based Storage API (eg. SessionStorage)

It's simple and straightforward to use the Storage API to cache data in the browser.

SessionStorage and LocalStorage are similar in terms of functionality, but LocalStorage persists data across browser sessions.

  • there is a separate sessionStorage for each tab or window.
  • When the tab or window is closed, the web browser ends the session and clears sessionStorage.
  • it's specific to the protocol of the page.
// Store data in SessionStorage
sessionStorage.setItem('myData', JSON.stringify({ foo: 'bar' }));

// Retrieve data from SessionStorage
const data = JSON.parse(sessionStorage.getItem('myData'));

Enter fullscreen mode Exit fullscreen mode

Disk-based (LocalStorage, IndexDB)

LocalStorage is a key-value store persisting data across browser sessions.

https://developer.mozilla.org/en-US/docs/Web/API/Storage

IndexDB is a more complex API for storing data in the browser and allows more complex querying and indexing of structured data. It can store data in different formats, including binary data, and provides asynchronous APIs for better performance. IndexedDB is asynchronous, while LocalStorage is synchronous.

https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API

SessionStorage is cleared when the browser is closed. LocalStorage is cleared when the user clears their browser cache.

const cacheKey = 'myData';
let cachedData = JSON.parse(localStorage.getItem(cacheKey));

if (!cachedData) {
  // Data is not in cache, fetch it from the server
  fetch('/my/data').then(response => {
    // Cache the response data
    response.json().then(data => {
      localStorage.setItem(cacheKey, JSON.stringify(data));
    });
  });
} else {
  // Use cached data
  console.log('Using cached data:', cachedData);
}
Enter fullscreen mode Exit fullscreen mode

WebWorker

WebWorkers can be used for background caching and to store data in memory, basically non-blocking interaction with the app's main thread with 'postMessage'. Also it can be used for offline functionality.

// Listen for requests from the main thread
self.addEventListener('message', event => {
  const { url, cacheKey } = event.data;

  // Check if the data is cached in localStorage
  const cachedData = localStorage.getItem(cacheKey);
  if (cachedData) {
    // If cached data is found, send the data back to the main thread
    self.postMessage(JSON.parse(cachedData));
  } else {
    // If cached data is not found, fetch the data from the API
    fetch(url)
      .then(response => response.json())
      .then(data => {
        // Store the fetched data in localStorage for future use
        localStorage.setItem(cacheKey, JSON.stringify(data));
        // Send the fetched data back to the main thread
        self.postMessage(data);
      })
      .catch(error => {
        // If an error occurs, send an error message back to the main thread
        self.postMessage({ error: error.message });
      });
  }
});

Enter fullscreen mode Exit fullscreen mode

Service Worker

Network Only, Network first, then cache, Stale-While-Revalidate, Cache First,fall back to network, Cache Only

The Service Worker is a bit more versatile than the WebWorker and will cache the specified files when it is installed, and intercept fetch requests to return cached responses if available. It will also clean up old caches when the new version of the Service Worker is activated.

You can think of it like a local reverse proxy server that intercepts requests and returns cached responses if available.

// Install the Service Worker and cache some files
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('my-cache').then(cache => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/script.js',
        '/image.png'
      ]);
    })
  );
});

// Intercept fetch requests and return cached responses if available
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

// Clean up old caches when activating the new version of the Service Worker
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.filter(cacheName => {
          return cacheName !== 'my-cache';
        }).map(cacheName => {
          return caches.delete(cacheName);
        })
      );
    })
  );
});

Enter fullscreen mode Exit fullscreen mode

Libraries

lru-cache

https://www.npmjs.com/package/lru-cache

  • provides a caching mechanism with a least recently used (LRU) eviction policy.
  • The LRU policy means that when the cache reaches its maximum size, the least recently used items are removed from the cache to make room for new items.
  • Optimize memory usage and performance by ensuring that the most frequently accessed items remain in the cache, while less frequently accessed items are evicted.

node-cache-manager

https://www.npmjs.com/package/cache-manager

React (other SPAs have similar options)

Memoization

  • React provides a built-in utility called React.memo() that can be used to memoize functional components. However, modern usage of React Hooks makes it easier and is the common usage.
  • useMemo
  • useCallback

A code example is below:

The two hooks are similar, but they have different use cases. useMemo is used to memoize the result of a function, while useCallback is used to memoize the function itself.

  • useMemo is used to memoize the result of the calculateSum function.
  • useCallback is used to memoize the calculateSum function. The calculateSum function will only be re-created if one of these values changes.

By using useCallback and useMemo, we're able to optimize our code and avoid unnecessary re-renders.

import React, { useState, useMemo, useCallback } from 'react';

function App() {
  const [num1, setNum1] = useState(0);
  const [num2, setNum2] = useState(0);

  const calculateSum = useCallback(() => {
    console.log("Calculating sum...");
    return num1 + num2;
  }, [num1, num2]);

  const sum = useMemo(() => calculateSum(), [calculateSum]);

  return (
    <div>
      <h1>Sum: {sum}</h1>
      <input type="number" value={num1} onChange={(e) => setNum1(parseInt(e.target.value))} />
      <input type="number" value={num2} onChange={(e) => setNum2(parseInt(e.target.value))} />
    </div>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

Query caching

Another important React caching technique is query caching. Query caching is used to store the results of a query so that the query doesn't have to be executed again if the same query is made. This can be useful for reducing the number of API requests made to a server.

Most popular libraries have their own query caching mechanism.

This chart shows some of the varieties available:
https://redux-toolkit.js.org/rtk-query/comparison

PureComponent (deprecated, but still in some codebases)

  • PureComponent is a subclass of the React.Component that implements a shallow comparison of props and state to determine if a re-render is necessary. This can improve performance by preventing unnecessary re-renders.

https://beta.reactjs.org/reference/react/PureComponent

React.lazy() and Suspense

  • React.lazy() is a newer feature introduced in React 16.6 that allows for lazy loading of components. Suspense is a component that allows you to handle loading states and fallbacks.

It's mainly use for code-splitting, but could facilitate caching since it dynamically loads components of code and stores in memory to be re-used in other components.

Context API

The Context API provides a way to pass data through the component tree without having to pass props down manually at every level.

By using context, we are effectively caching props and can avoid unnecessary re-renders and improve performance.

import React, { useContext } from 'react';

// Define a context with a default value
const MyContext = React.createContext('default value');

// A component that uses the context
function MyComponent() {
  // Get the value of the context using useContext hook
  const contextValue = useContext(MyContext);

  return (
    <div>
      <p>Context value: {contextValue}</p>
    </div>
  );
}

// A parent component that provides the context value
function App() {
  return (
    <MyContext.Provider value="hello world">
      <MyComponent />
    </MyContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next.js and Server-side rendering

Server-side rendering is the process of rendering the initial HTML and React components on the server before sending it to the client. This can improve performance by reducing the time it takes for the client to receive and render the content.

Next.js provides several built-in caching mechanisms that allow pages to be cached in memory or on disk, reducing the need to re-render pages for subsequent requests.

Memoized selectors

Redux is a popular state management library used with React.

Memoized selectors are functions that compute derived data from the Redux store (stored state data in memory).

Redux and RTK provide built-in support for memoized selectors through the reselect library - https://github.com/reduxjs/reselect

By memoizing these functions, you can avoid re-computing the same data over and over again.

This is basically extending memoization to the Redux framework and state management data structures.


We've looked at how caching can be used at the application level in Javascript and React to improve performance.

There are quite a few choices to make when it comes to caching, and it's important to understand the tradeoffs and use cases for each.

Some like useMemo and useCallback are used to optimize the performance of React components.

Others like Apollo Client and React Query are used to cache API requests and reduce the number of requests made to a server.

While some like Next.js and SSR are used to cache the output of server-side rendering.

Also, in the first article in this series we already examined some of the main caching patterns which can be applied across all types of services.

Next article we'll continue to trace our caching options through the cloud and into large-scale distributed systems.

Also see:
Ultimate Caching Guide 1: Overview and Strategies

and

150+ Solutions Architect metrics/calculations cheatsheet

Top comments (1)

Collapse
 
mjoycemilburn profile image
MartinJ

Thanks CSJ - that has opened my eyes to quite a few things I simply didn't know about.

I'm particularly interested in Next.js caching just now, but their own docs are aggressively technical,. Your high-level intro has been invaluable.

Keep up the good work! Regards, MartinJ