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
- A supported Node.js version, see Compatibility (
node -vto 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:
npm create vite@latest dcupl-vue -- --template vue-ts --no-immediate
cd dcupl-vue
npm install @dcupl/core @dcupl/common
npm installThe 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:
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:
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 { 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:
<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">
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:
<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:
<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:
<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:
<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:
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 - Re-read when a list's result changes (
onResultChange) for real-time updates, with proper cleanup - Create filter components that work with facets, with props down and events up
- 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: '', ... });
// 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 onUnmountedMemory 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);