9.1 KiB
BatchingResource - Reusable Batching Pattern
Overview
BatchingResource is a generic base class for creating smart batching resources that optimize multiple API requests by collecting params from multiple components and making a single batched request.
Location
libs/common/data-access/src/lib/resources/batching-resource.base.ts
Features
- ✅ Automatic request batching with configurable window
- ✅ Smart caching (including empty results to prevent infinite loops)
- ✅ Reference counting for automatic cleanup
- ✅ Per-item loading/error status tracking
- ✅ Custom cache keys support
- ✅ TypeScript generics for type safety
How to Use
1. Create a Resource Class
Extend BatchingResource and implement the required abstract methods:
import { Injectable, inject } from '@angular/core';
import { BatchingResource } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class MyDataResource extends BatchingResource<
number, // TParams: What users pass to resource()
MyApiParams, // TResourceParams: The type of batch API parameters
MyDataType // TResourceValue: The type of results
> {
#myService = inject(MyService);
constructor() {
super(100); // batchWindowMs (optional, defaults to 100ms)
}
// Required: Fetch data from your API
protected fetchFn(params: MyApiParams, signal: AbortSignal) {
return this.#myService.fetchData(params, signal);
}
// Required: Convert params list to API request format
protected buildParams(ids: number[]): MyApiParams {
return { ids };
}
// Required: Extract params from result for cache matching
protected getKeyFromResult(result: MyDataType): number | undefined {
return result.id;
}
// Required: Generate unique cache key from params
protected getCacheKey(id: number): string {
return String(id);
}
// Required: Extract params from resource params for status tracking
protected extractKeysFromParams(params: MyApiParams): number[] {
return params.ids;
}
}
2. Use in Components
import { Component, computed, inject, input } from '@angular/core';
@Component({
selector: 'my-component',
template: `
@if (data.isLoading()) {
<div>Loading...</div>
} @else if (data.error()) {
<div>Error: {{ data.error() }}</div>
} @else {
<div>Data: {{ data.value()?.name }}</div>
}
`,
})
export class MyComponent {
#myDataResource = inject(MyDataResource);
itemId = input.required<number>();
// Automatically batches with other components
data = this.#myDataResource.resource(this.itemId);
// Use computed for derived values
name = computed(() => this.data.value()?.name ?? 'Unknown');
}
API Reference
Constructor
constructor(batchWindowMs?: number) // default: 100
Abstract Methods (Must Implement)
// Fetch data from your API
protected abstract fetchFn(
params: TResourceParams,
abortSignal: AbortSignal
): Promise<TResourceValue[]>;
// Convert params list to API request format
protected abstract buildParams(params: TParams[]): TResourceParams;
// Extract params from result for cache matching
protected abstract getKeyFromResult(result: TResourceValue): TParams | undefined;
// Generate unique cache key from params
protected abstract getCacheKey(params: TParams): string;
// Extract params from resource params for status tracking
protected abstract extractKeysFromParams(params: TResourceParams): TParams[];
Methods
resource(params)
Returns a BatchingResourceRef for specific params.
Parameters:
params: Signal<TParams> | TParams- The params to fetch (can be a signal or static value)
Returns: BatchingResourceRef<TResourceValue>
reload()
Reloads all currently tracked items by clearing cache.
reloadKeys(params: TParams[])
Reloads specific items by clearing their cache entries.
BatchingResourceRef
The object returned by resource():
{
value: Signal<TResourceValue | undefined>; // The cached result
hasValue: Signal<boolean>; // True if cached (even if empty)
isLoading: Signal<boolean>; // True while loading
error: Signal<unknown>; // Error if failed
status: Signal<ResourceStatus>; // 'idle' | 'loading' | 'resolved' | 'error'
reload: () => void; // Reload this specific item
}
Advanced: Custom Cache Keys
For complex scenarios (like stock info with different warehouse IDs), encode all context in TParams:
// Define TParams as an object containing all necessary context
export class StockResource extends BatchingResource<
{ itemId: number; warehouseId: string }, // TParams includes all context
FetchStockParams, // TResourceParams
StockInfo // TResourceValue
> {
#stockService = inject(StockService);
constructor() {
super(250);
}
protected fetchFn(params: FetchStockParams, signal: AbortSignal) {
return this.#stockService.fetchStockInfos(params, signal);
}
protected buildParams(
paramsList: { itemId: number; warehouseId: string }[]
): FetchStockParams {
return {
itemIds: paramsList.map(p => p.itemId),
warehouseId: paramsList[0].warehouseId, // All should have same warehouse
};
}
protected getKeyFromResult(stock: StockInfo) {
return {
itemId: stock.itemId,
warehouseId: stock.warehouseId
};
}
protected getCacheKey(params: { itemId: number; warehouseId: string }): string {
return `${params.warehouseId}-${params.itemId}`;
}
protected extractKeysFromParams(params: FetchStockParams) {
return params.itemIds.map(itemId => ({
itemId,
warehouseId: params.warehouseId
}));
}
}
// Then use the complete params object
const stockInfo = stockResource.resource({ itemId: 123, warehouseId: 'WH1' });
How It Works
Batching Process
- Component mounts → Calls
resource(params) - Effect registers params → Adds to pending batch queue
- Batch window expires (default 100ms) → Pending params move to committed
- Resource triggers → Fetches data for all committed params
- Results cached → Both successful and empty results stored
- Component unmounts → Reference count decremented, cleanup if zero
Infinite Loop Prevention
The key innovation is caching empty results:
// If API returns no data for params, we still cache it with undefined
if (!hasResult) {
cache.set(cacheKey, undefined);
}
This prevents the resource from thinking the params are "uncached" and re-requesting them infinitely.
Example: Stock Info Resource (Real-World)
@Injectable({ providedIn: 'root' })
export class StockInfoResource extends BatchingResource<
number, // TParams - Item IDs
FetchStockParams, // TResourceParams - { itemIds: number[] }
StockInfo // TResourceValue - { itemId: number, inStock: number, ... }
> {
#stockService = inject(RemissionStockService);
constructor() {
super(250); // Wait longer for more batching
}
protected fetchFn(params: FetchStockParams, signal: AbortSignal) {
return this.#stockService.fetchStockInfos(params, signal);
}
protected buildParams(itemIds: number[]): FetchStockParams {
return { itemIds };
}
protected getKeyFromResult(stock: StockInfo): number | undefined {
return stock.itemId;
}
protected getCacheKey(itemId: number): string {
return String(itemId);
}
protected extractKeysFromParams(params: FetchStockParams): number[] {
return params.itemIds;
}
}
// Usage in component
const stockInfo = inject(StockInfoResource).resource(itemId);
const inStock = computed(() => stockInfo.value()?.inStock ?? 0);
Benefits Over Manual Implementation
✅ ~500 lines of complex batching logic → ~20 lines of config ✅ Automatic reference counting and cleanup ✅ Infinite loop prevention built-in ✅ Type-safe with TypeScript generics ✅ Reusable across any domain (stock, pricing, availability, etc.) ✅ Tested and battle-proven pattern
Migration Guide
If you have an existing custom resource, extract these parts:
- Params type (TParams): What do users pass to identify each item? (e.g.,
numberfor item IDs) - Resource params type (TResourceParams): What does your batch API accept? (e.g.,
{ itemIds: number[] }) - Result type (TResourceValue): What does your API return per item? (e.g.,
StockInfo) - Service injection: Your API service (e.g.,
inject(MyService)) - Fetch method: Your service method that calls the API
- Build params logic: How to convert params list → resource params
- Get params from result logic: How to extract the params from each result
- Cache key logic: How to generate a unique string key from params
- Extract params logic: How to get params list back from resource params (for status tracking)
Then implement these as abstract methods in your BatchingResource subclass!