.md

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

terminal
npm create vite@latest dcupl-react -- --template react-ts --no-immediate
cd dcupl-react
npm install @dcupl/core @dcupl/common
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 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:

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 {
        // 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:

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

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

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

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,
  } = 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.

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;
}

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()}`;
    // 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:

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

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. Re-render when a list's result changes (onResultChange) 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

// 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 unmounts

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