@equinor/fusion-query
Version:
Reactive data fetching and caching library with observable streams and comprehensive event system
305 lines (226 loc) • 10.4 kB
Markdown
# @equinor/fusion-query
Reactive data fetching and caching library built on RxJS Observables with queue strategies, automatic retry, and a comprehensive event system.
## When to Use
Use `@equinor/fusion-query` when you need to:
- Fetch remote data with built-in caching that prevents redundant network requests
- Manage concurrent requests with configurable queue strategies (`switch`, `merge`, `concat`)
- Automatically retry failed requests with customizable delay and count
- React to query lifecycle events for logging, debugging, or telemetry
- Share cached data across multiple consumers through a shared `QueryCache`
- Perform optimistic updates and cache mutations
## Installation
```bash
pnpm add @equinor/fusion-query
```
## Quick Start
```typescript
import { Query } from '@equinor/fusion-query';
interface User {
id: string;
name: string;
}
const userQuery = new Query<User, { id: string }>({
client: {
fn: async (args, signal) => {
const res = await fetch(`/api/users/${args.id}`, { signal });
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
},
},
key: (args) => args.id,
expire: 60_000, // cache valid for 60 seconds
});
// Observable usage
userQuery.query({ id: '123' }).subscribe({
next: (result) => console.log(result.value),
error: (err) => console.error(err),
});
// Async/await usage
const result = await userQuery.queryAsync({ id: '123' });
console.log(result.value);
```
## Core Concepts
### Query
The `Query` class is the main entry point. It coordinates a `QueryClient` (handles fetching), a `QueryCache` (stores results), and a queue operator (controls concurrency). When `query()` is called, it first checks the cache; if valid data exists, it emits immediately. Otherwise, a new fetch is queued.
### QueryClient
The `QueryClient` manages the execution of fetch functions, including dispatching requests, handling retries on failure, and emitting lifecycle events. It can be shared across multiple `Query` instances.
### QueryCache
The `QueryCache` stores fetched results keyed by a generated cache key. It supports insertions, mutations, invalidation, trimming, and resets. Multiple `Query` instances can share a single cache for cross-component data sharing.
### QueryTask
A `QueryTask` represents a single in-flight fetch operation. Tasks are deduplicated by cache key: if two consumers request the same data simultaneously, they share the same task.
## Configuration Options
The `QueryCtorOptions` type defines all configuration for a `Query` instance:
| Option | Type | Description |
|---|---|---|
| `client` | `QueryClient \| { fn, options? }` | The fetch function or an existing `QueryClient` instance |
| `key` | `(args) => string` | Generates a unique cache key from the query arguments |
| `expire` | `number` | Milliseconds before a cache entry is considered stale |
| `validate` | `(entry, args) => boolean` | Custom function to validate cache entries |
| `cache` | `QueryCache \| QueryCacheCtorArgs` | Shared cache instance or constructor args |
| `queueOperator` | `'switch' \| 'merge' \| 'concat' \| fn` | Strategy for handling concurrent requests |
## Queue Strategies
Queue strategies determine how multiple concurrent requests for different data are handled:
- **`switch`** (default): Cancels the active request when a new one arrives. Best for search-as-you-type where only the latest result matters.
- **`merge`**: Runs all requests in parallel. Best when every request must resolve independently.
- **`concat`**: Queues requests sequentially. Best when execution order matters.
```typescript
const query = new Query<SearchResult, { term: string }>({
client: { fn: searchApi },
key: (args) => args.term,
queueOperator: 'switch', // cancel stale searches
});
```
## Cache Management
### Mutation (Optimistic Update)
Update cached data directly without refetching. The `mutate` method returns an undo function that restores the previous value:
```typescript
const undo = userQuery.mutate(
{ id: '123' },
(current) => ({ value: { ...current!, name: 'Updated Name' }, updated: Date.now() }),
);
// Roll back if the server update fails
try {
await updateUserOnServer('123', { name: 'Updated Name' });
} catch {
undo();
}
```
### Invalidation
Mark specific entries or the entire cache as stale, forcing a refetch on the next query:
```typescript
userQuery.invalidate({ id: '123' }); // invalidate one entry
userQuery.invalidate(); // invalidate all entries
```
### Custom Cache Validation
Override the default expiration-based validation with custom logic:
```typescript
const query = new Query<Data, Args>({
client: { fn: fetchData },
key: (args) => JSON.stringify(args),
validate: (entry, args) => {
// Consider invalid if older than 30 minutes
return (entry.updated ?? 0) + 30 * 60_000 > Date.now();
},
});
```
### Shared Cache
Multiple `Query` instances can share a `QueryCache` to avoid duplicate fetches across components:
```typescript
import { Query } from '@equinor/fusion-query';
import { QueryCache } from '@equinor/fusion-query/cache';
const sharedCache = new QueryCache();
const homepageQuery = new Query({
client: { fn: fetchPosts },
cache: sharedCache,
key: () => 'posts',
});
const sidebarQuery = new Query({
client: { fn: fetchPosts },
cache: sharedCache,
key: () => 'posts', // same key — reuses cached data
});
```
## Persistent Query
Use `persistentQuery` when the UI should reflect cache mutations in real time. Unlike `query()`, which completes after emitting the result, `persistentQuery()` keeps the subscription open and re-emits whenever the underlying cache entry is updated:
```typescript
userQuery.persistentQuery({ id: '123' }).subscribe((result) => {
renderUser(result.value); // re-renders on cache mutation
});
```
## Event System
The `Query.event$` Observable aggregates events from three sources — the Query itself, the QueryClient, and the QueryCache — into a single stream for monitoring and debugging.
### Query Events
| Event | Description |
|---|---|
| `query_created` | A new query was initiated |
| `query_completed` | A query finished (from cache or fetch) |
| `query_connected` | Subscriber joined an existing in-flight task |
| `query_queued` | A task was added to the processing queue |
| `query_aborted` | A query was cancelled via `AbortSignal` |
| `query_cache_hit` | Valid cached data was found |
| `query_cache_miss` | No valid cache; fetch is required |
| `query_cache_added` | Fetched data was written to cache |
| `query_job_created` | A new fetch job was created |
| `query_job_selected` | A job was picked by the queue operator |
| `query_job_started` | A job began executing |
| `query_job_closed` | A job subscription was closed |
| `query_job_completed` | A job finished and its results were cached |
| `query_job_skipped` | A job was skipped (no longer observed) |
### QueryClient Events
| Event | Description |
|---|---|
| `query_client_job_requested` | A fetch was requested with arguments |
| `query_client_job_executing` | Fetch execution started |
| `query_client_job_completed` | Fetch completed successfully |
| `query_client_job_failed` | Fetch execution failed |
| `query_client_job_canceled` | Fetch was canceled |
| `query_client_job_error` | An irrecoverable error occurred |
### QueryCache Events
| Event | Description |
|---|---|
| `query_cache_entry_set` | A cache entry was set |
| `query_cache_entry_inserted` | A new entry was inserted |
| `query_cache_entry_removed` | An entry was removed |
| `query_cache_entry_invalidated` | An entry was invalidated |
| `query_cache_entry_mutated` | An entry was mutated |
| `query_cache_trimmed` | The cache was trimmed |
| `query_cache_reset` | The cache was reset |
### Subscribing to Events
```typescript
import { filter } from 'rxjs';
import { QueryEvent } from '@equinor/fusion-query';
import { QueryClientEvent } from '@equinor/fusion-query/client';
import { QueryCacheEvent } from '@equinor/fusion-query/cache';
// All events
query.event$.subscribe((event) => console.log(event.type, event));
// Filter by event type
query.event$.pipe(
filter((event) => event.type === 'query_cache_hit'),
).subscribe((event) => console.log('Cache hit:', event.data));
// Filter by event source using instanceof
const cacheEvents$ = query.event$.pipe(filter((e) => e instanceof QueryCacheEvent));
const clientEvents$ = query.event$.pipe(filter((e) => e instanceof QueryClientEvent));
const queryEvents$ = query.event$.pipe(filter((e) => e instanceof QueryEvent));
```
## React Integration
The `@equinor/fusion-query/react` sub-path exports the `useDebounceQuery` hook for debounced data fetching in React components:
```tsx
import { useDebounceQuery } from '@equinor/fusion-query/react';
function SearchComponent() {
const [term, setTerm] = useState('');
const result = useDebounceQuery(myQuery, {
args: [{ search: term }],
delay: 300,
});
return <input value={term} onChange={(e) => setTerm(e.target.value)} />;
}
```
## Operators
The `operators` namespace provides RxJS operator utilities:
- **`switchQueue`**: Cancels in-progress work when new items arrive (default strategy).
- **`mergeQueue`**: Processes items concurrently without cancellation.
- **`concatQueue`**: Processes items sequentially in FIFO order.
- **`queryValue`**: Extracts the raw `value` from a query result stream, stripping metadata.
```typescript
import { operators } from '@equinor/fusion-query';
query.query({ id: '123' }).pipe(operators.queryValue).subscribe((user) => {
console.log(user.name); // plain User object, no wrapper
});
```
## Cleanup
Always unsubscribe from query Observables or call `complete()` to release resources:
```typescript
const sub = query.query(args).subscribe((r) => console.log(r));
// Later:
sub.unsubscribe();
// Or dispose the entire Query instance:
query.complete();
```
## Sub-path Exports
| Path | Contents |
|---|---|
| `@equinor/fusion-query` | `Query`, types, operators, events |
| `@equinor/fusion-query/cache` | `QueryCache`, `QueryCacheEvent`, cache types |
| `@equinor/fusion-query/client` | `QueryClient`, `QueryClientError`, `QueryClientEvent`, client types |
| `@equinor/fusion-query/operators` | Queue operators and `queryValue` |
| `@equinor/fusion-query/react` | `useDebounceQuery` hook |