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
- Node.js 18 or higher installed
- Basic React knowledge (hooks, components)
Step 1: Create a React Project
Create a new React project with TypeScript:
npm create vite@latest dcupl-react -- --template react-ts
cd dcupl-react
npm install @dcupl/core
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 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 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 {
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:
import { useState, useEffect, useCallback, useMemo } from 'react';
import { dcupl, Product } from './instance';
interface Filters {
category?: string;
minPrice?: number;
maxPrice?: number;
inStock?: boolean;
searchTerm?: string;
}
interface FacetItem {
value: any;
count: number;
}
interface UseProductListResult {
products: Product[];
totalCount: number;
categoryFacets: FacetItem[];
priceStats: { min: number; max: number; avg: number };
setFilters: (filters: Filters) => void;
clearFilters: () => void;
isLoading: boolean;
}
export function useProductList(): UseProductListResult {
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>({});
const [isLoading, setIsLoading] = useState(false);
// Create or get the product list
const productList = useMemo(() => {
return dcupl.lists.create({ modelKey: 'Product' });
}, []);
// Apply filters and fetch data
const applyFilters = useCallback(() => {
setIsLoading(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
if (filters.minPrice !== undefined) {
productList.catalog.query.addCondition({
attribute: 'price',
operator: 'gte',
value: filters.minPrice,
});
}
if (filters.maxPrice !== undefined) {
productList.catalog.query.addCondition({
attribute: 'price',
operator: 'lte',
value: filters.maxPrice,
});
}
// Apply in-stock filter
if (filters.inStock !== undefined) {
productList.catalog.query.addCondition({
attribute: 'inStock',
operator: 'eq',
value: filters.inStock,
});
}
// Apply search term
if (filters.searchTerm) {
productList.catalog.query.addCondition({
attribute: 'name',
operator: 'find',
value: filters.searchTerm,
});
}
// Get filtered products
const items = productList.catalog.query.items({
sort: { attributes: ['rating'], order: ['desc'] },
}) as Product[];
setProducts(items);
// Get facets (on unfiltered data for category counts)
const tempList = dcupl.lists.create({ modelKey: 'Product' });
const facets = tempList.catalog.fn.facets({ attribute: 'category' });
setCategoryFacets(facets);
// Get price statistics
const stats = productList.catalog.fn.aggregate({ attribute: 'price' });
setPriceStats({ min: stats.min, max: stats.max, avg: stats.avg });
setIsLoading(false);
}, [filters, productList]);
// Subscribe to dcupl updates
useEffect(() => {
const unsubscribe = dcupl.on((event) => {
if (event.type === 'dcupl_updated_manually') {
applyFilters();
}
});
// Initial fetch
applyFilters();
return () => {
unsubscribe();
};
}, [applyFilters]);
const setFilters = useCallback((newFilters: Filters) => {
setFiltersState(newFilters);
}, []);
const clearFilters = useCallback(() => {
setFiltersState({});
}, []);
return {
products,
totalCount: products.length,
categoryFacets,
priceStats,
setFilters,
clearFilters,
isLoading,
};
}Step 5: Create UI Components
Create the product card component:
import { 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;
count: 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.count})
</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,
isLoading,
} = 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>
{isLoading && <span className="loading-indicator">Loading...</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:
* {
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;
}
.loading-indicator {
color: #999;
font-size: 0.9rem;
}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()}`;
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();
setIsUpdating(false);
};
const updateRandomPrice = async () => {
setIsUpdating(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();
}
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>
);
}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) - Subscribe to dcupl events 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
const { dcupl, isInitialized, isLoading, error } = useDcupl();Reactive Subscriptions
useEffect(() => {
const unsubscribe = dcupl.on((event) => {
if (event.type === 'dcupl_updated_manually') {
// Refetch data
}
});
return () => unsubscribe();
}, []);Filter State Management
const [filters, setFilters] = useState<Filters>({});
// Apply filters to dcupl list
productList.catalog.query.addCondition({ ... });Next Steps
- Building with Vue - Similar patterns for Vue
- 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:
if (!isInitialized) return <Loading />;Stale data after updates
Make sure to subscribe to dcupl events and refetch data when updates occur:
dcupl.on((event) => {
if (event.type === 'dcupl_updated_manually') {
refetchData();
}
});Multiple list instances
Use useMemo to ensure the same list instance is reused:
const list = useMemo(() => dcupl.lists.create({ modelKey: 'Product' }), []);