features_use-cached-resource.ts
import { inject, signal, computed, Signal, effect, DestroyRef, untracked } from '@angular/core';
import { CachedResource, CachedResourceOptions } from '../core/types';
import { NgxCachrService } from '../services/ngx-cachr.service';
import { serializeKey } from '../utils/key-serializer';
/**
* The primary resource for fetching and caching data.
* returns a reactive {@link CachedResource} object containing signals for data, status, and error.
*
* Supports reactive dependencies by passing a function that returns the options.
* When dependencies change (any signal accessed within the function), the resource automatically re-fetches.
*
* @template T The type of the data being fetched.
* @param {CachedResourceOptions<T> | Function} optionsOrFn - Configuration object or a function returning one.
* @returns {CachedResource<T>} A reactive object with `data`, `status`, and `error` signals.
*
* @example
* // Simple usage
* const user = cachedResource({
* key: 'user:1',
* loader: () => fetch('/api/user/1').then(r => r.json()),
* ttl: 60000 // 1 minute
* });
*
* // Template usage
* // <div>{{ user.data()?.name }}</div>
*
* @example
* // Reactive usage (dependent query)
* const userId = signal(1);
*
* const user = cachedResource(() => ({
* key: ['user', userId()], // Updates when userId changes
* loader: () => fetch(`/api/user/${userId()}`).then(r => r.json())
* }));
*
* // Trigger re-fetch
* userId.set(2);
*/
export function cachedResource<T>(
optionsOrFn: CachedResourceOptions<T> | (() => CachedResourceOptions<T>)
): CachedResource<T> {
const service = inject(NgxCachrService);
const destroyRef = inject(DestroyRef);
const data = signal<T | undefined>(undefined);
const status = signal<'idle' | 'loading' | 'revalidating' | 'error' | 'success'>('idle');
const error = signal<unknown>(null);
// Track the active key to prevent race conditions (stale responses overwriting new ones)
let activeKey: string | null = null;
const resolve = async (opts: CachedResourceOptions<T>) => {
const key = serializeKey(opts.key);
activeKey = key;
try {
const result = await service.get<T>(opts, (state) => {
if (activeKey !== key) return; // Ignore updates from stale requests
if (state.status) status.set(state.status);
if (state.data !== undefined) data.set(state.data);
if (state.error !== undefined) error.set(state.error);
});
// If the service returned data immediately (e.g. cache hit), set it
if (activeKey === key && result !== undefined) {
data.set(result);
// If we are revalidating (SWR), the callback above handles the status
// If we just got data from cache and it's fresh, we are success
// But status() might be 'revalidating' if SWR kicked in synchronously (unlikely with promise, but possible logic)
// Actually service.get returns data, and if SWR, it ALSO calls updateState('revalidating').
// We rely on updateState for status changes mostly.
// But if it returns data and didn't call updateState (e.g. fresh cache hit), we set success.
if (status() === 'idle' || status() === 'loading') {
status.set('success');
}
}
} catch (err) {
if (activeKey === key) {
error.set(err);
status.set('error');
}
}
};
// Handle reactivity if a function is passed
if (typeof optionsOrFn === 'function') {
effect(() => {
const opts = optionsOrFn();
untracked(() => resolve(opts));
});
} else {
resolve(optionsOrFn);
}
const mutate = (newData: T) => {
data.set(newData);
const opts = typeof optionsOrFn === 'function' ? optionsOrFn() : optionsOrFn;
service.set(opts.key, newData, opts.ttl);
};
const invalidate = () => {
const opts = typeof optionsOrFn === 'function' ? optionsOrFn() : optionsOrFn;
service.invalidate(opts.key).then(() => {
// Only refetch if the key hasn't changed in the meantime
const currentKey = serializeKey(opts.key);
if (activeKey === currentKey) {
resolve(opts);
}
});
};
return {
data: data.asReadonly(),
status: status.asReadonly(),
error: error.asReadonly(),
mutate,
invalidate
};
}