Skip to content

@bpmlib/utils-data-container

Vue 3 composable untuk mengelola paginated data dengan filtering, sorting, dan search capabilities

Versi: 0.1.0
Kategori: Framework Utils (Vue 3)

npm versionTypeScriptVue


TL;DR

Composable untuk manage state paginated data dengan support multiple pagination modes (length-aware, cursor-based, simple), advanced filtering dengan nested paths, sorting, dan search. Menyediakan separation between draft dan applied filters, auto-detection pagination mode dari API response, dan URL parameter generation untuk seamless integration dengan backend APIs.

Composables:

ts
import { useDataContainer } from '@bpmlib/utils-data-container';

Types:

ts
import type {
    DataContainerObject,
    PageConfig,
    SortConfig,
    SearchConfig,
    ErrorInfo,
    UrlParams,
    PaginationMode,
} from '@bpmlib/utils-data-container';

Installation & Setup

Requirements

Peer Dependencies

Library ini memerlukan peer dependencies berikut:

Wajib:

bash
npm install vue@^3.3.0
DependencyVersiStatusDeskripsi
vue^3.3.0RequiredVue 3 framework

Package Installation

bash
npm install @bpmlib/utils-data-container
bash
yarn add @bpmlib/utils-data-container
bash
pnpm add @bpmlib/utils-data-container
bash
bun install @bpmlib/utils-data-container

Import

Basic Import:

ts
import { useDataContainer } from '@bpmlib/utils-data-container';

With Types:

ts
import { useDataContainer } from '@bpmlib/utils-data-container';
import type { 
    DataContainerObject, 
    PageConfig, 
    PaginationMode 
} from '@bpmlib/utils-data-container';

Quick Start

Basic Usage

Contoh paling sederhana untuk manage paginated data:

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface User {
  id: number;
  name: string;
  email: string;
}

interface UserFilter {
  status?: string;
  role?: string;
}

const container = useDataContainer<User, UserFilter>({
  status: 'active',
  role: ''
});

async function fetchUsers() {
  container.loadStatus.value = 0; // Set loading
  
  const response = await fetch('/api/users?' + container.getterUrlStringAttribute());
  const data = await response.json();
  
  container.mapFromResponse(data);
}

fetchUsers();
</script>

<template>
  <div>
    <div v-if="container.isLoading.value">Loading...</div>
    <div v-else-if="container.hasError.value">{{ container.error.value?.message }}</div>
    <div v-else>
      <div v-for="user in container.data.value" :key="user.id">
        {{ user.name }} - {{ user.email }}
      </div>
    </div>
  </div>
</template>

Key Points:

  • useDataContainer mengelola reactive state untuk data, filter, pagination, sort, dan search
  • getterUrlStringAttribute() generate URL query string dari current state
  • mapFromResponse() auto-detect pagination mode dan map response ke container

Comprehensive Example

Full-featured example dengan filtering, pagination, dan sorting:

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

interface ProductFilter {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
}

const container = useDataContainer<Product, ProductFilter>({
  category: '',
  minPrice: 0,
  maxPrice: 0
});

async function fetchProducts() {
  container.loadStatus.value = 0;
  
  try {
    const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
    const data = await response.json();
    
    container.mapFromResponse(data);
  } catch (error) {
    container.setError('Failed to fetch products', 'FETCH_ERROR');
  }
}

function handleFilterChange() {
  if (container.hasUnappliedFilterChanges()) {
    container.applyFilter();
    fetchProducts();
  }
}

function handleSort(field: string) {
  const currentDir = container.sort.sortDir;
  container.setSort(field, currentDir === 'a' ? 'd' : 'a');
  fetchProducts();
}

function handlePageChange(page: number) {
  container.setPage({
    ...container.page,
    numeric: { ...container.page.numeric, current: page }
  });
  fetchProducts();
}

fetchProducts();
</script>

<template>
  <div>
    <!-- Filters -->
    <div class="filters">
      <input v-model="container.filter.category" @change="handleFilterChange" placeholder="Category">
      <input v-model.number="container.filter.minPrice" @change="handleFilterChange" placeholder="Min Price">
      <input v-model.number="container.filter.maxPrice" @change="handleFilterChange" placeholder="Max Price">
      <button @click="container.resetFilterToInit">Reset</button>
    </div>

    <!-- Search -->
    <input v-model="container.search" @change="fetchProducts" placeholder="Search products...">

    <!-- Sort -->
    <div class="sort">
      <button @click="handleSort('name')">Sort by Name</button>
      <button @click="handleSort('price')">Sort by Price</button>
    </div>

    <!-- Data -->
    <div v-if="container.isLoading.value">Loading...</div>
    <div v-else-if="container.hasError.value">{{ container.error.value?.message }}</div>
    <div v-else-if="container.isEmpty.value">No products found</div>
    <div v-else>
      <div v-for="product in container.data.value" :key="product.id">
        {{ product.name }} - ${{ product.price }} ({{ product.category }})
      </div>
    </div>

    <!-- Pagination -->
    <div v-if="container.page.paginationMode === 'lengthaware'" class="pagination">
      <button 
        v-for="page in container.page.numeric.max" 
        :key="page"
        @click="handlePageChange(page)"
        :disabled="page === container.page.numeric.current"
      >
        {{ page }}
      </button>
      <p>Total: {{ container.page.totalData }} items</p>
    </div>
  </div>
</template>

Core Concepts

Library ini didesain dengan beberapa konsep fundamental yang mempengaruhi cara kamu menggunakannya.

Lazy State Management & Mutable Filter Pattern

Filter state di-manage dalam dua layer: draft (filter) dan applied (_appliedFilter). System membedakan antara draft filter (user sedang input) dan applied filter (yang benar-benar digunakan untuk fetch).

How it works:

  • filter - Mutable reactive object yang bisa langsung di-bind ke form input
  • _appliedFilter - Readonly snapshot dari last applied filter
  • Perubahan pada filter tidak otomatis trigger fetch - kamu kontrol kapan apply
  • applyFilter() copy filter ke _appliedFilter yang digunakan untuk URL generation
  • hasUnappliedFilterChanges() check apakah ada pending changes
  • Pattern ini mencegah accidental fetch setiap user ketik di form

Example:

ts
// User input langsung update filter (tidak trigger fetch)
container.filter.status = 'active';
container.filter.role = 'admin';

// Check apakah ada perubahan yang belum di-apply
if (container.hasUnappliedFilterChanges()) {
  // Apply filter dan fetch
  container.applyFilter();
  fetchData();
}

// Reset ke applied state (discard draft)
container.resetFilterToApplied();

// Reset ke initial state
container.resetFilterToInit();

Use Case: Form dengan "Apply" button, mencegah accidental fetch, implement filter preview, atau dirty state tracking.

See: Filter Methods, Example 2


Pagination Mode Auto-Detection

Library auto-detect pagination mode berdasarkan response structure, tanpa perlu manual configuration.

How it works:

  • Length-Aware: Detected jika response punya max_page + total
  • Cursor: Detected jika response punya next_cursor atau previous_cursor
  • Simple: Detected jika response punya has_more
  • None: Default jika tidak ada pagination metadata

Detection Logic:

ts
// Backend response dengan max_page + total → Length-Aware
{
  content: [...],
  current_page: 1,
  max_page: 10,
  total: 95,
  per_page: 10
}

// Backend response dengan cursors → Cursor-based
{
  content: [...],
  next_cursor: 'token123',
  previous_cursor: null,
  has_more: true,
  per_page: 20
}

// Backend response dengan has_more only → Simple
{
  content: [...],
  current_page: 2,
  has_more: true,
  per_page: 25
}

See: PageConfig Type, Example 3


URL Parameter Generation

Library generate URL query parameters dari current state untuk seamless backend integration.

How it works:

  • getterObjectAttribute() - Return plain object untuk axios/fetch params
  • getterUrlStringAttribute() - Return URL-encoded string untuk direct append
  • Nested filters di-encode sebagai filter[key][nested]=value
  • Empty values otomatis di-skip (tidak masuk URL)

Generated Format:

ts
// Container state
container.setFilter('status', 'active');
container.setFilter('nested.category', 'tech');
container.setSort('name', 'a');
container.search = 'laptop';
container.setPage({ paginationMode: 'lengthaware', numeric: { current: 2 }, perPage: 20 });

// Object output
container.getterObjectAttribute();
// {
//   page: 2,
//   perPage: 20,
//   sortBy: 'name',
//   sortDir: 'asc',
//   filter: { status: 'active', nested: { category: 'tech' } },
//   q: 'laptop'
// }

// String output
container.getterUrlStringAttribute();
// "page=2&perPage=20&sortBy=name&sortDir=asc&filter[status]=active&filter[nested][category]=tech&q=laptop"

See: URL Generation Methods, Example 1


API Reference

Composables

useDataContainer

Composable untuk manage paginated data state dengan support filtering, sorting, search, dan multiple pagination modes.

Parameters

NameTypeDefaultDescription
initialFilterTFilter{}Initial filter values yang akan digunakan saat reset

Generic Types:

  • TData - Type of individual data items in array
  • TFilter - Type of filter object structure
  • TAdditional - Type of additional data dari response (metadata, stats, dll)

Returns

Contains:

Reactive State
data
ts
data: Ref<TData[]>

Array of data items yang di-fetch dari API. Menggunakan shallowRef untuk performance.

⚠️ Penting - Penggunaan .value:

  • Di <script>: Perlu .valuecontainer.data.value
  • Di <template>: Tidak perlu .value (auto-unwrap) → container.data

filter
ts
filter: Reactive<TFilter>

Draft filter state yang bisa langsung di-bind ke form inputs. Perubahan tidak otomatis trigger fetch.


page
ts
page: PageConfig

Reactive pagination configuration object. Contains numeric (page numbers), cursor (tokens), perPage, hasMore, totalData, dan paginationMode.

See: PageConfig Type, setPage()


sort
ts
sort: SortConfig

Reactive sort configuration object. Contains sortBy (field name) dan sortDir ('a' or 'd').

See: SortConfig Type, setSort()


loadStatus
ts
loadStatus: Ref<-1 | 0 | 1>

Loading status indicator: -1 (error), 0 (loading), 1 (loaded successfully).


error
ts
error: Ref<ErrorInfo | null>

Error information object dengan message dan code. Null jika tidak ada error.

See: ErrorInfo Type, setError(), clearError()


additionalData
ts
additionalData: Ref<TAdditional | null>

Additional data dari API response (metadata, statistics, dll). Menggunakan shallowRef untuk performance. Populated dari appended field di response.


ts
search: string

Search value dengan getter/setter. Tidak perlu .value untuk access. Value akan masuk URL params dengan parameter name dari searchParam (default: 'q').

See: setSearchParam(), getSearchParam()


Computed Properties
isLoading
ts
isLoading: ComputedRef<boolean>

Computed property, true ketika loadStatus === 0.


isLoaded
ts
isLoaded: ComputedRef<boolean>

Computed property, true ketika loadStatus === 1.


hasError
ts
hasError: ComputedRef<boolean>

Computed property, true ketika loadStatus === -1 atau error !== null.


isEmpty
ts
isEmpty: ComputedRef<boolean>

Computed property, true ketika data.length === 0.


Data Methods
appendData()
ts
appendData: (item: TData) => void

Append single item ke data array.

Use Case: Optimistic updates, manual data addition, real-time updates dari websocket.


deleteDataByIndex()
ts
deleteDataByIndex: (index: number) => boolean

Delete item dari data array by index. Returns true jika berhasil, false jika index invalid.

Use Case: Optimistic deletes, manual data removal.


dataLength()
ts
dataLength: () => number

Get current length of data array.

Use Case: Display counts, validation, conditional logic.


Filter Methods
setFilter()
ts
setFilter: (path: string, value: unknown) => void

Set filter value dengan support nested paths menggunakan dot notation (e.g., 'status', 'nested.category').

Use Case: Programmatic filter updates, complex filter logic, conditional filters.

See: Example 2


applyFilter()
ts
applyFilter: () => void

Copy current filter state ke _appliedFilter dan reset page ke 1. Call ini sebelum fetch data.

Use Case: "Apply" button on filter form, filter submission, batch filter updates.

See: Lazy State Management


resetFilterToInit()
ts
resetFilterToInit: () => void

Reset filter ke initialFilter values yang diberikan saat initialization.

Use Case: "Reset" button, clear all filters, return to default state.


resetFilterToApplied()
ts
resetFilterToApplied: () => void

Reset filter ke last _appliedFilter state (discard draft changes).

Use Case: "Cancel" button, discard changes, revert to last applied.

See: Lazy State Management


hasUnappliedFilterChanges()
ts
hasUnappliedFilterChanges: () => boolean

Check apakah ada pending changes di filter yang belum di-apply. Returns true jika ada changes.

Use Case: Enable/disable apply button, dirty state tracking, unsaved changes warning.


Search Methods
setSearchParam()
ts
setSearchParam: (param: string) => void

Set custom search parameter name untuk URL generation (default: 'q').

Use Case: Match backend API expectations, custom search field names.

See: Example 4


getSearchParam()
ts
getSearchParam: () => string

Get current search parameter name.


Pagination Methods
setPage()
ts
setPage: (config: Partial<PageConfig> | null) => void

Update pagination configuration. Pass null untuk reset ke default.

Use Case: Page navigation, change items per page, cursor pagination handling.

See: PageConfig Type, Example 3


Sort Methods
setSort()
ts
setSort: (sortBy: string, sortDir?: 'a' | 'd') => void

Set sort configuration. sortDir: 'a' (ascending) or 'd' (descending). Default: 'a'.

Use Case: Column sorting, sort direction toggle, default sort order.


Error Methods
setError()
ts
setError: (message: string, code: number | string) => void

Set error state manually. Otomatis set loadStatus ke -1.

Use Case: Manual error handling, custom error scenarios, API error mapping.

See: Example 7


clearError()
ts
clearError: () => void

Clear error state (set error ke null).

Use Case: Retry logic, error dismissal, state cleanup.


URL Generation Methods
getterObjectAttribute()
ts
getterObjectAttribute: () => UrlParams

Generate URL parameters sebagai plain object untuk axios/fetch. Returns object containing page, perPage, sortBy, sortDir, filter, dan search params.

Use Case: Axios requests, complex param handling, debugging URLs.

See: URL Parameter Generation


getterUrlStringAttribute()
ts
getterUrlStringAttribute: () => string

Generate URL query string untuk direct append ke URL. Returns URL-encoded string (without leading ?).

Use Case: Direct fetch URLs, URL construction, browser navigation.

See: URL Parameter Generation, Example 1


Utility Methods
reset()
ts
reset: () => void

Reset semua state ke default values. Data di-restore ke last successful fetch, loadStatus = 0, filters reset, search cleared, pagination/sort reset, error cleared.

Use Case: Clear all filters and state, return to initial state, hard reset.


mapFromResponse()
ts
mapFromResponse: (
  response: {
    success?: boolean;
    message?: string;
    code?: string | number;
    content?: TData[] | TData | unknown;
    appended?: TAdditional;
    max_page?: number | null;
    current_page?: number | null;
    total?: number | null;
    next_cursor?: string | null;
    previous_cursor?: string | null;
    per_page?: number | null;
    has_more?: boolean | null;
  },
  mapper?: ((item: unknown) => TData) | null
) => void

Map API response ke container state dengan auto-detection pagination mode. success: false akan trigger error state. content bisa array atau single object. mapper untuk transform setiap item.

Automatic Pagination Detection:

  • Length-Aware (has max_page + total) → paginationMode = 'lengthaware'
  • Cursor (has next_cursor or previous_cursor) → paginationMode = 'cursor'
  • Simple (has has_more only) → paginationMode = 'simple'
  • None (no pagination metadata) → paginationMode = 'none'

Use Case: Map API responses, transform data, handle pagination automatically, error handling.

See: Pagination Mode Auto-Detection, Example 5


Types

DataContainerObject

ts
interface DataContainerObject<
  TData = unknown,
  TFilter extends Record<string, unknown> = Record<string, unknown>,
  TAdditional = unknown
>

Return type dari useDataContainer composable. Contains all reactive state, computed properties, dan methods.

Generic Parameters:

  • TData - Type of individual data items
  • TFilter - Type of filter object structure
  • TAdditional - Type of additional response data

See: Complete property descriptions di API Reference - useDataContainer


PageConfig

ts
interface PageConfig {
  numeric: {
    current: number | null;
    max: number | null;
  };
  cursor: {
    next: string | null;
    prev: string | null;
  };
  perPage: number | null;
  hasMore: boolean | null;
  totalData: number | null;
  paginationMode: PaginationMode;
  isFrontEndPreferCursor: boolean;
}

Pagination configuration object.

Properties:

  • numeric.current - Current page number (length-aware/simple)
  • numeric.max - Total pages (length-aware)
  • cursor.next - Next page cursor token (cursor-based)
  • cursor.prev - Previous page cursor token (cursor-based)
  • perPage - Items per page
  • hasMore - More data available (cursor/simple)
  • totalData - Total count of all items (length-aware)
  • paginationMode - Current pagination mode
  • isFrontEndPreferCursor - Force cursor mode (useCursor=1 in URL)

See: PaginationMode Type, Pagination Mode Auto-Detection


SortConfig

ts
interface SortConfig {
  sortDir: 'a' | 'd';
  sortBy: string;
}

Sort configuration object.

Properties:

  • sortDir - Sort direction: 'a' (ascending) or 'd' (descending)
  • sortBy - Field name to sort by (empty string = no sort)

SearchConfig

ts
interface SearchConfig {
  searchParam: string;
  searchValue: string;
}

Search configuration object (internal).

Properties:

  • searchParam - Parameter name untuk URL (default: 'q')
  • searchValue - Current search value

Note: Access via container.search getter/setter dan getSearchParam() method.


ErrorInfo

ts
interface ErrorInfo {
  message: string;
  code: number | string;
}

Error information object dengan human-readable message dan error code (numeric status atau string identifier).


UrlParams

ts
interface UrlParams {
  perPage?: number;
  page?: number;
  cursor?: string;
  useCursor?: number;
  sortBy?: string;
  sortDir?: string;
  filter?: Record<string, string | string[]>;
  [key: string]: unknown;
}

URL parameters object structure. Contains pagination, sort, filter, dan dynamic search params.

See: getterObjectAttribute()


PaginationMode

ts
type PaginationMode = 'lengthaware' | 'cursor' | 'simple' | 'none';

Pagination mode types.

Values:

  • 'lengthaware' - Classic pagination dengan total pages (has max_page + total)
  • 'cursor' - Cursor-based dengan tokens (has next_cursor/previous_cursor)
  • 'simple' - Simple dengan "has more" indicator (has has_more)
  • 'none' - No pagination

See: Pagination Mode Auto-Detection


Examples

Contains:

1. Basic Data Fetching with Pagination

Complete example untuk fetch paginated data dengan length-aware pagination.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface Product {
  id: number;
  name: string;
  price: number;
}

const container = useDataContainer<Product>();

async function fetchProducts() {
  container.loadStatus.value = 0;
  
  try {
    const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
    const data = await response.json();
    
    container.mapFromResponse(data);
  } catch (error) {
    container.setError('Failed to fetch products', 'FETCH_ERROR');
  }
}

function goToPage(page: number) {
  container.setPage({
    ...container.page,
    numeric: { ...container.page.numeric, current: page }
  });
  fetchProducts();
}

fetchProducts();
</script>

<template>
  <div>
    <div v-if="container.isLoading.value">Loading...</div>
    
    <div v-else-if="container.hasError.value" class="error">
      {{ container.error.value?.message }}
      <button @click="fetchProducts">Retry</button>
    </div>
    
    <div v-else-if="container.isEmpty.value">No products found</div>
    
    <div v-else>
      <div v-for="product in container.data.value" :key="product.id" class="product">
        <h3>{{ product.name }}</h3>
        <p>${{ product.price }}</p>
      </div>
      
      <!-- Pagination -->
      <div v-if="container.page.paginationMode === 'lengthaware'" class="pagination">
        <button 
          v-for="page in container.page.numeric.max" 
          :key="page"
          @click="goToPage(page)"
          :disabled="page === container.page.numeric.current"
        >
          {{ page }}
        </button>
        <p>Total: {{ container.page.totalData }} products</p>
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • getterUrlStringAttribute() automatically builds query string dengan pagination params
  • mapFromResponse() auto-detect pagination mode dari response structure
  • Use isLoading, hasError, isEmpty untuk clean conditional rendering

2. Advanced Filtering with Nested Paths

Demonstrates filtering dengan nested objects dan apply/reset functionality.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface Product {
  id: number;
  name: string;
  price: number;
}

interface ProductFilter {
  status?: string;
  pricing?: {
    min?: number;
    max?: number;
  };
  tags?: string[];
}

const container = useDataContainer<Product, ProductFilter>({
  status: '',
  pricing: { min: 0, max: 0 },
  tags: []
});

async function fetchProducts() {
  container.loadStatus.value = 0;
  
  const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
  const data = await response.json();
  
  container.mapFromResponse(data);
}

function handleApplyFilters() {
  if (container.hasUnappliedFilterChanges()) {
    container.applyFilter();
    fetchProducts();
  }
}

function handleResetFilters() {
  container.resetFilterToInit();
  container.applyFilter();
  fetchProducts();
}

fetchProducts();
</script>

<template>
  <div>
    <!-- Filters Form -->
    <div class="filters">
      <select v-model="container.filter.status">
        <option value="">All Status</option>
        <option value="active">Active</option>
        <option value="inactive">Inactive</option>
      </select>
      
      <!-- Nested filters -->
      <input 
        v-model.number="container.filter.pricing.min" 
        type="number" 
        placeholder="Min Price"
      >
      <input 
        v-model.number="container.filter.pricing.max" 
        type="number" 
        placeholder="Max Price"
      >
      
      <!-- Array filter -->
      <div>
        <label>
          <input type="checkbox" value="sale" v-model="container.filter.tags">
          On Sale
        </label>
        <label>
          <input type="checkbox" value="new" v-model="container.filter.tags">
          New Arrival
        </label>
      </div>
      
      <!-- Actions -->
      <button 
        @click="handleApplyFilters"
        :disabled="!container.hasUnappliedFilterChanges()"
      >
        Apply Filters
      </button>
      <button @click="handleResetFilters">Reset</button>
      <button 
        @click="container.resetFilterToApplied"
        :disabled="!container.hasUnappliedFilterChanges()"
      >
        Cancel
      </button>
    </div>
    
    <!-- Data display -->
    <div v-if="container.isLoaded.value">
      <div v-for="product in container.data.value" :key="product.id">
        {{ product.name }} - ${{ product.price }}
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • setFilter() dengan dot notation untuk nested paths: setFilter('pricing.min', 100)
  • hasUnappliedFilterChanges() untuk enable/disable apply button
  • resetFilterToInit() vs resetFilterToApplied() untuk different reset behaviors
  • Filters tidak otomatis trigger fetch - harus explicit applyFilter() dan fetchData()

Catatan: URL generation otomatis handle nested filters sebagai filter[pricing][min]=100&filter[pricing][max]=500


3. Multiple Pagination Modes

Shows handling different pagination modes dengan single component logic.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface Item {
  id: number;
  name: string;
}

const container = useDataContainer<Item>();

async function fetchData() {
  container.loadStatus.value = 0;
  
  const response = await fetch('/api/items?' + container.getterUrlStringAttribute());
  const data = await response.json();
  
  // Auto-detects pagination mode from response
  container.mapFromResponse(data);
}

// Length-aware pagination
function goToPage(page: number) {
  container.setPage({
    ...container.page,
    numeric: { ...container.page.numeric, current: page }
  });
  fetchData();
}

// Cursor pagination
function goToNextPage() {
  if (container.page.cursor.next) {
    container.setPage({
      ...container.page,
      cursor: { next: container.page.cursor.next, prev: null }
    });
    fetchData();
  }
}

function goToPrevPage() {
  if (container.page.cursor.prev) {
    container.setPage({
      ...container.page,
      cursor: { next: null, prev: container.page.cursor.prev }
    });
    fetchData();
  }
}

// Simple pagination (load more)
function loadMore() {
  if (container.page.hasMore) {
    const nextPage = (container.page.numeric.current || 1) + 1;
    container.setPage({
      ...container.page,
      numeric: { ...container.page.numeric, current: nextPage }
    });
    fetchData();
  }
}

fetchData();
</script>

<template>
  <div>
    <!-- Data -->
    <div v-for="item in container.data.value" :key="item.id">
      {{ item.name }}
    </div>
    
    <!-- Length-Aware Pagination -->
    <div v-if="container.page.paginationMode === 'lengthaware'">
      <button 
        v-for="page in container.page.numeric.max" 
        :key="page"
        @click="goToPage(page)"
        :disabled="page === container.page.numeric.current"
      >
        {{ page }}
      </button>
      <p>Page {{ container.page.numeric.current }} of {{ container.page.numeric.max }}</p>
    </div>
    
    <!-- Cursor Pagination -->
    <div v-else-if="container.page.paginationMode === 'cursor'">
      <button 
        @click="goToPrevPage" 
        :disabled="!container.page.cursor.prev"
      >
        Previous
      </button>
      <button 
        @click="goToNextPage" 
        :disabled="!container.page.cursor.next"
      >
        Next
      </button>
      <p v-if="container.page.hasMore">More data available</p>
    </div>
    
    <!-- Simple Pagination (Load More) -->
    <div v-else-if="container.page.paginationMode === 'simple'">
      <button 
        @click="loadMore" 
        :disabled="!container.page.hasMore"
      >
        Load More
      </button>
      <p v-if="!container.page.hasMore">No more data</p>
    </div>
    
    <!-- No Pagination -->
    <div v-else-if="container.page.paginationMode === 'none'">
      <p>All data loaded</p>
    </div>
  </div>
</template>

Key Takeaways:

  • Check container.page.paginationMode untuk render appropriate UI
  • Length-aware: Use numeric.current dan numeric.max
  • Cursor: Use cursor.next dan cursor.prev
  • Simple: Use hasMore untuk "Load More" button
  • Auto-detection berdasarkan response structure - tidak perlu manual configuration

4. Search with Custom Parameter

Custom search parameter name untuk match backend API expectations.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';
import { watch } from 'vue';

interface Article {
  id: number;
  title: string;
  content: string;
}

const container = useDataContainer<Article>();

// Set custom search parameter (backend expects 'keyword' instead of 'q')
container.setSearchParam('keyword');

async function fetchArticles() {
  container.loadStatus.value = 0;
  
  const response = await fetch('/api/articles?' + container.getterUrlStringAttribute());
  const data = await response.json();
  
  container.mapFromResponse(data);
}

// Debounced search
let searchTimeout: number | undefined;
watch(() => container.search, (newValue) => {
  clearTimeout(searchTimeout);
  
  if (newValue.trim()) {
    searchTimeout = setTimeout(() => {
      container.applyFilter(); // Reset page to 1
      fetchArticles();
    }, 300) as unknown as number;
  } else {
    container.applyFilter();
    fetchArticles();
  }
});

fetchArticles();
</script>

<template>
  <div>
    <!-- Search Input -->
    <input 
      v-model="container.search" 
      type="search"
      placeholder="Search articles..."
    >
    <p v-if="container.search">
      Searching for: "{{ container.search }}" 
      (param: {{ container.getSearchParam() }})
    </p>
    
    <!-- Results -->
    <div v-if="container.isLoading.value">Searching...</div>
    <div v-else-if="container.isEmpty.value">No articles found</div>
    <div v-else>
      <div v-for="article in container.data.value" :key="article.id">
        <h3>{{ article.title }}</h3>
        <p>{{ article.content }}</p>
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • setSearchParam('keyword') changes URL param dari default ?q= ke ?keyword=
  • getSearchParam() returns current parameter name
  • Watch container.search untuk implement debounced search
  • applyFilter() resets page to 1 before search (important untuk pagination)

Catatan: Generated URL akan include: ?keyword=laptop&page=1&perPage=20 instead of default ?q=laptop


5. Response Mapping with Custom Transformer

Transform API response data dengan custom mapper function.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

// Backend response type
interface UserResponse {
  user_id: number;
  first_name: string;
  last_name: string;
  email_address: string;
  created: string;
}

// Frontend model type
interface User {
  id: number;
  fullName: string;
  email: string;
  createdAt: Date;
}

interface UserStats {
  totalUsers: number;
  activeUsers: number;
}

const container = useDataContainer<User, Record<string, never>, UserStats>();

async function fetchUsers() {
  container.loadStatus.value = 0;
  
  try {
    const response = await fetch('/api/users');
    const data = await response.json();
    
    // Map with transformer
    container.mapFromResponse(data, (item: unknown) => {
      const raw = item as UserResponse;
      
      return {
        id: raw.user_id,
        fullName: `${raw.first_name} ${raw.last_name}`,
        email: raw.email_address,
        createdAt: new Date(raw.created)
      };
    });
    
    // Access additional data
    if (container.additionalData.value) {
      console.log('Total users:', container.additionalData.value.totalUsers);
      console.log('Active users:', container.additionalData.value.activeUsers);
    }
  } catch (error) {
    container.setError('Failed to fetch users', 'FETCH_ERROR');
  }
}

fetchUsers();
</script>

<template>
  <div>
    <!-- Stats from additionalData -->
    <div v-if="container.additionalData.value" class="stats">
      <p>Total Users: {{ container.additionalData.value.totalUsers }}</p>
      <p>Active Users: {{ container.additionalData.value.activeUsers }}</p>
    </div>
    
    <!-- Transformed data -->
    <div v-if="container.isLoaded.value">
      <div v-for="user in container.data.value" :key="user.id" class="user">
        <h3>{{ user.fullName }}</h3>
        <p>{{ user.email }}</p>
        <small>Joined: {{ user.createdAt.toLocaleDateString() }}</small>
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • Mapper function transform setiap item dari backend format ke frontend model
  • Useful untuk rename fields, compute values, atau convert types (string → Date)
  • additionalData untuk store metadata/stats dari response (via appended field)
  • Type safety dengan generic: <User, Filter, UserStats>

Catatan: Backend response structure:

json
{
  "success": true,
  "content": [...],
  "appended": {
    "totalUsers": 150,
    "activeUsers": 120
  }
}

6. Complete CRUD Operations

Demonstrates data manipulation methods untuk optimistic updates.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';
import { ref } from 'vue';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const container = useDataContainer<Todo>();
const newTodoTitle = ref('');

async function fetchTodos() {
  container.loadStatus.value = 0;
  
  const response = await fetch('/api/todos');
  const data = await response.json();
  
  container.mapFromResponse(data);
}

async function createTodo() {
  if (!newTodoTitle.value.trim()) return;
  
  try {
    const response = await fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify({ title: newTodoTitle.value })
    });
    const data = await response.json();
    
    // Optimistic update - append immediately
    container.appendData({
      id: data.id,
      title: newTodoTitle.value,
      completed: false
    });
    
    newTodoTitle.value = '';
  } catch (error) {
    container.setError('Failed to create todo', 'CREATE_ERROR');
  }
}

async function toggleTodo(index: number) {
  const todo = container.data.value[index];
  
  try {
    await fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      body: JSON.stringify({ completed: !todo.completed })
    });
    
    // Update in place
    container.data.value[index].completed = !todo.completed;
  } catch (error) {
    container.setError('Failed to update todo', 'UPDATE_ERROR');
  }
}

async function deleteTodo(index: number) {
  const todo = container.data.value[index];
  
  try {
    await fetch(`/api/todos/${todo.id}`, { method: 'DELETE' });
    
    // Optimistic delete
    container.deleteDataByIndex(index);
  } catch (error) {
    container.setError('Failed to delete todo', 'DELETE_ERROR');
    // Refetch to restore
    fetchTodos();
  }
}

fetchTodos();
</script>

<template>
  <div>
    <!-- Create Form -->
    <div class="create-form">
      <input v-model="newTodoTitle" placeholder="New todo..." @keyup.enter="createTodo">
      <button @click="createTodo">Add</button>
    </div>
    
    <!-- Todo List -->
    <div v-if="container.isLoaded.value">
      <p>Total: {{ container.dataLength() }} todos</p>
      
      <div 
        v-for="(todo, index) in container.data.value" 
        :key="todo.id" 
        class="todo"
      >
        <input 
          type="checkbox" 
          :checked="todo.completed"
          @change="toggleTodo(index)"
        >
        <span :class="{ completed: todo.completed }">{{ todo.title }}</span>
        <button @click="deleteTodo(index)">Delete</button>
      </div>
    </div>
    
    <!-- Error -->
    <div v-if="container.hasError.value" class="error">
      {{ container.error.value?.message }}
      <button @click="container.clearError">Dismiss</button>
    </div>
  </div>
</template>

Key Takeaways:

  • appendData() untuk optimistic create
  • deleteDataByIndex() untuk optimistic delete
  • Direct mutation data.value[index] untuk updates (karena shallowRef)
  • dataLength() untuk display count
  • Error handling dengan manual refetch jika optimistic update fails

7. Error Handling Patterns

Different error handling strategies dan recovery patterns.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';
import { ref } from 'vue';

interface Product {
  id: number;
  name: string;
}

const container = useDataContainer<Product>();
const retryCount = ref(0);
const maxRetries = 3;

async function fetchProducts(silent = false) {
  if (!silent) {
    container.loadStatus.value = 0;
  }
  
  try {
    const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
    
    if (!response.ok) {
      // HTTP error
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    
    const data = await response.json();
    
    // Backend error
    if (!data.success) {
      throw new Error(data.message || 'Unknown error');
    }
    
    container.mapFromResponse(data);
    retryCount.value = 0; // Reset on success
    
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Network error';
    const code = error instanceof Error && 'status' in error ? error.status : 'NETWORK_ERROR';
    
    container.setError(message, code);
    
    // Auto-retry with exponential backoff
    if (retryCount.value < maxRetries) {
      retryCount.value++;
      const delay = Math.pow(2, retryCount.value) * 1000; // 2s, 4s, 8s
      
      console.log(`Retrying in ${delay / 1000}s... (${retryCount.value}/${maxRetries})`);
      
      setTimeout(() => {
        fetchProducts(true); // Silent retry
      }, delay);
    }
  }
}

function handleManualRetry() {
  retryCount.value = 0;
  container.clearError();
  fetchProducts();
}

function handleReset() {
  retryCount.value = 0;
  container.reset();
  fetchProducts();
}

fetchProducts();
</script>

<template>
  <div>
    <!-- Loading State -->
    <div v-if="container.isLoading.value && !container.hasError.value">
      Loading products...
    </div>
    
    <!-- Error State -->
    <div v-else-if="container.hasError.value" class="error">
      <h3>Error occurred</h3>
      <p><strong>Message:</strong> {{ container.error.value?.message }}</p>
      <p><strong>Code:</strong> {{ container.error.value?.code }}</p>
      
      <!-- Retry Info -->
      <p v-if="retryCount > 0 && retryCount < maxRetries">
        Auto-retrying... ({{ retryCount }}/{{ maxRetries }})
      </p>
      <p v-else-if="retryCount >= maxRetries">
        Max retries reached. Please try again manually.
      </p>
      
      <!-- Actions -->
      <button @click="handleManualRetry">Retry Now</button>
      <button @click="handleReset">Reset All</button>
      <button @click="container.clearError">Dismiss</button>
    </div>
    
    <!-- Success State -->
    <div v-else-if="container.isLoaded.value">
      <div v-if="container.isEmpty.value">No products found</div>
      <div v-else>
        <div v-for="product in container.data.value" :key="product.id">
          {{ product.name }}
        </div>
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • setError() untuk manual error state management
  • clearError() untuk dismiss errors
  • Exponential backoff retry pattern dengan silent flag
  • Differentiate HTTP errors vs backend errors
  • reset() untuk complete state cleanup
  • Display error details (message + code) untuk debugging

Catatan: Auto-retry useful untuk transient network errors, manual retry untuk user-triggered recovery.