Code Monkey home page Code Monkey logo

remix-client-cache's Introduction

remix-client-cache

GitHub Repo stars npm GitHub npm npm GitHub top language

remix-client-cache is a powerful and lightweight library made for Remix.run to cache your server loader data on the client using clientLoaders.

By default it uses the stale while revalidate strategy and hot swaps your stale info once loaded from the server. It also allows you to invalidate the cache for a specific key or multiple keys.

It allows you to pass in an adapter of your choice to cache your data.

It comes with a default adapter that uses in memory storage to cache your data.

First party support for localStorage, sessionStorage and localforage packages. You can just provide them as the argument to configureGlobalCache.

Install

npm install remix-client-cache

Basic usage

Here is an example usage of remix-client-cache with the default in memory adapter.

import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { ClientLoaderFunctionArgs } from "@remix-run/react";

import { cacheClientLoader, useCachedLoaderData } from "remix-client-cache";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${params.user}`
  );
  const user = await response.json();
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return json({ user: { ...user, description: Math.random() } });
};


// Caches the loader data on the client
export const clientLoader = (args: ClientLoaderFunctionArgs) => cacheClientLoader(args);
  
// make sure you turn this flag on
clientLoader.hydrate = true;

export default function Index() {
  // The data is automatically cached for you and hot swapped when refetched
  const { user } = useCachedLoaderData<typeof loader>(); 

  return (
    <div>
      {user.name} <hr /> {user.email}
      <hr />
      {user.username}
      <hr />
      {user.website} <hr />
      {user.description} 
    </div>
  );
}

Cache adapters

The library exports an interface you need to implement in order to create your own cache adapter. The interface is called CacheAdapter. It closely matches the interface of Storage and requires you to have the following methods:

  • getItem: takes a key and returns a promise that resolves to the value stored at that key
  • setItem: takes a key and a value and returns a promise that resolves when the value is stored
  • removeItem: takes a key and returns a promise that resolves when the value is removed

The cacheLoaderData will use the default memory cache adapter that comes with the library. If you want an advanced use-case make sure that the adapter you provide implements the CacheAdapter interface.

// Inside your entry.client.tsx file 
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

import { configureGlobalCache } from "remix-client-cache";

// You can use the configureGlobalCache function to override the libraries default in-memory cache adapter
configureGlobalCache(() => localStorage); // uses localStorage as the cache adapter

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <RemixBrowser />
    </StrictMode>
  );
});

You can use the configureGlobalCache function to override the libraries default in-memory cache adapter. It will globally switch to whatever adapter you provide to it.

If you want to have a per route adapter you can use the createCacheAdapter to create an adapter and provide it to your hooks and functions.

import { createCacheAdapter, useCachedLoaderData } from "remix-client-cache";

const { adapter } = createCacheAdapter(() => localStorage); // uses localStorage as the cache adapter


// Caches the loader data on the client
export const clientLoader = (args: ClientLoaderFunctionArgs) => cacheClientLoader(args, { 
  // We pass our custom adapter to the clientLoader
  adapter
});
  
// make sure you turn this flag on
clientLoader.hydrate = true;

export default function Index() {
  const { user } = useCachedLoaderData<typeof loader>({ 
    // We use the adapter returned by the createCacheAdapter function
    adapter
  });

  return (
    <div>
      {user.name} <hr /> {user.email}
      <hr />
      {user.username}
      <hr />
      {user.website} <hr />
      {user.description} 
    </div>
  );
}

Here are some examples of how you can use the library with different global adapters.

configureGlobalCache(() => localStorage); // uses localStorage as the cache adapter
configureGlobalCache(() => sessionStorage); // uses sessionStorage as the cache adapter
configureGlobalCache(() => localforage); // uses localforage as the cache adapter

Also with different per route adapters:

const { adapter } = createCacheAdapter(() => localStorage); // uses localStorage as the cache adapter
const { adapter } = createCacheAdapter(() => sessionStorage); // uses sessionStorage as the cache adapter
const { adapter } = createCacheAdapter(() => localforage); // uses localforage as the cache adapter

Let's say you want to use a custom adapter that uses a database to store the data.

You can do that by implementing the CacheAdapter interface and passing it to the configureGlobalCache or createCacheAdapter function.

class DatabaseAdapter implements CacheAdapter {
  async getItem(key: string) {
    // get the item from the database
  }

  async setItem(key: string, value: string) {
    // set the item in the database
  }

  async removeItem(key: string) {
    // remove the item from the database
  }
}

configureGlobalCache(() => new DatabaseAdapter()); // uses your custom adapter as the cache adapter globally
const { adapter } = createCacheAdapter(() => new DatabaseAdapter()); // uses your custom adapter as the cache adapter per route

API's

createCacheAdapter

Function that creates a cache adapter and returns it. It takes one argument, the adapter that is used to store the data.

import { createCacheAdapter } from "remix-client-cache";

const { adapter } = createCacheAdapter(() => localStorage); // uses localStorage as the cache adapter

configureGlobalCache

Function that configures the global cache adapter. It takes one argument, the adapter that is used to store the data.

import { configureGlobalCache } from "remix-client-cache";

configureGlobalCache(() => localStorage); // uses localStorage as the cache adapter

cacheClientLoader

Used to cache the data that is piped from the loader to your component using the clientLoader export.

It takes two arguments, the first one is the ClientLoaderFunctionArgs object that is passed to the clientLoader function, the second one is an object with the following properties:

  • type - that tells the client loader if it should use the normal caching mechanism where it stores the data and early returns that instead of refetching or if it should use the staleWhileRevalidate mechanism where it returns the cached data and refetches in the background.
  • key - key that is used to store the data in the cache. Defaults to the current route path including search params and hashes. (eg. /user/1?name=John#profile)
  • adapter - the cache adapter that is used to store the data. Defaults to the in memory adapter that comes with the library.
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { ClientLoaderFunctionArgs } from "@remix-run/react"; 
import { cacheClientLoader, useCachedLoaderData } from "remix-client-cache";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${params.user}`
  );
  const user = await response.json();
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return json({ user: { ...user, description: Math.random() } });
};

export const clientLoader = (args: ClientLoaderFunctionArgs) => cacheClientLoader(args, {
  type: "swr", // default is swr, can also be set to normal
  key: "/user/1" // default is the current route path including search params and hashes
  adapter: () => localStorage // default is the in memory adapter, can be anything your wish
});
clientLoader.hydrate = true;

decacheClientLoader

Used to remove the data that is piped from the loader to your component using the clientLoader export.

import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { ClientLoaderFunctionArgs } from "@remix-run/react"; 
import { decacheClientLoader, useCachedLoaderData } from "remix-client-cache";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${params.user}`
  );
  const user = await response.json();
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return json({ user: { ...user, description: Math.random() } });
};
// The data is cached here
export const clientLoader = (args: ClientLoaderFunctionArgs) => cacheClientLoader;
clientLoader.hydrate = true;
// It is de-cached after a successful action submission via the clientAction export
export const clientAction = decacheClientLoader;

Accepts an optional object with the following properties:

  • key - key that is used to store the data in the cache.
  • adapter - the cache adapter that is used to store the data.

useCachedLoaderData

Hook that can be used to get the cached data from the clientLoader export. Must be used together with cacheClientLoader because the data returned from the cacheClientLoader is augmented to work with useCachedLoaderData in mind and not the standard useLoaderData hook.

import { useCachedLoaderData } from "remix-client-cache";

// Must be used together with cacheClientLoader
export const clientLoader = (args: ClientLoaderFunctionArgs) => cacheClientLoader(args, "swr");
clientLoader.hydrate = true;

export default function Index() {
  // The data is automatically cached for you and hot swapped when refetched
  const { user } = useCachedLoaderData<typeof loader>(); 

  return (
    <div>
      {user.name} <hr /> {user.email}
      <hr />
      {user.username}
      <hr />
      {user.website} <hr />
      {user.description} 
    </div>
  );
}

Accepts an optional object with the following properties:

  • adapter - the cache adapter that is used to store the data. Defaults to the in memory adapter that comes with the library.

useSwrData

Hook used to get an SWR component that hot swaps the data for you. It takes one argument, loaderData returned by the useCachedLoaderData OR useLoaderData hook.

import { useCachedLoaderData, useSwrData } from "remix-client-cache";

export const clientLoader = (args: ClientLoaderFunctionArgs) => cacheClientLoader(args);
clientLoader.hydrate = true;

export default function Index() {
  // We do not destructure the data so we can pass in the object into the useSwrData hook
  const loaderData = useLoaderData<typeof loader>(); 
  // You can also use useCachedLoaderData hook with the useSwrData hook
  const loaderData = useCachedLoaderData<typeof loader>(); 
  // Pass the loader data into the hook and the component will handle everything else for you
  const SWR = useSwrData(loaderData);

  return (
    <SWR>
      {/** Hot swapped automatically */}
      {({ user }) => (
        <div>
          {data.name} <hr /> {data.email}
          <hr />
          {data.username}
          <hr />
          {data.website} <hr />
          {data.description} 
        </div>
      )}
    </SWR>
  );
}

invalidateCache

Utility function that can be used to invalidate the cache for a specific key. It takes one argument, the key that is used to store the data in the cache. Can also be an array of keys

import { invalidateCache } from "remix-client-cache";

invalidateCache("/user/1"); // invalidates the cache for the /user/1 route

Keep in mind this can only be used on the client, so either in clientLoader or clientAction exports or the components themselves.

useCacheInvalidator

Hook that returns a function that can be used to invalidate the cache for a specific key. It takes one argument, the key that is used to store the data in the cache. Can also be an array of keys

import { useCacheInvalidator } from "remix-client-cache";

export default function Index() {
  const { invalidateCache } = useCacheInvalidator(); 

  return (
    <div>
      // invalidates the cache for the /user/1 route
      <button onClick={ () => invalidateCache("/user/1") }>Invalidate cache</button>
    </div>
  );
}

Support

If you like the project, please consider supporting us by giving a ⭐️ on Github.

License

MIT

Bugs

If you find a bug, please file an issue on our issue tracker on GitHub

Contributing

Thank you for considering contributing to remix-client-cache! We welcome any contributions, big or small, including bug reports, feature requests, documentation improvements, or code changes.

To get started, please fork this repository and make your changes in a new branch. Once you're ready to submit your changes, please open a pull request with a clear description of your changes and any related issues or pull requests.

Please note that all contributions are subject to our Code of Conduct. By participating, you are expected to uphold this code.

We appreciate your time and effort in contributing to remix-client-cache and helping to make it a better tool for the community!

remix-client-cache's People

Contributors

alemtuzlak avatar

Stargazers

akazwz avatar Stephen Weiss avatar Jinesh Shah avatar  avatar Thomas Valadez avatar Hitesh Riziya avatar S San avatar Marcus S. Abildskov avatar Larissa avatar Bhanu Teja Pachipulusu avatar Geoff Holden avatar The Vibery avatar Leon Liefting avatar  avatar Edward Bella avatar Hazel Daniel avatar Eric Josue avatar Julian avatar Black avatar Daniel Hammami avatar Alireza Barzegar avatar Tim Kersey avatar Eric Fung avatar Pat Needham avatar #342b45 avatar BeiXiao avatar Moishi Netzer avatar Joern avatar  avatar Niels Kootstra avatar  avatar JP   avatar Erick Hilda Andreas avatar Hung Vu avatar  avatar Bapusaheb Patil avatar Sam avatar Syamil Abdillah avatar  avatar Claus Haas avatar Rishi Viswanathan avatar Dmytro Pushkarchuk avatar Anibal avatar Smarthug avatar Tim Zolleis avatar Tobias Möritz avatar Jody Clements avatar Gustavo Domaradzki avatar  avatar Nabin Saud avatar  avatar Yara Polana avatar Max avatar Alexandre Stahmer avatar Aleksandr B. avatar Dario Ielardi avatar David Southmountain avatar  avatar James Restall avatar Will Honey avatar akshay kadam (a2k) avatar Brandon Kish avatar  avatar Kalle avatar Carlo avatar Leo Miranda avatar Trey Long avatar Cingala David avatar Ciofu Serban Ioan avatar Luis Amado avatar Saurabh Prakash avatar Wes Grimes avatar CA Gustavo avatar Jonas Fleur-Aime avatar  avatar  avatar Taeho Kim avatar Ben Kraus avatar Thibault Lenclos avatar Rui Saraiva avatar Gary Ditsch avatar Nahuel Luca avatar blurk avatar Brandon Orther avatar yupix avatar Marcelo Dias avatar  avatar Michael Carter avatar Coji Mizoguchi avatar 김회준 avatar ishan avatar  avatar Cole Townsend avatar Sébastien Lorber avatar Darren Baldwin avatar Hello avatar Greg Huber avatar Sky Winston avatar  avatar Y6NDR avatar

remix-client-cache's Issues

InvalidCache with wildcards

If an user goes to /books and perform a query to /books?query="harry potter" both are cached. If user add to its favorites the book from the query. Then we need to invalidate both routes.

Since /books displays the favorites at the top.

It would be great to be able to invalidate the cache from a wildcard such as invalidateCache("/books*")

Great job btw!

Can I use this to cache client loader data too?

This is working great for server data - but in my usecase I am fetching data in the client loader that I also want to cache. Below is some rough code of what I'm trying to do - is this possible?

export async function clientLoader(args: ClientLoaderFunctionArgs) {
	const cache = await cacheClientLoader(args, {
		type: 'swr',
	})

	if (cache.whiteboards) {
		console.log(cache)
		return cache
	}

	const { serverLoader } = args
	const server = (await serverLoader()) as {}

	const whiteboards = await getAllLocalWhiteboards()

	return {
		...server,
		whiteboards,
	}
}

Invalidate `normal` cache on `action`?

I used a bunch of normal caches to not fetch data that hasn't changed between page changes, but I was expecting an action on the same page to invalidate even the normal cache (in swr manner).

In essence I was expecting it to be a strategy that behaves like swr if action is triggered, but otherwise like normal.

Pathless routes having the same cache key as their parent

Example route with a pathless child:

/accounts.tsx
/accounts._index.tsx

Both have the same cache key. As seen in useCachedLoaderData() result:

/accounts.tsx:
{ serverData: {…}, deferredServerData: undefined, key: "/accounts", cacheKey: "/accounts", ... }
/accounts._index.tsx:
{ serverData: {…}, deferredServerData: undefined, key: "/accounts", cacheKey: "/accounts", ... }

This makes the first renders of the child route return wrong data (The data from the parent), leading to potential X is undefined errors, between others.

Support caching for streamed responses

Stackblitz

When using defer to stream a response from a loader that returned a promise which later used in a boundary, we cannot properly serialize the promise to store say in localStorage or IndexedDB (JSON.stringify will result in an empty object {}). The only way to store this type of data is directly in memory, so on the next navigation, clientLoader will return fulfilled promise with the data, rather than calling the server `loader'.

let cache: SerializeFrom<typeof loader>;
export const clientLoader = defineClientLoader(async ({ serverLoader }) => {
    if (!cache) {
        cache = await serverLoader<typeof loader>();
    }

    return cache;
});

clientLoader.hydrate = true;

By using remix-client-cache, we can create an adapter for when we need to cache streaming responses, rather than relying on a global setting using, for example, localStorage.

import { lru } from 'tiny-lru';
import { type CacheAdapter, createCacheAdapter } from 'remix-client-cache';

const cache = lru(100);

class LRUAdapter implements CacheAdapter {
	async getItem(key: string) {
		return cache.get(key);
	}

	async setItem(key: string, value: any) {
		return cache.set(key, value);
	}

	async removeItem(key: string) {
		return cache.delete(key);
	}
}

export const { adapter: lruAdapter } = createCacheAdapter(() => new LRUAdapter());

routes/index.tsx:

import { defer } from '@remix-run/node';
import { Await, ClientLoaderFunctionArgs, Link } from '@remix-run/react';
import { Suspense } from 'react';
import { cacheClientLoader, useCachedLoaderData } from 'remix-client-cache';
import { lruAdapter } from '~/client-cache-adapter';

async function getQueryData() {
	await new Promise((resolve) => setTimeout(resolve, 3000));
	return { data: [{ id: 1 }] };
}

export async function loader() {
	const query = getQueryData();

	return defer({
		query,
	});
}

export const clientLoader = (args: ClientLoaderFunctionArgs) => cacheClientLoader(args, { adapter: lruAdapter });
clientLoader.hydrate = true;

export default function Page() {
	const { query } = useCachedLoaderData<typeof loader>();

	return (
		<>
			<Link to="/">Home</Link>

			<br />
			<br />

			<Suspense fallback="loading...">
				<Await resolve={query}>
					{({ data }) => (
						<ul>
							{data?.map((entry) => <li key={entry.id}>{entry.id}</li>)}
						</ul>
					)}
				</Await>
			</Suspense>
		</>
	);
}

This opens up a question whether it's a good idea to store data in server memory, it would be interesting if we could transform fulfilled promises using a library like turbo-stream to store on the client-side or use a web worker and decode to the original form for consumption by .


There is currently a bug where a returned promise from the cache has already been fulfilled, the internal logic of remix-client-cache cannot understand when a revalidation should occur, or fulfilled data is currently present, since we must store promises directly in memory and not as a string.
https://github.com/forge42dev/remix-client-cache/blob/main/src/index.tsx#L116-L140

// Unpack deferred data from the server
useEffect(() => {
	let isMounted = true;
	if (loaderData.deferredServerData) {
		loaderData.deferredServerData.then((newData: any) => {
			if (isMounted) {
				adapter.setItem(loaderData.key, newData);
				setFreshData(newData);
			}
		});
	}
	return () => {
		isMounted = false;
	};
}, [loaderData, adapter]);

// Update the cache if the data changes
useEffect(() => {
	if (
		loaderData.serverData &&
		JSON.stringify(loaderData.serverData) !== JSON.stringify(freshData)
	) {
		setFreshData(loaderData.serverData);
	}
}, [loaderData?.serverData, freshData]);

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.