Building with React
In this tutorial, you will learn how to integrate dcupl with React applications. You will build a product browser with filtering, search, and real-time updates using React hooks and dcupl's reactive capabilities.
Time required: 15-20 minutes
What you will build: A React 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 React knowledge (hooks, components)
Step 1: create a React project
Create a new React project with TypeScript and install dcupl. @dcupl/common holds the TypeScript types the hooks use; --no-immediate stops create-vite from installing and starting the dev server before dcupl is installed:
npm create vite@latest dcupl-react -- --template react-ts --no-immediate
cd dcupl-react
npm install @dcupl/core @dcupl/common
npm installStep 2: set up the dcupl instance
Create a dcupl service that can be shared across your React 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: React's StrictMode runs effects twice in development, and several components can use the hook from Step 3.
Step 3: create a custom useDcupl hook
Create a hook to manage dcupl initialization:
import { useState, useEffect } from 'react';
import { dcupl, initializeDcupl } from './instance';
export function useDcupl() {
const [isInitialized, setIsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let mounted = true;
async function init() {
try {
// init() resolves false when it fails (it doesn't throw)
const success = await initializeDcupl();
if (mounted) {
setIsInitialized(success);
setIsLoading(false);
}
} catch (err) {
if (mounted) {
setError(err as Error);
setIsLoading(false);
}
}
}
init();
return () => {
mounted = false;
};
}, []);
return { dcupl, isInitialized, isLoading, error };
}Step 4: create a useProductList hook
Create a hook for managing product queries with filters. It creates one dcupl list per mounted component (and destroys it on unmount), turns the filter state into the list's query, and re-reads products, facets and price statistics whenever the list's result changes:
import { useState, useEffect, useCallback } from 'react';
import type { DcuplList } from '@dcupl/core';
import type { DcuplQuery, DcuplQueryGroup } from '@dcupl/common';
import { dcupl, type Product } from './instance';
interface Filters {
category?: string;
minPrice?: number;
maxPrice?: number;
inStock?: boolean;
searchTerm?: string;
}
interface FacetItem {
value: any;
size: number;
}
interface UseProductListResult {
products: Product[];
totalCount: number;
categoryFacets: FacetItem[];
priceStats: { min: number; max: number; avg: number };
setFilters: (filters: Filters) => void;
clearFilters: () => void;
}
export function useProductList(): UseProductListResult {
const [productList, setProductList] = useState<DcuplList<Product> | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [categoryFacets, setCategoryFacets] = useState<FacetItem[]>([]);
const [priceStats, setPriceStats] = useState({ min: 0, max: 0, avg: 0 });
const [filters, setFiltersState] = useState<Filters>({});
// Create the product list once per mount and destroy it on unmount
useEffect(() => {
const list = dcupl.lists.create<Product>({ modelKey: 'Product' });
setProductList(list);
return () => list.destroy();
}, []);
// Read the results now and whenever the list's result changes:
// after a filter change and after `await dcupl.update()` changed the data
useEffect(() => {
if (!productList) return;
const readResults = () => {
// Get filtered products
const items = productList.catalog.query.items({
sort: { attributes: ['rating'], order: ['DESC'] },
});
setProducts(items);
// Category facets count under all other active filters; the category
// selection itself is left out, and excludeZeros: false keeps categories
// with 0 matches listed, so every category stays selectable
setCategoryFacets(productList.catalog.fn.facets({ attribute: 'category', excludeZeros: false }));
// Get price statistics (missing when no product matches)
const stats = productList.catalog.fn.aggregate({
attribute: 'price',
types: ['min', 'max', 'avg'],
});
setPriceStats({ min: stats.min ?? 0, max: stats.max ?? 0, avg: stats.avg ?? 0 });
};
readResults();
return productList.onResultChange(readResults);
}, [productList]);
// Turn the filters into query groups and apply them to the list's query
useEffect(() => {
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 !== undefined) {
priceRange.push({ attribute: 'price', operator: 'gte', value: filters.minPrice });
}
if (filters.maxPrice !== undefined) {
priceRange.push({ attribute: 'price', operator: 'lte', value: filters.maxPrice });
}
if (priceRange.length > 0) {
groups.push({ groupKey: 'price', groupType: 'and', queries: priceRange });
}
// In-stock filter
if (filters.inStock !== undefined) {
groups.push({
groupKey: 'inStock',
groupType: 'and',
queries: [{ attribute: 'inStock', operator: 'eq', value: filters.inStock }],
});
}
// Search term: '/term/' matches anywhere in the name,
// regexFlags 'i' ignores case (a bare string must match the whole value)
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)
productList.catalog.query.apply({ groupKey: 'root', groupType: 'and', queries: groups }, { mode: 'set' });
}, [filters, productList]);
const setFilters = useCallback((newFilters: Filters) => {
setFiltersState(newFilters);
}, []);
const clearFilters = useCallback(() => {
setFiltersState({});
}, []);
return {
products,
totalCount: products.length,
categoryFacets,
priceStats,
setFilters,
clearFilters,
};
}dcupl runs queries synchronously in memory, so the hook needs no loading state. Each change to the query emits result_updated, and onResultChange re-reads the results. The filter effect replaces the query with one apply() call, so a filter change recomputes and re-reads the results once; clear() followed by an addCondition() or addGroup() per filter would do it once per call.
Step 5: create UI components
Create the product card component:
import type { Product } from '../dcupl/instance';
interface ProductCardProps {
product: Product;
}
export function ProductCard({ product }: ProductCardProps) {
return (
<div className="product-card">
<div className="product-image">
<img src={product.imageUrl} alt={product.name} />
{!product.inStock && <span className="out-of-stock">Out of Stock</span>}
</div>
<div className="product-info">
<h3>{product.name}</h3>
<p className="description">{product.description}</p>
<div className="meta">
<span className="category">{product.category}</span>
<span className="rating">{'★'.repeat(Math.round(product.rating))} {product.rating}</span>
</div>
<div className="price">${product.price.toFixed(2)}</div>
<div className="tags">
{product.tags.map((tag) => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
</div>
</div>
);
}Create the filter sidebar component:
import { useState } from 'react';
interface FacetItem {
value: any;
size: number;
}
interface FilterSidebarProps {
categoryFacets: FacetItem[];
priceStats: { min: number; max: number };
onFilterChange: (filters: any) => void;
onClearFilters: () => void;
}
export function FilterSidebar({
categoryFacets,
priceStats,
onFilterChange,
onClearFilters,
}: FilterSidebarProps) {
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [minPrice, setMinPrice] = useState<string>('');
const [maxPrice, setMaxPrice] = useState<string>('');
const [inStockOnly, setInStockOnly] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const handleApplyFilters = () => {
onFilterChange({
category: selectedCategory || undefined,
minPrice: minPrice ? parseFloat(minPrice) : undefined,
maxPrice: maxPrice ? parseFloat(maxPrice) : undefined,
inStock: inStockOnly || undefined,
searchTerm: searchTerm || undefined,
});
};
const handleClear = () => {
setSelectedCategory('');
setMinPrice('');
setMaxPrice('');
setInStockOnly(false);
setSearchTerm('');
onClearFilters();
};
return (
<aside className="filter-sidebar">
<h2>Filters</h2>
{/* Search */}
<div className="filter-section">
<label>Search</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
/>
</div>
{/* Categories */}
<div className="filter-section">
<label>Category</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
>
<option value="">All Categories</option>
{categoryFacets.map((facet) => (
<option key={facet.value} value={facet.value}>
{facet.value} ({facet.size})
</option>
))}
</select>
</div>
{/* Price Range */}
<div className="filter-section">
<label>Price Range (${priceStats.min} - ${priceStats.max})</label>
<div className="price-inputs">
<input
type="number"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="Min"
/>
<span>to</span>
<input
type="number"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
placeholder="Max"
/>
</div>
</div>
{/* In Stock */}
<div className="filter-section">
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
In Stock Only
</label>
</div>
{/* Actions */}
<div className="filter-actions">
<button onClick={handleApplyFilters} className="apply-btn">
Apply Filters
</button>
<button onClick={handleClear} className="clear-btn">
Clear All
</button>
</div>
</aside>
);
}Step 6: build the main app component
Create the main application component:
import { useDcupl } from './dcupl/useDcupl';
import { useProductList } from './dcupl/useProductList';
import { ProductCard } from './components/ProductCard';
import { FilterSidebar } from './components/FilterSidebar';
import './App.css';
function App() {
const { isInitialized, isLoading: dcuplLoading, error } = useDcupl();
if (dcuplLoading) {
return <div className="loading">Initializing dcupl...</div>;
}
if (error) {
return <div className="error">Error: {error.message}</div>;
}
if (!isInitialized) {
return <div className="error">Failed to initialize dcupl</div>;
}
return <ProductBrowser />;
}
function ProductBrowser() {
const {
products,
totalCount,
categoryFacets,
priceStats,
setFilters,
clearFilters,
} = useProductList();
return (
<div className="app">
<header>
<h1>Product Browser</h1>
<p>Powered by dcupl + React</p>
</header>
<main className="main-content">
<FilterSidebar
categoryFacets={categoryFacets}
priceStats={priceStats}
onFilterChange={setFilters}
onClearFilters={clearFilters}
/>
<section className="product-grid-section">
<div className="results-header">
<span>{totalCount} products found</span>
</div>
<div className="product-grid">
{products.map((product) => (
<ProductCard key={product.key} product={product} />
))}
</div>
{products.length === 0 && (
<div className="no-results">
No products match your filters. Try adjusting your criteria.
</div>
)}
</section>
</main>
</div>
);
}
export default App;Step 7: add styles
Add basic styling. The template's src/index.css centers #root and changes heading colors in dark mode, which clashes with these styles: delete the line import './index.css' from src/main.tsx.
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
background: #f5f5f5;
}
.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;
}
/* Filter Sidebar */
.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;
}
.apply-btn {
background: #000;
color: #fff;
border: none;
padding: 0.75rem;
border-radius: 4px;
cursor: pointer;
}
.clear-btn {
background: #fff;
color: #666;
border: 1px solid #ddd;
padding: 0.75rem;
border-radius: 4px;
cursor: pointer;
}
/* Product Grid */
.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;
}
.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;
}
.product-info .description {
color: #666;
font-size: 0.9rem;
margin: 0 0 0.75rem 0;
}
.product-info .meta {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.product-info .category {
background: #f0f0f0;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.product-info .rating {
color: #f5a623;
}
.product-info .price {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.product-info .tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.product-info .tag {
background: #e8e8e8;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.75rem;
color: #666;
}
.loading,
.error,
.no-results {
text-align: center;
padding: 3rem;
color: #666;
}Step 8: add real-time updates
Add a component to simulate real-time data changes:
import { useState } from 'react';
import { dcupl } from '../dcupl/instance';
export function AdminPanel() {
const [isUpdating, setIsUpdating] = useState(false);
const addProduct = async () => {
setIsUpdating(true);
const id = `p${Date.now()}`;
// upsert adds new items (data.update would ignore an unknown key)
dcupl.data.upsert(
[
{
key: id,
name: `New Product ${id.slice(-4)}`,
description: 'Just added!',
category: 'Electronics',
price: Math.floor(Math.random() * 200) + 50,
rating: Math.round((4 + Math.random()) * 10) / 10, // 4.0–5.0, one decimal
inStock: true,
tags: ['new'],
imageUrl: '/placeholder.jpg',
},
],
{ model: 'Product' }
);
// Data changes are staged until dcupl.update() applies them
await dcupl.update();
setIsUpdating(false);
};
const updateRandomPrice = async () => {
setIsUpdating(true);
// Read all products without 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();
}
setIsUpdating(false);
};
return (
<div className="admin-panel">
<h3>Admin Controls</h3>
<button onClick={addProduct} disabled={isUpdating}>
Add Random Product
</button>
<button onClick={updateRandomPrice} disabled={isUpdating}>
Update Random Price
</button>
</div>
);
}Render the panel in ProductBrowser in src/App.tsx, above the results header:
import { AdminPanel } from './components/AdminPanel';
// in ProductBrowser, first child of <section className="product-grid-section">:
<AdminPanel />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 9: 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 React application with Vite
- Create a singleton dcupl instance for shared state
- Build custom React hooks for dcupl initialization (
useDcupl) - Build reactive hooks for filtered data (
useProductList) - Re-render when a list's result changes (
onResultChange) for real-time updates - Create filter components that work with facets
- Handle loading and error states properly
- Implement real-time data modifications
Key patterns
Singleton dcupl Instance
// instance.ts
export const dcupl = new Dcupl();
// Use the same instance everywhereInitialization Hook
// Any component can call it: initializeDcupl() runs dcupl.init() only once
const { dcupl, isInitialized, isLoading, error } = useDcupl();Reactive Subscriptions
useEffect(() => {
if (!productList) return;
// Fires after query changes and after `await dcupl.update()`; returns the unsubscribe function
return productList.onResultChange(() => {
// Re-read items, facets and aggregates
});
}, [productList]);Filter State Management
const [filters, setFilters] = useState<Filters>({});
// Turn the filters into query groups and apply them in one call
productList.catalog.query.apply({ groupKey: 'root', groupType: 'and', queries: groups }, { mode: 'set' });Common issues
dcupl not initialized when component renders
Always check isInitialized before rendering components that use dcupl:
if (!isInitialized) return <Loading />;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(() => refetchData());
// call unsubscribe() when the component unmountsMultiple list instances
Every dcupl.lists.create() call registers a new list that stays alive until it is destroyed. Don't create lists during render or per query. useMemo isn't enough either: nothing destroys the list on unmount, and React Strict Mode calls the factory twice in development, which leaves an extra list behind. Create the list in an effect and destroy it in the cleanup:
const [list, setList] = useState<DcuplList<Product> | null>(null);
useEffect(() => {
const created = dcupl.lists.create<Product>({ modelKey: 'Product' });
setList(created);
return () => created.destroy();
}, []);