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 tutorial
- Node.js 18 or higher installed
- Basic Vue 3 knowledge (Composition API, reactivity)
Step 1: Create a Vue Project
Create a new Vue 3 project with TypeScript:
npm create vite@latest dcupl-vue -- --template vue-ts
cd dcupl-vue
npm install @dcupl/core
npm installStep 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:
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:
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:
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:
<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:
<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:
<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:
<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:
<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:
<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:
npm run devOpen your browser to http://localhost:5173 to see the product browser.
What You Learned
In this tutorial, you learned how to:
- Set up dcupl in a Vue 3 application with Vite
- Create a singleton dcupl instance for shared state
- Build Vue composables for dcupl initialization (
useDcupl) - Build reactive composables for filtered data (
useProductList) - Use Vue's
watchto react to filter changes - Subscribe to dcupl events for real-time updates with proper cleanup
- Create filter components that work with facets using
v-model - Handle loading and error states properly
- Implement real-time data modifications with visual feedback
Key Patterns
Singleton dcupl Instance
// instance.ts
export const dcupl = new Dcupl();
// Use the same instance everywhereInitialization 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
- Building with React - Compare with React patterns
- Real-time Data Updates - Deep dive into update patterns
- Implementing Faceted Search - Advanced filtering
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 }
);