Files
ISA-Frontend/libs/common/data-access/README-BATCHING-RESOURCE.md

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

  1. Component mounts → Calls resource(params)
  2. Effect registers params → Adds to pending batch queue
  3. Batch window expires (default 100ms) → Pending params move to committed
  4. Resource triggers → Fetches data for all committed params
  5. Results cached → Both successful and empty results stored
  6. 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:

  1. Params type (TParams): What do users pass to identify each item? (e.g., number for item IDs)
  2. Resource params type (TResourceParams): What does your batch API accept? (e.g., { itemIds: number[] })
  3. Result type (TResourceValue): What does your API return per item? (e.g., StockInfo)
  4. Service injection: Your API service (e.g., inject(MyService))
  5. Fetch method: Your service method that calls the API
  6. Build params logic: How to convert params list → resource params
  7. Get params from result logic: How to extract the params from each result
  8. Cache key logic: How to generate a unique string key from params
  9. 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!