Framework-agnostic stale-while-revalidate (SWR) data fetching library
A powerful yet lightweight caching library for JavaScript/TypeScript. Works seamlessly with React, Vue, Svelte, or vanilla JS projects.
- Stale-While-Revalidate: Return cached data instantly, then refresh in the background
- Smart caching with TTL, LRU eviction, and configurable cache size limits
- Auto re-render when cache updates (via pub/sub pattern)
- Request deduplication: Prevent duplicate network requests automatically
- ️ Advanced error handling with exponential backoff retry strategy
- ️ Optimistic updates: Manual cache mutation with optional revalidation
- Auto revalidation on window focus and network reconnection (configurable)
- Polling / interval revalidation: Automatic data refresh at specified intervals
- Memory management: Automatic cleanup and cache size limits
- Cache introspection: Built-in cache management and debugging APIs
- ️ TypeScript-first: Full type safety with zero
anytypes - Framework-agnostic: Use directly or wrap in React/Vue/Svelte hooks
- Lightweight: ~1.8KB gzipped (~6KB raw), zero dependencies
npm install revaliyarn add revalipnpm add revaliimport { revaliFetch, subscribe, mutate } from 'revali';
// Fetch data with caching and SWR behavior
const userData = await revaliFetch(
'user/1',
() => fetch('https://api.example.com/users/1').then((r) => r.json()),
{
ttl: 5 * 60 * 1000, // 5 minutes cache
retries: 3, // Retry failed requests 3 times
revalidateOnFocus: true, // Refresh when window regains focus
refreshInterval: 30 * 1000, // Poll every 30 seconds
},
);
// Subscribe to cache updates (with error handling)
const unsubscribe = subscribe('user/1', (data, error) => {
if (error) {
console.error('Fetch error:', error);
} else {
console.log('Updated data:', data);
}
});
// Optimistic updates with automatic revalidation
mutate(
'user/1',
(prev) => ({
...prev,
name: 'Updated Name',
}),
true,
); // Will revalidate after mutation
// Cache management
import { clearCache, getCacheInfo, cleanup } from 'revali';
clearCache('user/1'); // Clear specific cache
clearCache(); // Clear all cache
console.log(getCacheInfo()); // { size: 5, keys: ["user/1", ...] }
// Manual revalidation control
import { triggerRevalidation } from 'revali';
triggerRevalidation(); // Manually trigger revalidation for all eligible entries
// Clean up everything
cleanup(); // Clear all cache and remove all subscribers
unsubscribe(); // Or just unsubscribe from specific keyimport { useState, useEffect, useCallback } from 'react';
import { revaliFetch, subscribe, mutate, type RevaliOptions } from 'revali';
interface UseRevaliResult<T> {
data: T | undefined;
error: Error | undefined;
isLoading: boolean;
isValidating: boolean;
mutate: (data: T | ((prev: T | undefined) => T), shouldRevalidate?: boolean) => T;
}
export function useRevali<T>(
key: string,
fetcher: () => Promise<T>,
options?: RevaliOptions,
): UseRevaliResult<T> {
const [data, setData] = useState<T | undefined>();
const [error, setError] = useState<Error | undefined>();
const [isLoading, setIsLoading] = useState(true);
const [isValidating, setIsValidating] = useState(false);
useEffect(() => {
let mounted = true;
const loadData = async () => {
if (!mounted) return;
setIsLoading(true);
setIsValidating(true);
try {
const result = await revaliFetch(key, fetcher, options);
if (mounted) {
setData(result);
setError(undefined);
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (mounted) {
setIsLoading(false);
setIsValidating(false);
}
}
};
loadData();
// Subscribe to cache updates
const unsubscribe = subscribe(key, (newData, newError) => {
if (!mounted) return;
setData(newData);
setError(newError);
setIsValidating(false);
});
return () => {
mounted = false;
unsubscribe();
};
}, [key]);
const mutateFn = useCallback(
(data: T | ((prev: T | undefined) => T), shouldRevalidate = true) => {
return mutate(key, data, shouldRevalidate);
},
[key],
);
return { data, error, isLoading, isValidating, mutate: mutateFn };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading, mutate } = useRevali(
`user/${userId}`,
() => fetch(`/api/users/${userId}`).then((r) => r.json()),
{ ttl: 5 * 60 * 1000 },
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data?.name}</h1>
<button onClick={() => mutate((prev) => ({ ...prev, name: 'New Name' }))}>Update Name</button>
</div>
);
}<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { revaliFetch, subscribe, type RevaliOptions } from 'revali';
interface UseRevaliResult<T> {
data: Ref<T | undefined>;
error: Ref<Error | undefined>;
isLoading: Ref<boolean>;
}
function useRevali<T>(
key: string,
fetcher: () => Promise<T>,
options?: RevaliOptions,
): UseRevaliResult<T> {
const data = ref<T>();
const error = ref<Error>();
const isLoading = ref(true);
let unsubscribe: (() => void) | null = null;
const load = async () => {
isLoading.value = true;
try {
data.value = await revaliFetch(key, fetcher, options);
error.value = undefined;
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err));
} finally {
isLoading.value = false;
}
};
onMounted(async () => {
await load();
unsubscribe = subscribe(key, (newData, newError) => {
data.value = newData;
error.value = newError;
});
});
onUnmounted(() => {
unsubscribe?.();
});
return { data, error, isLoading };
}
// Usage
const {
data: user,
error,
isLoading,
} = useRevali('user/1', () => fetch('/api/user/1').then((r) => r.json()));
</script>The main function for fetching and caching data.
const data = await revaliFetch('posts/1', () => fetch('/api/posts/1').then((r) => r.json()), {
ttl: 5 * 60 * 1000, // Cache for 5 minutes
retries: 3, // Retry 3 times on failure
retryDelay: 1000, // Initial retry delay
maxCacheSize: 50, // Max cache entries
revalidateOnFocus: true, // Revalidate on window focus
revalidateOnReconnect: true, // Revalidate on network reconnect
});Subscribe to cache updates for a specific key.
const unsubscribe = subscribe('user/1', (data, error) => {
if (error) console.error('Error:', error);
else console.log('Data updated:', data);
});Manually update cache and optionally trigger revalidation.
// Update with new data
mutate('user/1', { id: 1, name: 'John Doe' });
// Update with function
mutate('user/1', (prev) => ({ ...prev, name: 'Jane Doe' }));
// Update without revalidation
mutate('user/1', newData, false);Clear cache entries.
clearCache('user/1'); // Clear specific key
clearCache(); // Clear all cacheGet information about the current cache state.
const { size, keys } = getCacheInfo();
console.log(`Cache has ${size} entries:`, keys);Clean up all cache entries and remove all subscribers. Useful for complete cleanup.
import { cleanup } from 'revali';
// Clear everything - cache and subscribers
cleanup();Manually trigger revalidation for all eligible cache entries.
import { triggerRevalidation } from 'revali';
// Manually revalidate all entries that have revalidateOnFocus or revalidateOnReconnect enabled
triggerRevalidation();Manually initialize automatic revalidation listeners. This is called automatically when importing Revali, but can be called manually if needed.
import { initAutoRevalidation } from 'revali';
// Manually set up window focus and network reconnect listeners
// (This is done automatically when importing Revali)
initAutoRevalidation();Get information about active polling tasks.
import { getPollingInfo } from 'revali';
const pollingInfo = getPollingInfo();
console.log(`Active polling tasks: ${pollingInfo.activeCount}`);
console.log('Polling keys:', pollingInfo.keys);Check if a specific key has active polling.
import { hasActivePolling } from 'revali';
if (hasActivePolling('user/1')) {
console.log('User data is being polled');
}Clean up all active polling tasks. Useful for cleanup when your application is shutting down.
import { cleanupPolling } from 'revali';
// Stop all polling tasks
cleanupPolling();export type Fetcher<T> = () => Promise<T>;
export type Subscriber<T> = (data: T | undefined, error?: Error) => void;
export interface RevaliOptions {
retries?: number; // Max retry attempts (default: 2)
retryDelay?: number; // Initial retry delay in ms (default: 300)
ttl?: number; // Cache TTL in ms (default: 300000 = 5min)
maxCacheSize?: number; // Max cache entries (default: 100)
revalidateOnFocus?: boolean; // Revalidate on focus (default: true)
revalidateOnReconnect?: boolean; // Revalidate on reconnect (default: true)
refreshInterval?: number; // Polling interval in ms, 0 means no polling (default: 0)
refreshWhenHidden?: boolean; // Continue polling when page is hidden (default: false)
refreshWhenOffline?: boolean; // Continue polling when offline (default: false)
dedupingInterval?: number; // Deduping interval in ms (default: 2000)
}
export interface CacheEntry<T> {
data: T | undefined;
timestamp: number;
error?: Error;
fetcher: Fetcher<T>;
options: RevaliOptions;
}
export interface RevaliState<T> {
data?: T;
error?: Error;
isLoading: boolean;
isValidating: boolean;
}
// Default configuration constant
export const DEFAULT_OPTIONS: Required<RevaliOptions>;| Feature | SWR | TanStack Query | Revali 🚀 |
|---|---|---|---|
| Bundle Size | ~6KB | ~13KB | ~1.8KB |
| Framework Support | React | Multi | Any |
| TypeScript-first | ✅ | ✅ | ✅ |
| Request Deduplication | ✅ | ✅ | ✅ |
| Cache with TTL | ✅ | ✅ | ✅ |
| Error Retry + Backoff | ✅ | ✅ | ✅ |
| Optimistic Updates | ✅ | ✅ | ✅ |
| Background Revalidation | ✅ | ✅ | ✅ |
| Focus/Reconnect Revalidate | ✅ | ✅ | ✅ |
| Memory Management | Limited | ✅ | ✅ |
| Cache Introspection | Limited | ✅ | ✅ |
| Zero Dependencies | ❌ | ❌ | ✅ |
- ✅ Basic cache & subscription system
- ✅ Request deduplication
- ✅ Error retry with exponential backoff
- ✅ Revalidate on focus & network reconnect
- ✅ Manual mutate / optimistic updates
- ✅ TTL-based cache expiration
- ✅ LRU cache eviction
- ✅ Memory management & cleanup
- ✅ Full TypeScript support
- ✅ Cache introspection APIs
- ✅ Polling / interval revalidation
- ✅ Request cancellation (AbortController)
- Middleware system
- Built-in React/Vue/Svelte hooks
- Pagination & infinite loading
- Offline support with persistence
- DevTools browser extension
- GraphQL integration
- SSR/SSG support
Revali automatically sets up revalidation listeners when imported:
import { revaliFetch } from 'revali';
// Automatically listens for:
// - Window focus events (revalidates when tab becomes active)
// - Network online events (revalidates when connection restored)You can control revalidation behavior manually:
import { triggerRevalidation, initAutoRevalidation, DEFAULT_OPTIONS } from 'revali';
// Use default options as base for custom configuration
const customOptions = {
...DEFAULT_OPTIONS,
ttl: 10 * 60 * 1000, // Override TTL to 10 minutes
retries: 5, // Override retries to 5
};
// Manually trigger revalidation
triggerRevalidation();
// Access default configuration
console.log('Default TTL:', DEFAULT_OPTIONS.ttl);Configure automatic data refresh at specified intervals:
import { revaliFetch, getPollingInfo, hasActivePolling } from 'revali';
// Basic polling - refresh every 30 seconds
const liveStats = await revaliFetch(
'live-stats',
() => fetch('/api/stats').then(r => r.json()),
{
refreshInterval: 30 * 1000, // 30 seconds
ttl: 5 * 60 * 1000, // 5 minutes cache
}
);
// Advanced polling configuration
const criticalData = await revaliFetch(
'critical-data',
fetchCriticalData,
{
refreshInterval: 5 * 1000, // Poll every 5 seconds
refreshWhenHidden: true, // Continue when tab is not active
refreshWhenOffline: false, // Pause when offline
dedupingInterval: 2000, // Prevent requests closer than 2s
ttl: 10 * 1000, // Short cache TTL for fresh data
}
);
// Check polling status
console.log('Polling info:', getPollingInfo());
console.log('Is polling active:', hasActivePolling('live-stats'));
// Polling automatically stops when cache is cleared
clearCache('live-stats'); // Stops polling for this key- Choose appropriate intervals: Balance freshness needs with server load
- Use
refreshWhenHidden: falsefor non-critical data to save resources - Configure
dedupingIntervalto prevent excessive requests - Monitor polling with
getPollingInfo()for debugging
Revali supports request cancellation using the standard AbortController API, providing fine-grained control over request lifecycle:
import { revaliFetch, cancel } from 'revali';
// Cancel a specific request
const key = 'user-data';
const promise = revaliFetch(key, async (signal) => {
const response = await fetch('/api/user', { signal });
return response.json();
});
// Cancel the request
cancel(key);
// The promise will reject with CancellationError
try {
await promise;
} catch (error) {
if (error.name === 'CancellationError') {
console.log('Request was cancelled');
}
}import { revaliFetch } from 'revali';
// Use your own AbortController
const controller = new AbortController();
const promise = revaliFetch('data', async (signal) => {
const response = await fetch('/api/data', { signal });
return response.json();
}, {
signal: controller.signal
});
// Cancel using your controller
controller.abort();import { revaliFetch } from 'revali';
// Automatically cancel after timeout
const data = await revaliFetch('slow-api', async (signal) => {
const response = await fetch('/api/slow', { signal });
return response.json();
}, {
abortTimeout: 5000 // Cancel after 5 seconds
});import { revaliFetch } from 'revali';
// Cancel previous request when starting new one
const searchResults = await revaliFetch(`search-${query}`, async (signal) => {
const response = await fetch(`/api/search?q=${query}`, { signal });
return response.json();
}, {
abortOnRevalidate: true // Cancel previous search when new search starts
});import {
cancel,
cancelAll,
isCancelled,
getCancellationInfo,
isCancellationError
} from 'revali';
// Cancel specific request
const cancelled = cancel('request-key');
console.log('Cancelled:', cancelled);
// Cancel all active requests
const cancelledCount = cancelAll();
console.log('Cancelled count:', cancelledCount);
// Check if request was cancelled
const wasCancelled = isCancelled('request-key');
// Get cancellation information
const info = getCancellationInfo();
console.log('Active requests:', info.activeCount);
console.log('Active keys:', info.keys);
// Check if error is cancellation error
try {
await someRequest();
} catch (error) {
if (isCancellationError(error)) {
console.log('Request was cancelled');
}
}-
Always handle AbortSignal in your fetchers:
const fetcher = async (signal?: AbortSignal) => { const response = await fetch('/api/data', { signal }); if (!response.ok) throw new Error('Failed'); return response.json(); };
-
Use
abortOnRevalidatefor search/filter scenarios:const searchData = await revaliFetch(`search-${query}`, fetcher, { abortOnRevalidate: true // Cancel previous search });
-
Set reasonable timeouts for slow operations:
const heavyComputation = await revaliFetch('compute', fetcher, { abortTimeout: 30000 // 30 second timeout });
-
Clean up on component unmount (React example):
useEffect(() => { const controller = new AbortController(); revaliFetch('component-data', fetcher, { signal: controller.signal }); return () => controller.abort(); // Clean up on unmount }, []);
Thanks to the modular architecture, bundlers can tree-shake unused code:
// Only imports the functions you use
import { revaliFetch, mutate } from 'revali';
// ✅ Other modules (cleanup, revalidation) won't be included in bundleUse consistent, descriptive cache keys:
// ✅ Good - consistent and descriptive
const userId = 123;
const userData = await revaliFetch(`user/${userId}`, fetchUser);
const userPosts = await revaliFetch(`user/${userId}/posts`, fetchUserPosts);
// ❌ Avoid - inconsistent or too generic
const userData = await revaliFetch(`user-${userId}`, fetchUser);
const userPosts = await revaliFetch(`posts`, fetchUserPosts);Set TTL based on data freshness requirements:
// Fast-changing data - shorter TTL
const liveData = await revaliFetch('live-stats', fetchStats, {
ttl: 30 * 1000, // 30 seconds
});
// Relatively stable data - longer TTL
const userData = await revaliFetch('user/profile', fetchProfile, {
ttl: 5 * 60 * 1000, // 5 minutes
});
// Static data - very long TTL
const appConfig = await revaliFetch('app/config', fetchConfig, {
ttl: 60 * 60 * 1000, // 1 hour
});Configure appropriate cache limits:
const options = {
maxCacheSize: 50, // Limit cache entries for memory efficiency
ttl: 5 * 60 * 1000,
};Always cleanup subscriptions:
useEffect(() => {
const unsubscribe = subscribe(key, handleUpdate);
return () => {
unsubscribe(); // Prevent memory leaks
};
}, [key]);Revali (from "revalidate") was created to understand how modern data fetching libraries work under the hood, while providing a framework-agnostic solution that's both powerful and lightweight.
- Framework Agnostic: Works with any UI library or vanilla JavaScript
- TypeScript First: Built with type safety as a priority
- Zero Dependencies: No external dependencies, minimal bundle size
- Memory Conscious: Smart caching with automatic cleanup
- Developer Experience: Simple API with powerful features
- Modular Architecture: Clean separation of concerns for better maintainability
We welcome contributions! Here's how you can help:
- Report bugs via GitHub Issues
- Suggest features or improvements
- Improve documentation and examples
- Add tests for edge cases
- Submit PRs for bug fixes or features
git clone https://github.com/cerebralatlas/revali.git
cd revali
pnpm install
# Development
pnpm run dev
# Build
pnpm run build
# Test
pnpm run test
pnpm run test:coverage # Run tests with coverage report
pnpm run test:ui # Run tests with UI
# Type checking
pnpm run type-check
# Linting
pnpm run lint
pnpm run lint:fix- 98% Test Coverage: Comprehensive test suite covering all core functionality
- Modular Architecture: Clean separation of concerns for maintainability
- Zero Dependencies: No external runtime dependencies
| Format | Raw Size | Gzipped | Minified + Gzipped |
|---|---|---|---|
| ESM | ~6.2KB | ~1.8KB | ~1.8KB |
| CJS | ~7.6KB | ~2.2KB | ~2.2KB |
Actual network transfer size is the gzipped size, making Revali one of the smallest SWR libraries available.
MIT © Cerebral Atlas
Star this repo if you find it useful!