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)
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:
import { useDataContainer } from '@bpmlib/utils-data-container';Types:
import type { DataContainerObject, QueryIntent, PageConfig, PageSetup } from '@bpmlib/utils-data-container';Installation & Setup
Requirements
Peer Dependencies
| Dependency | Versi | Status |
|---|---|---|
vue | ^3.3.0 | Required |
Package Installation
npm install @bpmlib/utils-data-containeryarn add @bpmlib/utils-data-containerpnpm add @bpmlib/utils-data-containerImport
import { useDataContainer } from '@bpmlib/utils-data-container';Untuk TypeScript type imports:
import type { DataContainerObject, QueryIntent, PageConfig, PageSetup } from '@bpmlib/utils-data-container';Quick Start
Basic Usage
Setup minimal untuk fetch dan render list dengan pagination:
<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:
<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:
- User edit
container.filter.category = 'laptop'→ perubahan di draft saja - Panggil
applyFilter()→ draft dipromote ke applied (nilai kosong di-strip) - 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:
| Mode | Deskripsi |
|---|---|
'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:
// ✅ 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:
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
- useDataContainer - Composable utama untuk mengelola state API-driven list
Types
- DataContainerObject - Return type dari
useDataContainer - QueryIntent - Initialize state dari URL
- PaginationIntent - Konfigurasi pagination initial
- SortIntent - Initial sort state
- SearchIntent - Initial search state
- PageSetup - Konfigurasi pagination frontend
- PageConfig - State pagination lengkap
- SortConfig - Sort state
- SearchConfig - Search state internal
- PaginationMode - Mode pagination
- ErrorInfo - Error state
- UrlParams - Return type dari
getterObjectAttribute()
Examples
Contains:
- 1. Basic Fetch Lifecycle
- 2. Lazy Filter State
- 3. Search dengan submitSearch ⭐ NEW v0.4.0
- 4. Sort Toggle
- 5. Cursor Pagination
- 6. QueryIntent dari Vue Router
- 7. URL Generation + Fetch
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.
<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.
<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 baruisUsingFilter()→ 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.
<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().
<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.
<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.
<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.
// 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— pastikanapplyFilter()sudah dipanggil - Filter nested di-serialize dengan bracket notation:
filter[status]=active - Search param name ikut
setSearchParam()— defaultq
Links
- Repository: Gitea
- Registry: Private NPM