Skip to content

DataContainer ​

Vue 3 composable untuk mengelola data list dari API: pagination, filter, sort, dan search dalam satu reactive object.

Versi: 0.4.0 Changelog | API Reference
Kategori: Framework Utils (Vue 3)

npm versionTypeScriptVue

TL;DR ​

Composable tunggal useDataContainer yang mengelola state lengkap sebuah API-driven list: pagination (lengthAware, cursor, simple), filter dengan lazy state, sort, search, dan URL serialization. Tidak perlu setup store atau state management terpisah — satu composable untuk connect ke API.

Composables:

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

Types:

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

Installation & Setup ​

Requirements ​

Peer Dependencies ​

DependencyVersiStatus
vue^3.3.0Required

Package Installation ​

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

Import ​

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

Untuk TypeScript type imports:

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

Quick Start ​

Basic Usage ​

Setup minimal untuk fetch dan render list dengan pagination:

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

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

const container = useDataContainer<Product>();

async function fetchProducts() {
  container.loadStatus = 0;
  const res = await axios.get('/api/products', {
    params: container.getterObjectAttribute(),
  });
  container.mapFromResponse(res.data);
}

fetchProducts();
</script>

<template>
  <div v-if="container.isLoading">Loading...</div>
  <ul v-else>
    <li v-for="item in container.data" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

Comprehensive Example ​

Setup dengan filter, search, dan pagination:

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

interface Product { id: number; name: string; }
interface Filter { category: string; status: string; }

const container = useDataContainer<Product, Filter>({ category: '', status: '' });

async function fetchProducts() {
  container.loadStatus = 0;
  try {
    const res = await axios.get('/api/products', {
      params: container.getterObjectAttribute(),
    });
    container.mapFromResponse(res.data);
  } catch {
    container.setError('Gagal memuat data', 500);
  }
}

function applyFilter() {
  container.applyFilter();
  container.navigatePage(1);
  fetchProducts();
}

function handleSearch() {
  container.submitSearch(); // reset page ke 1 otomatis
  fetchProducts();
}

fetchProducts();
</script>

Core Concepts ​

Filter State Machine ​

Filter dikelola dalam dua layer reaktif yang parallel: filter (draft — yang user sedang input) dan _appliedFilter (applied — yang terakhir dikirim ke API). applyFilter() mem-promote draft ke applied setelah membersihkan nilai kosong. URL generation selalu menggunakan _appliedFilter, bukan filter.

Alur:

  1. User edit container.filter.category = 'laptop' → perubahan di draft saja
  2. Panggil applyFilter() → draft dipromote ke applied (nilai kosong di-strip)
  3. Fetch dengan getterObjectAttribute() → menggunakan applied filter

hasUnappliedFilterChanges() mengecek apakah draft berbeda dari applied. isUsingFilter() mengecek apakah applied berbeda dari initial filter — berguna untuk conditional rendering tombol "Hapus Filter".


Pagination Modes ​

Mode pagination dikontrol via page.setup.mode — frontend yang mendeklarasikan, backend tidak bisa override:

ModeDeskripsi
'lengthAware'Pagination numerik dengan current_page dan max_page dari API
'cursor'Next/prev cursor dari API (next_cursor, previous_cursor)
'simple'Flag has_more saja — tidak tahu total halaman
'none'Tanpa pagination

Gunakan declarePagination({ mode, perPage? }) untuk mendeklarasikan intent sebelum fetch pertama. Jika tidak dideklarasikan, mapFromResponse() auto-deteksi mode dari nilai field response pada response pertama dan mengunci mode tersebut — cursor fields bernilai null tidak dianggap cursor mode. Setelah mode terkunci via page.setup, response berikutnya hanya mengupdate state (cursor, page number, total), tidak mengubah mode.


Direct Value Properties ​

loadStatus dan search adalah getter/setter biasa — bukan Ref. Tidak perlu .value:

ts
// ✅ Benar
container.loadStatus = 0;
container.search = 'keyword';

// ❌ Salah
container.loadStatus.value = 0;

loadStatus harus di-set manual ke 0 sebelum fetch. mapFromResponse() otomatis set ke 1 (loaded) atau -1 (error). Property search digunakan untuk binding ke input — gunakan submitSearch() untuk trigger search baru agar pagination otomatis reset ke halaman 1.


QueryIntent ​

Parameter kedua opsional useDataContainer(filter, queryIntent) untuk initialize state dari URL params — cocok untuk deep linking, shareable URLs, dan SSR hydration:

ts
const container = useDataContainer<Product, Filter>(
  { category: '' },
  {
    pagination: { mode: 'lengthAware', page: Number(route.query.page) || 1 },
    sort: { by: String(route.query.sortBy || 'name'), dir: 'asc' },
    search: { value: String(route.query.q || '') },
  }
);

Lihat: Example 6: QueryIntent dari Vue Router


API Reference ​

Dokumentasi lengkap API tersedia di halaman terpisah untuk kemudahan navigasi.

📖 Buka API Reference Lengkap → ​

Daftar Isi API:

Composables ​

Types ​


Examples ​

Contains:

1. Basic Fetch Lifecycle ​

Pattern standar: set loading, fetch, map response. Error dari network ditangani via try-catch; error dari API (saat response.success === false) ditangani otomatis oleh mapFromResponse.

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

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

const container = useDataContainer<User>();

async function fetchUsers() {
  container.loadStatus = 0;
  try {
    const res = await axios.get('/api/users', {
      params: container.getterObjectAttribute(),
    });
    container.mapFromResponse(res.data);
  } catch {
    container.setError('Gagal terhubung ke server', 'NETWORK_ERR');
  }
}

function nextPage() {
  container.nextPage();
  fetchUsers();
}

fetchUsers();
</script>

<template>
  <div v-if="container.isLoading">Loading...</div>
  <div v-else-if="container.hasError">{{ container.error?.message }}</div>
  <div v-else>
    <ul>
      <li v-for="user in container.data" :key="user.id">{{ user.name }}</li>
    </ul>
    <button @click="nextPage">Next</button>
  </div>
</template>

2. Lazy Filter State ​

Filter bekerja dalam dua layer: draft (user edit) dan applied (sudah dikirim ke API). Ini memungkinkan user mengubah beberapa field sebelum confirm — filter drawer yang belum di-submit tidak mengubah hasil list.

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

interface Product { id: number; name: string; }
interface Filter { category: string; status: string; }

const container = useDataContainer<Product, Filter>({ category: '', status: '' });

async function fetchProducts() {
  container.loadStatus = 0;
  const res = await axios.get('/api/products', {
    params: container.getterObjectAttribute(),
  });
  container.mapFromResponse(res.data);
}

function applyFilter() {
  container.applyFilter();
  container.navigatePage(1);
  fetchProducts();
}

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

fetchProducts();
</script>

<template>
  <select v-model="container.filter.category">
    <option value="">Semua</option>
    <option value="laptop">Laptop</option>
  </select>
  <select v-model="container.filter.status">
    <option value="">Semua</option>
    <option value="active">Aktif</option>
  </select>

  <button @click="applyFilter" :disabled="!container.hasUnappliedFilterChanges()">
    Terapkan Filter
  </button>
  <button v-if="container.isUsingFilter()" @click="resetFilter">
    Hapus Filter
  </button>
</template>

Key Takeaways:

  • Edit container.filter.* tidak langsung affect URL — applyFilter() yang promote ke applied
  • hasUnappliedFilterChanges() → disable button jika tidak ada perubahan baru
  • isUsingFilter() → tampilkan tombol "Hapus Filter" hanya saat filter aktif

3. Search dengan submitSearch ​

TIP

NEW v0.3.2

Gunakan submitSearch() saat user submit search baru agar pagination otomatis reset ke halaman 1. Mencegah user stuck di halaman yang tidak ada di hasil search baru.

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

const container = useDataContainer();

async function fetchData() {
  container.loadStatus = 0;
  const res = await axios.get('/api/products', {
    params: container.getterObjectAttribute(),
  });
  container.mapFromResponse(res.data);
}

function handleSearch() {
  container.submitSearch(); // reset page ke 1, lalu fetch
  fetchData();
}

function clearSearch() {
  container.submitSearch('');
  fetchData();
}

fetchData();
</script>

<template>
  <form @submit.prevent="handleSearch">
    <input v-model="container.search" placeholder="Cari produk..." />
    <button type="submit">Cari</button>
    <button v-if="container.search" type="button" @click="clearSearch">Clear</button>
  </form>
  <p v-if="container.search">
    Hasil untuk "{{ container.search }}"
  </p>
</template>

4. Sort Toggle ​

Pattern toggle sort menggunakan setSortAsc()/setSortDesc() — lebih eksplisit dibanding pass direction string ke setSort().

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

const container = useDataContainer();
container.setSort('name', 'asc');

async function fetchData() {
  container.loadStatus = 0;
  const res = await axios.get('/api/products', {
    params: container.getterObjectAttribute(),
  });
  container.mapFromResponse(res.data);
}

function toggleSort(field: string) {
  if (container.sort.sortBy === field) {
    container.sort.sortDir === 'asc'
      ? container.setSortDesc()
      : container.setSortAsc();
  } else {
    container.setSortAsc(field); // ganti field, default ascending
  }
  fetchData();
}

fetchData();
</script>

<template>
  <button @click="toggleSort('name')">
    Nama
    <span v-if="container.sort.sortBy === 'name'">
      {{ container.sort.sortDir === 'asc' ? '↑' : '↓' }}
    </span>
  </button>
  <button @click="toggleSort('price')">Harga</button>
</template>

5. Cursor Pagination ​

Untuk API yang mengembalikan next_cursor/previous_cursor. mapFromResponse() auto-detect mode 'cursor' dari field response.

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

const container = useDataContainer();

async function fetchData() {
  container.loadStatus = 0;
  const res = await axios.get('/api/feed', {
    params: container.getterObjectAttribute(),
  });
  container.mapFromResponse(res.data);
}

function loadNext() {
  container.nextPage();
  fetchData();
}

function loadPrev() {
  container.prevPage();
  fetchData();
}

fetchData();
</script>

<template>
  <div v-for="item in container.data" :key="item.id">{{ item.title }}</div>
  <button :disabled="!container.page.cursor.prev" @click="loadPrev">Sebelumnya</button>
  <button :disabled="!container.page.cursor.next" @click="loadNext">Selanjutnya</button>
</template>

Catatan: Untuk backend yang pakai pagination numerik tapi ingin UI cursor-style, deklarasikan container.declarePagination({ mode: 'cursor' }) sebelum fetch pertama — frontend yang menentukan mode, bukan response.


6. QueryIntent dari Vue Router ​

Initialize container state dari URL query params untuk shareable links dan deep linking.

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

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

const route = useRoute();

const container = useDataContainer<Product, Filter>(
  { category: String(route.query.category || '') },
  {
    pagination: {
      mode: 'lengthAware',
      page: Number(route.query.page) || 1,
      perPage: 20,
    },
    sort: {
      by: String(route.query.sortBy || 'name'),
      dir: route.query.sortDir === 'desc' ? 'desc' : 'asc',
    },
    search: { value: String(route.query.q || '') },
  }
);

container.applyFilter();

async function fetchProducts() {
  container.loadStatus = 0;
  const res = await axios.get('/api/products', {
    params: container.getterObjectAttribute(),
  });
  container.mapFromResponse(res.data);
}

fetchProducts();
</script>

7. URL Generation + Fetch ​

Perbandingan getterObjectAttribute() vs getterUrlStringAttribute() — pilih sesuai HTTP client.

ts
// Untuk axios — pass sebagai params object
const res = await axios.get('/api/products', {
  params: container.getterObjectAttribute(),
});
// → GET /api/products?page=2&perPage=15&sortBy=name&sortDir=asc&filter[status]=active&q=laptop

// Untuk native fetch — build URL manual
const queryString = container.getterUrlStringAttribute();
const res = await fetch(`/api/products?${queryString}`);

// Inspect params sebelum dikirim
console.log(container.getterObjectAttribute());
// {
//   page: 2,
//   perPage: 15,
//   sortBy: 'name',
//   sortDir: 'asc',
//   filter: { status: 'active' },
//   q: 'laptop'
// }

Key Takeaways:

  • Kedua method selalu menggunakan _appliedFilter — pastikan applyFilter() sudah dipanggil
  • Filter nested di-serialize dengan bracket notation: filter[status]=active
  • Search param name ikut setSearchParam() — default q