# 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](/learn/tutorials/first-app) tutorial
- A supported Node.js version, see [Compatibility](/sdk/compatibility#nodejs) (`node -v` to 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:

```bash filename="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:

```typescript filename="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:

```typescript filename="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:

```typescript filename="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:

```typescript filename="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:

```typescript filename="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:

```typescript filename="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`.

```css filename="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:

```typescript filename="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:

```typescript title="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:

```bash filename="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**

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

**Initialization Hook**

```typescript
// Any component can call it: initializeDcupl() runs dcupl.init() only once
const { dcupl, isInitialized, isLoading, error } = useDcupl();
```

**Reactive Subscriptions**

```typescript
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**

```typescript
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' });
```

## Next steps

- [Building with Vue](/learn/tutorials/vue-integration) — Similar patterns for Vue
- [Real-time data updates](/learn/tutorials/real-time-updates) — Deep dive into update patterns
- [Build a Product Catalog](/learn/tutorials/product-catalog) — Faceted navigation in depth
- [Lists API](/sdk/api/lists) — Typed lists, onResultChange() and destroy()
- [Query operators reference](/sdk/api/query-operators) — find with a /regex/ value and regexFlags
- [Real-time updates](/sdk/advanced/realtime-updates) — Data changes and list events in the SDK

## Common issues

**dcupl not initialized when component renders**

Always check `isInitialized` before rendering components that use dcupl:

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

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

```typescript
const [list, setList] = useState<DcuplList<Product> | null>(null);

useEffect(() => {
  const created = dcupl.lists.create<Product>({ modelKey: 'Product' });
  setList(created);
  return () => created.destroy();
}, []);
```
