nuxt-multi-cache
nuxt-multi-cache copied to clipboard
useCachedAsyncData composable
I may be wrong, but most often we have to deal with asyncData
To avoid routine, I suggest creating a wrap-composable useCachedAsyncData
Example
import type { NuxtApp, AsyncDataOptions } from 'nuxt/app';
import type { KeysOf } from 'nuxt/dist/app/composables/asyncData';
export function useCachedAsyncData<
ResT,
DataT = ResT,
PickKeys extends KeysOf<DataT> = KeysOf<DataT>,
DefaultT = null
>(
key: string,
handler: (ctx?: NuxtApp) => Promise<ResT>,
options: AsyncDataOptions<ResT, DataT, PickKeys, DefaultT> & {
cacheKey: string;
cacheTags: string[];
cacheExpires?: number;
}
) {
// We need to cache transformed value to prevent value from being transformed every time.
const transform = options?.transform;
// Remove transform from options, so useAsyncData doesn't transform it again
const optionsWithoutTransform = { ...options, transform: undefined };
return useAsyncData(
key,
async () => {
const { value, addToCache } = await useDataCache<
DataT | Awaited<ResT>
>(options.cacheKey);
if (value) {
return value;
}
const _result = await handler();
const result = transform ? transform(_result) : _result;
addToCache(result, options.cacheTags, options.cacheExpires);
return result;
},
optionsWithoutTransform
);
}
This sounds interesting, I will give this a try!
Oh yes this would be so handy!
@or2e I'm now looking into implementing this. Was there a particular reason you added the cacheKey
as an option instead of using the key
argument provided? Just wondering, maybe I'm not thinking of a use case :smile:
99% of the time they match
Right - so I guess it would be fine to reuse that key and make it required.
For the cacheExpires
and cacheTags
options: I thought about making these methods, that receive the untransformed result. That way cacheability metadata like tags and expires coming from a backend response could be used.
@or2e @Crease29 I've implemented it now and opened a PR, if you like you can take a look and tell me if the implementation makes sense :smile: The docs are here: https://deploy-preview-58--nuxt-multi-cache.netlify.app/composables/useCachedAsyncData
Thank you so much! :)
Regarding the documentation:
In the very first example, I'd actually replace weather
with users
and unify the cache key with the full example.
In the full example I'd personally prefer showing the use with static cache tags, I think that's more common than the response including cache tags.
It seems your implementation uses the regular useAsyncData()
composable in client side. IMHO, it would be nice to also have a kind of memoization with a TTL by using transform()
and getCachedData()
.
That's what I've done in my current project.
Here is the implementation :
// useCacheableAsyncData.ts
import { toRef, toValue } from 'vue';
import { callWithNuxt, type NuxtApp } from '#app';
import type { AsyncDataOptions, KeysOf } from '#app/composables/asyncData';
import { useDataCache } from '#nuxt-multi-cache/composables';
import { assertValidCacheKey, assertValidTtl } from '~/lib/cache-utils';
export interface TimestampedPayload<T> {
payload: T;
issuedAt: number;
}
function wrapPayloadWithTimestamp<T>(payload: T, issuedAt = Date.now()): TimestampedPayload<T> {
return {
payload,
issuedAt,
};
}
function unwrapTimestampedPayload<T>({ payload }: TimestampedPayload<T>): T {
return payload;
}
export type CachedAsyncDataOptions<
// eslint-disable-next-line unicorn/prevent-abbreviations
ResT,
DataT = ResT,
PickKeys extends KeysOf<DataT> = KeysOf<DataT>,
DefaultT = null,
> = Omit<AsyncDataOptions<ResT, DataT, PickKeys, DefaultT>, 'transform' | 'getCachedData'> & {
/**
* Time To Live in milliseconds
* @example 60_000 for 1 min
*/
ttl?: number;
cacheTags?: string[] | ((response: ResT) => string[]);
};
export default async function useCacheableAsyncData<
// eslint-disable-next-line unicorn/prevent-abbreviations
ResT,
NuxtErrorDataT = unknown,
DataT extends TimestampedPayload<ResT> = TimestampedPayload<ResT>,
PickKeys extends KeysOf<DataT> = KeysOf<DataT>,
DefaultT = DataT,
>(
key: string,
handler: (context?: NuxtApp) => Promise<ResT>,
options?: CachedAsyncDataOptions<ResT, DataT, PickKeys, DefaultT>,
) {
const { ttl, cacheTags = [], ...otherOptions } = options ?? {};
if (ttl !== undefined) {
assertValidTtl(ttl);
}
assertValidCacheKey(key);
const { data, ...others } = await useAsyncData<
ResT,
NuxtErrorDataT,
TimestampedPayload<ResT> | undefined,
PickKeys,
DefaultT
>(
key,
async (nuxtApp) => {
const { value, addToCache } = await useDataCache<ResT>(key);
if (value) {
return value;
}
const response = nuxtApp === undefined ? await handler(nuxtApp) : await callWithNuxt(nuxtApp, handler, [nuxtApp]);
await addToCache(
response,
Array.isArray(cacheTags) ? cacheTags : cacheTags(response),
ttl ? ttl / 1000 : undefined,
);
return response;
},
{
...otherOptions,
transform(input) {
return wrapPayloadWithTimestamp(input);
},
getCachedData(key, nuxtApp) {
const data: DataT | undefined = (nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]) as DataT | undefined;
// No data in payload
if (data === undefined) {
return;
}
if (ttl !== undefined && data.issuedAt + ttl < Date.now()) {
return;
}
return data;
},
},
);
// data.value cannot be undefined at this point. Using `as` to fix type. Maybe this is a typing issue in Nuxt source code
const value = toValue(data.value) as TimestampedPayload<ResT>;
return { data: toRef(() => unwrapTimestampedPayload(value)), ...others };
}
Usage:
const { data } = useCacheableAsyncData(
'some:cache:key',
async () => {
return Promise.resolve('Some data');
},
{
deep: false,
ttl: import.meta.server ? CACHE_TTL_MS_12_HOURS : CACHE_TTL_MS_15_MIN,
},
);
Using import.meta.server
, I have even been able to specify different TTLs for server and client (as they have their own bundle) with way shorter TTLs in client.
I didn't found any downside for now.
WDYT ?
@bgondy I actually have been thinking about extending useDataCache
to the client-side as well. And indeed your approach with a maxAge that would also apply to the client side is a nice idea. My main concern is (the classic) question of cache invalidation client side. Obviously a simple browser refresh always "purges" the cache. But imho it would have to work a bit like useAsyncData's clear
and refresh
.
In this case here, however, since the underlying useAsyncData
already does some rudimentary "caching" anyway, we could indeed add client-side caching here. Especially since the name useCachedAsyncData
basically implies that things will be cached. I will give this a try.
Alright, I gave this a shot and added client-side caching. I've used the example from @bgondy as a basis, but changed the behaviour:
- It also stores and gets subsequent client-side handler results in
nuxtApp.static.data
- Behaviour during hydration is identical with Nuxt
- It determines the expire date for payload cached data based on "time of first hydration"
- I've renamed the options to
serverMaxAge
andserverCacheTags
- To opt-in for client-side caching, a
clientMaxAge
value has to be provided in the form of a positive integer
I first wanted to have a single maxAge
option for both client and server side. But the problem is that, when a method is provided, it can only receive the full (untransformed) data during SSR. On the client that full untransformed result is obviously not available anymore. The argument would have to be optional, but that's a bit annoying.
Let me know if this makes sense. And a big thanks to all for all the constructive feedback and ideas!
It also stores and gets subsequent client-side handler results in
nuxtApp.static.data
This is so cool !
LGTM
Now available in 3.3.0