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