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:

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

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 function
export async function initializeDcupl(): Promise<boolean> {
  return dcupl.init();
}

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

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 { ref, reactive, watch, onMounted, onUnmounted } from 'vue';
import { dcupl, Product } from './instance';

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

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

export function useProductList() {
  const products = ref<Product[]>([]);
  const categoryFacets = ref<FacetItem[]>([]);
  const priceStats = ref({ min: 0, max: 0, avg: 0 });
  const isLoading = ref(false);

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

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

  const applyFilters = () => {
    if (!productList) return;

    isLoading.value = true;

    // Clear existing conditions
    productList.catalog.query.clear();

    // Apply category filter
    if (filters.category) {
      productList.catalog.query.addCondition({
        attribute: 'category',
        operator: 'eq',
        value: filters.category,
      });
    }

    // Apply price range using gte (greater than or equal) and lte (less than or equal)
    if (filters.minPrice !== null) {
      productList.catalog.query.addCondition({
        attribute: 'price',
        operator: 'gte',
        value: filters.minPrice,
      });
    }

    if (filters.maxPrice !== null) {
      productList.catalog.query.addCondition({
        attribute: 'price',
        operator: 'lte',
        value: filters.maxPrice,
      });
    }

    // Apply in-stock filter using eq for exact boolean match
    if (filters.inStock !== null) {
      productList.catalog.query.addCondition({
        attribute: 'inStock',
        operator: 'eq',
        value: filters.inStock,
      });
    }

    // Apply search term using includes operator
    if (filters.searchTerm) {
      productList.catalog.query.addCondition({
        attribute: 'name',
        operator: 'find',
        value: filters.searchTerm,
      });
    }

    // Get filtered products sorted by rating
    products.value = productList.catalog.query.items({
      sort: { attributes: ['rating'], order: ['desc'] },
    }) as Product[];

    // Get facets from unfiltered data for category counts
    const tempList = dcupl.lists.create({ modelKey: 'Product' });
    categoryFacets.value = tempList.catalog.fn.facets({ attribute: 'category' });

    // Get price statistics for filtered results
    const stats = productList.catalog.fn.aggregate({ attribute: 'price' });
    priceStats.value = { min: stats.min, max: stats.max, avg: stats.avg };

    isLoading.value = false;
  };

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

  // Watch for filter changes
  watch(
    () => ({ ...filters }),
    () => applyFilters(),
    { deep: true }
  );

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

    // Subscribe to dcupl updates
    unsubscribe = dcupl.on((event) => {
      if (event.type === 'dcupl_updated_manually') {
        applyFilters();
      }
    });

    // Initial fetch
    applyFilters();
  });

  onUnmounted(() => {
    if (unsubscribe) {
      unsubscribe();
    }
  });

  return {
    products,
    totalCount: () => products.value.length,
    categoryFacets,
    priceStats,
    filters,
    clearFilters,
    isLoading,
  };
}

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">
interface FacetItem {
  value: any;
  count: number;
}

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

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

const filters = defineModel<Filters>('filters', { required: true });
const emit = defineEmits<{
  (e: 'clear'): void;
}>();

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

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

    <!-- Search -->
    <div class="filter-section">
      <label>Search</label>
      <input
        v-model="filters.searchTerm"
        type="text"
        placeholder="Search products..."
      />
    </div>

    <!-- Categories -->
    <div class="filter-section">
      <label>Category</label>
      <select v-model="filters.category">
        <option value="">All Categories</option>
        <option
          v-for="facet in categoryFacets"
          :key="facet.value"
          :value="facet.value"
        >
          {{ facet.value }} ({{ facet.count }})
        </option>
      </select>
    </div>

    <!-- Price Range -->
    <div class="filter-section">
      <label>Price Range (${{ priceStats.min }} - ${{ priceStats.max }})</label>
      <div class="price-inputs">
        <input
          v-model.number="filters.minPrice"
          type="number"
          placeholder="Min"
        />
        <span>to</span>
        <input
          v-model.number="filters.maxPrice"
          type="number"
          placeholder="Max"
        />
      </div>
    </div>

    <!-- In Stock -->
    <div class="filter-section">
      <label>
        <input
          v-model="filters.inStock"
          type="checkbox"
          :true-value="true"
          :false-value="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>

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 { useProductList } from './dcupl/useProductList';
import ProductCard from './components/ProductCard.vue';
import FilterSidebar from './components/FilterSidebar.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';
import AdminPanel from './AdminPanel.vue';

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

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

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

      <section class="product-grid-section">
        <div class="results-header">
          <span>{{ totalCount() }} products found</span>
          <span v-if="isLoading" class="loading-indicator">Loading...</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>

        <AdminPanel />
      </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;
}

.loading-indicator {
  color: #999;
  font-size: 0.9rem;
}
</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;

  const list = dcupl.lists.create({ modelKey: 'Product' });
  const products = list.catalog.query.items();
  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 list = dcupl.lists.create({ modelKey: 'Product' });
  const products = list.catalog.query.items();
  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>

Update App.vue to use the ProductBrowser 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>

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. Subscribe to dcupl events for real-time updates with proper cleanup
  7. Create filter components that work with facets using v-model
  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: '', ... });

watch(
  () => ({ ...filters }),
  () => applyFilters(),
  { deep: true }
);

Event Subscription with Cleanup

onMounted(() => {
  unsubscribe = dcupl.on((event) => {
    if (event.type === 'dcupl_updated_manually') {
      applyFilters();
    }
  });
});

onUnmounted(() => {
  if (unsubscribe) unsubscribe();
});

Two-way Binding with defineModel

const filters = defineModel<Filters>('filters', { required: true });

Query Operators Used

Operator Purpose Example
eq Exact match { attribute: 'category', operator: 'eq', value: 'Electronics' }
gte Greater than or equal { attribute: 'price', operator: 'gte', value: 100 }
lte Less than or equal { attribute: 'price', operator: 'lte', value: 500 }
find Substring match { attribute: 'name', operator: 'find', value: 'Phone' }

Next Steps

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

Make sure to subscribe to dcupl events and refresh data when updates occur:

dcupl.on((event) => {
  if (event.type === 'dcupl_updated_manually') {
    applyFilters();
  }
});

Memory leaks from subscriptions

Always clean up subscriptions in onUnmounted:

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

onMounted(() => {
  unsubscribe = dcupl.on(callback);
});

onUnmounted(() => {
  if (unsubscribe) unsubscribe();
});

Filters not reactive

Use reactive for object filters and spread when watching:

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

watch(
  () => ({ ...filters }), // Spread to trigger on nested changes
  () => applyFilters(),
  { deep: true }
);