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:

Step 1: Create a React Project

Create a new React project with TypeScript:

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

Step 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:

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 Custom useDcupl Hook

Create a hook to manage dcupl initialization:

src/dcupl/useDcupl.ts
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:

src/dcupl/useProductList.ts
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:

src/components/ProductCard.tsx
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:

src/components/FilterSidebar.tsx
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:

src/App.tsx
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:

src/App.css
* {
  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:

src/components/AdminPanel.tsx
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:

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 React application with Vite
  2. Create a singleton dcupl instance for shared state
  3. Build custom React hooks for dcupl initialization (useDcupl)
  4. Build reactive hooks for filtered data (useProductList)
  5. Subscribe to dcupl events for real-time updates
  6. Create filter components that work with facets
  7. Handle loading and error states properly
  8. Implement real-time data modifications

Key Patterns

Singleton dcupl Instance

// instance.ts
export const dcupl = new Dcupl();
// Use the same instance everywhere

Initialization 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

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' }), []);