.md

Building with Vue

In this tutorial, you will learn how to integrate dcupl with Vue 3 applications using the Composition API. You will build a product browser with filtering, search, and real-time updates using Vue's reactivity system and dcupl's data management.

Time required: 15-20 minutes

What you will build: A Vue 3 application with a filterable product grid, search functionality, and live data updates.

Prerequisites

Before starting, make sure you have:

Step 1: create a Vue project

Create a new Vue 3 project with TypeScript and install dcupl. @dcupl/common holds the TypeScript types the composable uses; --no-immediate stops create-vite from installing and starting the dev server before dcupl is installed:

terminal
npm create vite@latest dcupl-vue -- --template vue-ts --no-immediate
cd dcupl-vue
npm install @dcupl/core @dcupl/common
npm install

The template's src/style.css centers #app and changes heading colors in dark mode, which clashes with the styles of this tutorial: delete the line import './style.css' from src/main.ts.

Step 2: set up the dcupl instance

Create a dcupl service that can be shared across your Vue app.

Create a new file for dcupl configuration:

src/dcupl/instance.ts
import { Dcupl } from '@dcupl/core';

// Create a singleton dcupl instance
export const dcupl = new Dcupl();

// Define the Product model
dcupl.models.set({
  key: 'Product',
  properties: [
    { key: 'name', type: 'string' },
    { key: 'description', type: 'string' },
    { key: 'category', type: 'string' },
    { key: 'price', type: 'float' },
    { key: 'rating', type: 'float' },
    { key: 'inStock', type: 'boolean' },
    { key: 'tags', type: 'Array<string>' },
    { key: 'imageUrl', type: 'string' },
  ],
});

// Load sample data
dcupl.data.set(
  [
    {
      key: 'p1',
      name: 'Wireless Headphones',
      description: 'Premium noise-canceling headphones',
      category: 'Electronics',
      price: 299.99,
      rating: 4.8,
      inStock: true,
      tags: ['audio', 'wireless', 'premium'],
      imageUrl: '/headphones.jpg',
    },
    {
      key: 'p2',
      name: 'Mechanical Keyboard',
      description: 'RGB backlit mechanical keyboard',
      category: 'Electronics',
      price: 149.99,
      rating: 4.6,
      inStock: true,
      tags: ['gaming', 'keyboard', 'rgb'],
      imageUrl: '/keyboard.jpg',
    },
    {
      key: 'p3',
      name: 'Running Shoes',
      description: 'Lightweight running shoes',
      category: 'Sports',
      price: 129.99,
      rating: 4.7,
      inStock: true,
      tags: ['running', 'fitness', 'lightweight'],
      imageUrl: '/shoes.jpg',
    },
    {
      key: 'p4',
      name: 'Coffee Maker',
      description: 'Programmable drip coffee maker',
      category: 'Home',
      price: 89.99,
      rating: 4.4,
      inStock: false,
      tags: ['kitchen', 'coffee', 'appliance'],
      imageUrl: '/coffee.jpg',
    },
    {
      key: 'p5',
      name: 'Yoga Mat',
      description: 'Non-slip exercise mat',
      category: 'Sports',
      price: 39.99,
      rating: 4.9,
      inStock: true,
      tags: ['yoga', 'fitness', 'exercise'],
      imageUrl: '/mat.jpg',
    },
    {
      key: 'p6',
      name: 'Smart Watch',
      description: 'Fitness tracker with heart rate monitor',
      category: 'Electronics',
      price: 249.99,
      rating: 4.5,
      inStock: true,
      tags: ['wearable', 'fitness', 'smart'],
      imageUrl: '/watch.jpg',
    },
    {
      key: 'p7',
      name: 'Desk Lamp',
      description: 'LED desk lamp with adjustable brightness',
      category: 'Home',
      price: 49.99,
      rating: 4.3,
      inStock: true,
      tags: ['lighting', 'office', 'led'],
      imageUrl: '/lamp.jpg',
    },
    {
      key: 'p8',
      name: 'Protein Powder',
      description: 'Whey protein isolate 2lb',
      category: 'Sports',
      price: 44.99,
      rating: 4.6,
      inStock: true,
      tags: ['nutrition', 'protein', 'fitness'],
      imageUrl: '/protein.jpg',
    },
  ],
  { model: 'Product' },
);

// Initialize once: every caller gets the promise of the first call
let initPromise: Promise<boolean> | undefined;

export function initializeDcupl(): Promise<boolean> {
  initPromise ??= dcupl.init();
  return initPromise;
}

// Product type
export interface Product {
  key: string;
  name: string;
  description: string;
  category: string;
  price: number;
  rating: number;
  inStock: boolean;
  tags: string[];
  imageUrl: string;
}

initializeDcupl() keeps the promise of its first call, so dcupl.init() runs only once, even when several components use the composable from Step 3.

Step 3: create a useDcupl composable

Create a composable to manage dcupl initialization:

src/dcupl/useDcupl.ts
import { ref, onMounted } from 'vue';
import { dcupl, initializeDcupl } from './instance';

export function useDcupl() {
  const isInitialized = ref(false);
  const isLoading = ref(true);
  const error = ref<Error | null>(null);

  onMounted(async () => {
    try {
      const success = await initializeDcupl();
      isInitialized.value = success;
    } catch (err) {
      error.value = err as Error;
    } finally {
      isLoading.value = false;
    }
  });

  return {
    dcupl,
    isInitialized,
    isLoading,
    error,
  };
}

Step 4: create a useProductList composable

Create a composable for managing product queries with filters:

src/dcupl/useProductList.ts
import { shallowRef, ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
import type { DcuplList } from '@dcupl/core';
import type { DcuplQuery, DcuplQueryGroup } from '@dcupl/common';
import { dcupl, type Product } from './instance';

export interface Filters {
  category: string;
  minPrice: number | null;
  maxPrice: number | null;
  inStock: boolean | null;
  searchTerm: string;
}

interface FacetItem {
  value: any;
  size: number;
}

export function useProductList() {
  // Results are replaced as a whole on every read, so shallowRef is enough
  const products = shallowRef<Product[]>([]);
  const categoryFacets = shallowRef<FacetItem[]>([]);
  const priceStats = ref({ min: 0, max: 0, avg: 0 });
  const totalCount = computed(() => products.value.length);

  const filters = reactive<Filters>({
    category: '',
    minPrice: null,
    maxPrice: null,
    inStock: null,
    searchTerm: '',
  });

  let productList: DcuplList<Product> | null = null;
  let unsubscribe: (() => void) | null = null;

  // Read products, facets and price statistics from the list
  const readResults = () => {
    if (!productList) return;

    // Get filtered products sorted by rating (sort orders are uppercase)
    products.value = productList.catalog.query.items({
      sort: { attributes: ['rating'], order: ['DESC'] },
    });

    // Category counts under the other active filters. The category selection itself
    // is left out of the count, and excludeZeros: false keeps categories with 0 matches listed
    categoryFacets.value = productList.catalog.fn.facets({ attribute: 'category', excludeZeros: false });

    // Get price statistics for filtered results (missing when no product matches)
    const stats = productList.catalog.fn.aggregate({ attribute: 'price', types: ['min', 'max', 'avg'] });
    priceStats.value = { min: stats.min ?? 0, max: stats.max ?? 0, avg: stats.avg ?? 0 };
  };

  // Turn the filters into query groups and apply them to the list's query
  const applyFilters = () => {
    if (!productList) return;

    // One group per attribute, keyed by the attribute: a facet leaves out
    // the group of its own attribute when it counts
    const groups: DcuplQueryGroup[] = [];

    // Category filter
    if (filters.category) {
      groups.push({
        groupKey: 'category',
        groupType: 'and',
        queries: [{ attribute: 'category', operator: 'eq', value: filters.category }],
      });
    }

    // Price range: both bounds must hold, so they share one 'and' group
    const priceRange: DcuplQuery[] = [];
    if (filters.minPrice !== null) {
      priceRange.push({ attribute: 'price', operator: 'gte', value: filters.minPrice });
    }
    if (filters.maxPrice !== null) {
      priceRange.push({ attribute: 'price', operator: 'lte', value: filters.maxPrice });
    }
    if (priceRange.length > 0) {
      groups.push({ groupKey: 'price', groupType: 'and', queries: priceRange });
    }

    // In-stock filter using eq for exact boolean match
    if (filters.inStock !== null) {
      groups.push({
        groupKey: 'inStock',
        groupType: 'and',
        queries: [{ attribute: 'inStock', operator: 'eq', value: filters.inStock }],
      });
    }

    // Search term: wrap it in slashes for a substring match (a bare string
    // must match the whole value), escape regex characters, ignore case
    if (filters.searchTerm) {
      const escaped = filters.searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      groups.push({
        groupKey: 'name',
        groupType: 'and',
        queries: [{ attribute: 'name', operator: 'find', value: `/${escaped}/`, options: { regexFlags: 'i' } }],
      });
    }

    // Replace the whole query in one call (no groups = no filters).
    // The onResultChange subscription then re-reads the results once.
    productList.catalog.query.apply({ groupKey: 'root', groupType: 'and', queries: groups }, { mode: 'set' });
  };

  // Apply changes the filter sidebar emits
  const updateFilters = (changes: Partial<Filters>) => {
    Object.assign(filters, changes);
  };

  const clearFilters = () => {
    filters.category = '';
    filters.minPrice = null;
    filters.maxPrice = null;
    filters.inStock = null;
    filters.searchTerm = '';
  };

  // A watcher on a reactive object is deep: it fires on every filter change
  watch(filters, applyFilters);

  onMounted(() => {
    // Create the product list
    productList = dcupl.lists.create<Product>({ modelKey: 'Product' });

    // Re-read whenever the list's result changes: after a filter change
    // and after `await dcupl.update()` changed the data
    unsubscribe = productList.onResultChange(readResults);

    // Initial read
    readResults();
  });

  onUnmounted(() => {
    unsubscribe?.();
    productList?.destroy();
  });

  return {
    products,
    totalCount,
    categoryFacets,
    priceStats,
    filters,
    updateFilters,
    clearFilters,
  };
}

dcupl runs queries synchronously in memory, so the composable needs no loading state. applyFilters() only changes the query; onResultChange calls readResults() whenever the list's result changes, after a filter change as well as after await dcupl.update().

Step 5: create UI components

Create the product card component:

src/components/ProductCard.vue
<script setup lang="ts">
import type { Product } from '../dcupl/instance';

defineProps<{
  product: Product;
}>();
</script>

<template>
  <div class="product-card">
    <div class="product-image">
      <img :src="product.imageUrl" :alt="product.name" />
      <span v-if="!product.inStock" class="out-of-stock">Out of Stock</span>
    </div>
    <div class="product-info">
      <h3>{{ product.name }}</h3>
      <p class="description">{{ product.description }}</p>
      <div class="meta">
        <span class="category">{{ product.category }}</span>
        <span class="rating"> {{ '★'.repeat(Math.round(product.rating)) }} {{ product.rating.toFixed(1) }} </span>
      </div>
      <div class="price">${{ product.price.toFixed(2) }}</div>
      <div class="tags">
        <span v-for="tag in product.tags" :key="tag" class="tag">{{ tag }}</span>
      </div>
    </div>
  </div>
</template>

<style scoped>
.product-card {
  background: #fff;
  border-radius: 8px;
  overflow: hidden;
  transition:
    transform 0.2s,
    box-shadow 0.2s;
}

.product-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.product-image {
  position: relative;
  height: 200px;
  background: #f0f0f0;
}

.product-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.out-of-stock {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  background: #ff4444;
  color: #fff;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font-size: 0.75rem;
}

.product-info {
  padding: 1rem;
}

.product-info h3 {
  margin: 0 0 0.5rem 0;
  font-size: 1.1rem;
}

.description {
  color: #666;
  font-size: 0.9rem;
  margin: 0 0 0.75rem 0;
}

.meta {
  display: flex;
  justify-content: space-between;
  font-size: 0.85rem;
  margin-bottom: 0.5rem;
}

.category {
  background: #f0f0f0;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
}

.rating {
  color: #f5a623;
}

.price {
  font-size: 1.25rem;
  font-weight: 600;
  margin-bottom: 0.5rem;
}

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
}

.tag {
  background: #e8e8e8;
  padding: 0.125rem 0.375rem;
  border-radius: 3px;
  font-size: 0.75rem;
  color: #666;
}
</style>

Create the filter sidebar component:

src/components/FilterSidebar.vue
<script setup lang="ts">
import type { Filters } from '../dcupl/useProductList';

interface FacetItem {
  value: any;
  size: number;
}

defineProps<{
  filters: Filters;
  categoryFacets: FacetItem[];
  priceStats: { min: number; max: number };
}>();

const emit = defineEmits<{
  (e: 'change', changes: Partial<Filters>): void;
  (e: 'clear'): void;
}>();

// Props are read-only: the inputs show the filters and emit what changed
const valueOf = (event: Event) => (event.target as HTMLInputElement | HTMLSelectElement).value;
const numberOf = (event: Event) => (valueOf(event) === '' ? null : Number(valueOf(event)));
const checkedOf = (event: Event) => (event.target as HTMLInputElement).checked;

const handleClear = () => {
  emit('clear');
};
</script>

<template>
  <aside class="filter-sidebar">
    <h2>Filters</h2>

    <!-- Search -->
    <div class="filter-section">
      <label>Search</label>
      <input
        :value="filters.searchTerm"
        type="text"
        placeholder="Search products..."
        @input="emit('change', { searchTerm: valueOf($event) })"
      />
    </div>

    <!-- Categories -->
    <div class="filter-section">
      <label>Category</label>
      <select :value="filters.category" @change="emit('change', { category: valueOf($event) })">
        <option value="">All Categories</option>
        <option v-for="facet in categoryFacets" :key="facet.value" :value="facet.value">
          {{ facet.value }} ({{ facet.size }})
        </option>
      </select>
    </div>

    <!-- Price Range -->
    <div class="filter-section">
      <label>Price Range (${{ priceStats.min }} - ${{ priceStats.max }})</label>
      <div class="price-inputs">
        <input
          :value="filters.minPrice ?? ''"
          type="number"
          placeholder="Min"
          @input="emit('change', { minPrice: numberOf($event) })"
        />
        <span>to</span>
        <input
          :value="filters.maxPrice ?? ''"
          type="number"
          placeholder="Max"
          @input="emit('change', { maxPrice: numberOf($event) })"
        />
      </div>
    </div>

    <!-- In Stock -->
    <div class="filter-section">
      <label>
        <input
          :checked="filters.inStock === true"
          type="checkbox"
          @change="emit('change', { inStock: checkedOf($event) ? true : null })"
        />
        In Stock Only
      </label>
    </div>

    <!-- Actions -->
    <div class="filter-actions">
      <button @click="handleClear" class="clear-btn">Clear All</button>
    </div>
  </aside>
</template>

<style scoped>
.filter-sidebar {
  width: 280px;
  flex-shrink: 0;
  background: #fff;
  padding: 1.5rem;
  border-radius: 8px;
  height: fit-content;
}

.filter-sidebar h2 {
  margin: 0 0 1.5rem 0;
  font-size: 1.25rem;
}

.filter-section {
  margin-bottom: 1.5rem;
}

.filter-section label {
  display: block;
  font-weight: 500;
  margin-bottom: 0.5rem;
}

.filter-section input[type='text'],
.filter-section input[type='number'],
.filter-section select {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.price-inputs {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.price-inputs input {
  flex: 1;
}

.filter-actions {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.clear-btn {
  background: #fff;
  color: #666;
  border: 1px solid #ddd;
  padding: 0.75rem;
  border-radius: 4px;
  cursor: pointer;
}

.clear-btn:hover {
  background: #f5f5f5;
}
</style>

FilterSidebar doesn't change its filters prop: its inputs show the current filters and emit a change event with the fields that changed. ProductBrowser passes those changes to updateFilters() of useProductList, whose watcher then applies them to the query. A cleared price input becomes null, so no bound is applied.

Step 6: build the main app component

Update the main App component:

src/App.vue
<script setup lang="ts">
import { useDcupl } from './dcupl/useDcupl';
import ProductBrowser from './components/ProductBrowser.vue';

const { isInitialized, isLoading: dcuplLoading, error } = useDcupl();
</script>

<template>
  <div v-if="dcuplLoading" class="loading">Initializing dcupl...</div>
  <div v-else-if="error" class="error">Error: {{ error.message }}</div>
  <div v-else-if="!isInitialized" class="error">Failed to initialize dcupl</div>
  <ProductBrowser v-else />
</template>

<style>
* {
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  margin: 0;
  background: #f5f5f5;
}

.loading,
.error {
  text-align: center;
  padding: 3rem;
  color: #666;
}
</style>

Create the ProductBrowser component:

src/components/ProductBrowser.vue
<script setup lang="ts">
import { useProductList } from '../dcupl/useProductList';
import ProductCard from './ProductCard.vue';
import FilterSidebar from './FilterSidebar.vue';

const { products, totalCount, categoryFacets, priceStats, filters, updateFilters, clearFilters } = useProductList();
</script>

<template>
  <div class="app">
    <header>
      <h1>Product Browser</h1>
      <p>Powered by dcupl + Vue</p>
    </header>

    <main class="main-content">
      <FilterSidebar
        :filters="filters"
        :category-facets="categoryFacets"
        :price-stats="priceStats"
        @change="updateFilters"
        @clear="clearFilters"
      />

      <section class="product-grid-section">
        <div class="results-header">
          <span>{{ totalCount }} products found</span>
        </div>

        <div class="product-grid">
          <ProductCard v-for="product in products" :key="product.key" :product="product" />
        </div>

        <div v-if="products.length === 0" class="no-results">
          No products match your filters. Try adjusting your criteria.
        </div>
      </section>
    </main>
  </div>
</template>

<style scoped>
.app {
  min-height: 100vh;
}

header {
  background: #000;
  color: #fff;
  padding: 1.5rem 2rem;
}

header h1 {
  margin: 0 0 0.25rem 0;
}

header p {
  margin: 0;
  opacity: 0.7;
}

.main-content {
  display: flex;
  gap: 2rem;
  padding: 2rem;
  max-width: 1400px;
  margin: 0 auto;
}

.product-grid-section {
  flex: 1;
}

.results-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1rem;
}

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
}

.no-results {
  text-align: center;
  padding: 3rem;
  color: #666;
}
</style>

Step 7: add real-time updates

Create an admin panel component to simulate real-time changes:

src/components/AdminPanel.vue
<script setup lang="ts">
import { ref } from 'vue';
import { dcupl } from '../dcupl/instance';

const isUpdating = ref(false);

const addProduct = async () => {
  isUpdating.value = true;
  const id = `p${Date.now()}`;

  dcupl.data.upsert(
    [
      {
        key: id,
        name: `New Product ${id.slice(-4)}`,
        description: 'Just added!',
        category: 'Electronics',
        price: Math.floor(Math.random() * 200) + 50,
        rating: 4.0 + Math.random(),
        inStock: true,
        tags: ['new'],
        imageUrl: '/placeholder.jpg',
      },
    ],
    { model: 'Product' },
  );

  await dcupl.update();
  isUpdating.value = false;
};

const updateRandomPrice = async () => {
  isUpdating.value = true;

  // A one-off read: query the model directly instead of creating a list
  const products = dcupl.query.execute({ modelKey: 'Product', queries: [] });
  const randomProduct = products[Math.floor(Math.random() * products.length)];

  if (randomProduct) {
    const newPrice = Math.floor(Math.random() * 300) + 20;
    dcupl.data.update([{ key: randomProduct.key, price: newPrice }], { model: 'Product' });
    await dcupl.update();
  }

  isUpdating.value = false;
};

const toggleStock = async () => {
  isUpdating.value = true;

  const products = dcupl.query.execute({ modelKey: 'Product', queries: [] });
  const randomProduct = products[Math.floor(Math.random() * products.length)];

  if (randomProduct) {
    dcupl.data.update([{ key: randomProduct.key, inStock: !randomProduct.inStock }], { model: 'Product' });
    await dcupl.update();
  }

  isUpdating.value = false;
};
</script>

<template>
  <div class="admin-panel">
    <h3>Admin Controls (Real-time Demo)</h3>
    <div class="admin-buttons">
      <button @click="addProduct" :disabled="isUpdating">Add Random Product</button>
      <button @click="updateRandomPrice" :disabled="isUpdating">Update Random Price</button>
      <button @click="toggleStock" :disabled="isUpdating">Toggle Random Stock</button>
    </div>
  </div>
</template>

<style scoped>
.admin-panel {
  margin-top: 2rem;
  padding: 1rem;
  background: #fff3cd;
  border-radius: 8px;
}

.admin-panel h3 {
  margin: 0 0 1rem 0;
  font-size: 1rem;
}

.admin-buttons {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.admin-buttons button {
  padding: 0.5rem 1rem;
  border: 1px solid #856404;
  background: #fff;
  border-radius: 4px;
  cursor: pointer;
}

.admin-buttons button:hover:not(:disabled) {
  background: #856404;
  color: #fff;
}

.admin-buttons button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

Render the panel in src/components/ProductBrowser.vue: import it next to the other components, and add it at the end of the product-grid-section:

src/components/ProductBrowser.vue: add the AdminPanel
<script setup lang="ts">
// ... the imports from Step 6 ...
import AdminPanel from './AdminPanel.vue';
</script>

<template>
  <!-- ... -->
  <section class="product-grid-section">
    <!-- ... results header, grid and no-results message from Step 6 ... -->
    <AdminPanel />
  </section>
  <!-- ... -->
</template>

You don't need to wire anything else: await dcupl.update() emits result_updated on the product list, the onResultChange subscription in useProductList re-reads products, facets and price statistics, and the grid re-renders.

Step 8: run the application

Start the development server:

terminal
npm run dev

Open your browser to http://localhost:5173 to see the product browser.

What you learned

In this tutorial, you learned how to:

  1. Set up dcupl in a Vue 3 application with Vite
  2. Create a singleton dcupl instance for shared state
  3. Build Vue composables for dcupl initialization (useDcupl)
  4. Build reactive composables for filtered data (useProductList)
  5. Use Vue's watch to react to filter changes
  6. Re-read when a list's result changes (onResultChange) for real-time updates, with proper cleanup
  7. Create filter components that work with facets, with props down and events up
  8. Handle loading and error states properly
  9. Implement real-time data modifications with visual feedback

Key patterns

Singleton dcupl Instance

// instance.ts
export const dcupl = new Dcupl();
// Use the same instance everywhere

Initialization Composable

const { dcupl, isInitialized, isLoading, error } = useDcupl();

Reactive Filters with watch

const filters = reactive<Filters>({ category: '', ... });

// A watcher on a reactive object is deep
watch(filters, applyFilters);

Result Subscription with Cleanup

onMounted(() => {
  productList = dcupl.lists.create<Product>({ modelKey: 'Product' });
  // Fires after query changes and after `await dcupl.update()`; returns the unsubscribe function
  unsubscribe = productList.onResultChange(readResults);
});

onUnmounted(() => {
  unsubscribe?.();
  productList?.destroy();
});

Props Down, Events Up

<!-- The child shows the filters and emits what changed; the composable applies it -->
<FilterSidebar :filters="filters" @change="updateFilters" @clear="clearFilters" />

Query operators used

The filters above use eq, gte, lte and find (a /regex/ value with regexFlags); every operator and its options is in the query operators reference.

Common issues

dcupl not initialized when component renders

Always check isInitialized before rendering components that use dcupl:

<ProductBrowser v-if="isInitialized" />
<Loading v-else />

Stale data after updates

Data calls (data.set, upsert, update, remove) are staged until await dcupl.update(). Re-read the list when its result changes:

const unsubscribe = list.onResultChange(() => readResults());
// call unsubscribe() in onUnmounted

Memory leaks from subscriptions

Always clean up subscriptions and lists in onUnmounted:

let unsubscribe: (() => void) | null = null;

onMounted(() => {
  unsubscribe = productList.onResultChange(readResults);
});

onUnmounted(() => {
  unsubscribe?.();
  productList?.destroy();
});

Filters not reactive

Keep the filters in one reactive object and watch the object itself. A watcher on a reactive object is deep, so it fires when any field changes:

const filters = reactive<Filters>({ ... });

watch(filters, applyFilters);