# 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:

- Completed the [Build your first dcupl application](/learn/tutorials/first-app) tutorial
- A supported Node.js version, see [Compatibility](/sdk/compatibility#nodejs) (`node -v` to check)
- Basic Vue 3 knowledge (Composition API, reactivity)

## 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:

```bash filename="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:

```typescript filename="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:

```typescript filename="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:

```typescript filename="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:

```vue filename="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:

```vue filename="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:

```vue filename="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:

```vue filename="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:

```vue filename="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`:

```vue title="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:

```bash filename="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**

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

**Initialization Composable**

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

**Reactive Filters with watch**

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

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

**Result Subscription with Cleanup**

```typescript
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**

```vue
<!-- 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](/sdk/api/query-operators).

## Next steps

- [Building with React](/learn/tutorials/react-integration) — Compare with React patterns
- [Real-time data updates](/learn/tutorials/real-time-updates) — Deep dive into update patterns
- [Build a Product Catalog](/learn/tutorials/product-catalog) — Faceted navigation in depth
- [Lists API](/sdk/api/lists) — Typed lists, onResultChange() and destroy()
- [Query operators reference](/sdk/api/query-operators) — Every operator, including find with regexFlags
- [Real-time updates](/sdk/advanced/realtime-updates) — Data changes and list events in the SDK

## Common issues

**dcupl not initialized when component renders**

Always check `isInitialized` before rendering components that use dcupl:

```vue
<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:

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

**Memory leaks from subscriptions**

Always clean up subscriptions and lists in `onUnmounted`:

```typescript
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:

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

watch(filters, applyFilters);
```
