.md

Real-time data updates

In this tutorial, you will learn how to handle real-time data updates in dcupl applications. You will build a live dashboard that reflects data changes instantly, using partial updates for performance and event subscriptions for reactivity.

Time required: 15-20 minutes

What you will build: A live inventory dashboard that updates in real-time as stock levels change, prices update, and new products are added.

Prerequisites

Before starting, make sure you have:

Why real-time updates matter

Stock levels, prices and live feeds change while the app runs; dcupl applies such changes as partial updates, notifies subscribers through events and lets query-scoped lists pick up new matches, as the real-time updates guide explains.

Step 1: set up the project

Create a new project:

terminal
mkdir realtime-dashboard
cd realtime-dashboard
npm init -y
npm install @dcupl/core @dcupl/common

@dcupl/common holds the TypeScript types that Step 12 imports.

Create the main application file:

dashboard.ts
import { Dcupl } from '@dcupl/core';

async function main() {
  const dcupl = new Dcupl();

  // We will build the real-time dashboard here
}

main();

Step 2: define the inventory model

Create a model for tracking product inventory:

dashboard.ts
import { Dcupl } from '@dcupl/core';

// The item type for query results
type InventoryItem = {
  key: string;
  sku: string;
  name: string;
  category: string;
  price: number;
  stock: number;
  lowStockThreshold: number;
  lastUpdated: string;
};

async function main() {
  const dcupl = new Dcupl();

  // Define Inventory model
  dcupl.models.set({
    key: 'Inventory',
    properties: [
      { key: 'sku', type: 'string' },
      { key: 'name', type: 'string' },
      { key: 'category', type: 'string' },
      { key: 'price', type: 'float' },
      { key: 'stock', type: 'int' },
      { key: 'lowStockThreshold', type: 'int' },
      { key: 'lastUpdated', type: 'string' },
    ],
  });

  console.log('Inventory model defined');
}

main();

The InventoryItem type is for TypeScript: Step 4 passes it to dcupl.lists.create<InventoryItem>(), so every read from the list is typed. one() returns null for a key the list doesn't have, which Step 5 checks.

Step 3: load initial data

Add initial inventory data after dcupl.models.set(), inside main(). Steps 4 to 12 add more code inside main(), each below the previous step.

dashboard.ts: add inside main()
// Load initial inventory
dcupl.data.set(
  [
    {
      key: 'inv1',
      sku: 'LAPTOP-001',
      name: 'MacBook Pro 14"',
      category: 'Electronics',
      price: 1999.99,
      stock: 25,
      lowStockThreshold: 10,
      lastUpdated: new Date().toISOString(),
    },
    {
      key: 'inv2',
      sku: 'LAPTOP-002',
      name: 'Dell XPS 15',
      category: 'Electronics',
      price: 1499.99,
      stock: 18,
      lowStockThreshold: 8,
      lastUpdated: new Date().toISOString(),
    },
    {
      key: 'inv3',
      sku: 'PHONE-001',
      name: 'iPhone 15 Pro',
      category: 'Electronics',
      price: 1199.99,
      stock: 45,
      lowStockThreshold: 20,
      lastUpdated: new Date().toISOString(),
    },
    {
      key: 'inv4',
      sku: 'CHAIR-001',
      name: 'Ergonomic Office Chair',
      category: 'Furniture',
      price: 399.99,
      stock: 12,
      lowStockThreshold: 5,
      lastUpdated: new Date().toISOString(),
    },
    {
      key: 'inv5',
      sku: 'DESK-001',
      name: 'Standing Desk',
      category: 'Furniture',
      price: 599.99,
      stock: 4,
      lowStockThreshold: 5,
      lastUpdated: new Date().toISOString(),
    },
    {
      key: 'inv6',
      sku: 'HEADPHONES-001',
      name: 'Sony WH-1000XM5',
      category: 'Electronics',
      price: 349.99,
      stock: 32,
      lowStockThreshold: 15,
      lastUpdated: new Date().toISOString(),
    },
  ],
  { model: 'Inventory' },
);

await dcupl.init();
console.log('Initial inventory loaded');

Step 4: create the inventory list

Create a list to query inventory data:

dashboard.ts: add inside main()
// Create inventory list
const inventoryList = dcupl.lists.create<InventoryItem>({
  modelKey: 'Inventory',
});

// Display dashboard
function displayDashboard() {
  console.clear();
  console.log('===========================================');
  console.log('         INVENTORY DASHBOARD');
  console.log('===========================================\n');

  const items = inventoryList.catalog.query.items({
    sort: { attributes: ['stock'], order: ['ASC'] },
  });

  console.log('SKU            | Name                    | Stock | Price    | Status');
  console.log('---------------|-------------------------|-------|----------|--------');

  items.forEach((item) => {
    const status = item.stock <= item.lowStockThreshold ? 'LOW' : 'OK';
    const statusColor = status === 'LOW' ? '!!' : '  ';
    console.log(
      `${item.sku.padEnd(14)} | ` +
        `${item.name.slice(0, 23).padEnd(23)} | ` +
        `${String(item.stock).padStart(5)} | ` +
        `$${item.price.toFixed(2).padStart(7)} | ` +
        `${statusColor}${status}`,
    );
  });

  // Summary stats
  const totalStock = items.reduce((sum, i) => sum + i.stock, 0);
  const lowStockCount = items.filter((i) => i.stock <= i.lowStockThreshold).length;
  const totalValue = items.reduce((sum, i) => sum + i.stock * i.price, 0);

  console.log('\n-------------------------------------------');
  console.log(`Total Items: ${items.length} | Total Stock: ${totalStock} | Low Stock Alerts: ${lowStockCount}`);
  console.log(`Total Inventory Value: $${totalValue.toLocaleString()}`);
  console.log('-------------------------------------------\n');
}

displayDashboard();

Step 5: implement partial updates with data.update()

The data.update() method updates existing items without replacing all data. When only some of the items change, dcupl.update() then reprocesses just those items, which is faster than replacing the model with data.set():

dashboard.ts: add inside main()
// Simulate a sale - reduce stock for one item
async function processSale(itemKey: string, quantity: number) {
  const item = inventoryList.catalog.query.one({ itemKey });
  if (!item) {
    console.log(`Item ${itemKey} not found`);
    return;
  }

  const newStock = Math.max(0, item.stock - quantity);

  // Use data.update() for partial update
  dcupl.data.update(
    [
      {
        key: itemKey,
        stock: newStock,
        lastUpdated: new Date().toISOString(),
      },
    ],
    { model: 'Inventory' },
  );

  // Process the update
  await dcupl.update();

  console.log(`Sale processed: ${item.name} - Sold ${quantity}, New stock: ${newStock}`);
}

// Test a sale
console.log('\n--- Processing Sale ---');
await processSale('inv1', 3);
displayDashboard();

Like every data call, data.update() is staged: nothing changes until await dcupl.update() applies it. It only changes items that already exist. Keys it does not know are silently ignored, so use data.upsert() (Step 7) to add items. Every attribute you send replaces the stored value, attributes you leave out keep theirs.

Step 6: subscribe to data changes with on()

Subscribe to the list's result to re-render the dashboard whenever the inventory changes:

dashboard.ts: add inside main()
// Re-render whenever the list's result changes; returns an unsubscribe function
const unsubscribe = inventoryList.onResultChange(() => {
  console.log('Inventory changed - refreshing dashboard...');
  displayDashboard();
});

// Now updates will trigger the callback
console.log('\n--- Subscribing to changes ---');
console.log('Dashboard will auto-refresh on data changes\n');

list.onResultChange() fires when the list's query changes and once per dcupl.update() that can have changed the list, never for reads like items() or fn.facets(). It is the hook for re-rendering one list.

To follow the whole instance, for example for logging, subscribe with dcupl.on(). It receives the instance's global events, for example data_change as soon as a data.* call stages a change, dcupl_partial_update while dcupl.update() applies staged changes, and dcupl_updated_manually when a dcupl.update() has finished. A list's result_updated goes only to list.on(), not to dcupl.on().

Optional: log every instance event
const unsubscribeLog = dcupl.on((event) => {
  console.log(`[Event] ${event.type}`);
});
// Call unsubscribeLog() to stop logging

Step 7: implement upsert for new items

Use data.upsert() to add new items or update existing ones:

dashboard.ts: add inside main()
// Add new inventory or update existing
async function upsertInventory(item: any) {
  dcupl.data.upsert([item], { model: 'Inventory' });
  await dcupl.update();
  console.log(`Upserted: ${item.name}`);
}

// Add a new product
console.log('\n--- Adding New Product ---');
await upsertInventory({
  key: 'inv7',
  sku: 'MONITOR-001',
  name: '4K Ultra Monitor',
  category: 'Electronics',
  price: 699.99,
  stock: 15,
  lowStockThreshold: 5,
  lastUpdated: new Date().toISOString(),
});

Step 8: implement batch updates

Update multiple items at once: one dcupl.update() for the whole batch is cheaper than one per item:

dashboard.ts: add inside main()
// Batch price update (e.g., 10% discount on all electronics)
async function applyDiscount(category: string, discountPercent: number) {
  const items = inventoryList.catalog.query.items();
  const categoryItems = items.filter((i) => i.category === category);

  const updates = categoryItems.map((item) => ({
    key: item.key,
    price: Number((item.price * (1 - discountPercent / 100)).toFixed(2)),
    lastUpdated: new Date().toISOString(),
  }));

  if (updates.length > 0) {
    dcupl.data.update(updates, { model: 'Inventory' });
    await dcupl.update();
    console.log(`Applied ${discountPercent}% discount to ${updates.length} ${category} items`);
  }
}

console.log('\n--- Applying Batch Discount ---');
await applyDiscount('Electronics', 10);

Step 9: handle stock replenishment

Simulate receiving new inventory:

dashboard.ts: add inside main()
// Replenish stock for low items
async function replenishLowStock() {
  const items = inventoryList.catalog.query.items();
  const lowStockItems = items.filter((i) => i.stock <= i.lowStockThreshold);

  if (lowStockItems.length === 0) {
    console.log('No items need replenishment');
    return;
  }

  const updates = lowStockItems.map((item) => ({
    key: item.key,
    stock: item.stock + 50, // Add 50 units
    lastUpdated: new Date().toISOString(),
  }));

  dcupl.data.update(updates, { model: 'Inventory' });
  await dcupl.update();

  console.log(`Replenished ${updates.length} low-stock items (+50 each)`);
}

console.log('\n--- Checking for Low Stock ---');
await replenishLowStock();

Step 10: implement real-time simulation

Create a simulation that mimics real-world inventory changes:

dashboard.ts: add inside main()
// Simulate real-time inventory changes
function simulateRealTimeUpdates() {
  const operations = [
    async () => {
      // Random sale
      const items = inventoryList.catalog.query.items();
      const randomItem = items[Math.floor(Math.random() * items.length)];
      const qty = Math.floor(Math.random() * 5) + 1;
      await processSale(randomItem.key, qty);
    },
    async () => {
      // Random price change
      const items = inventoryList.catalog.query.items();
      const randomItem = items[Math.floor(Math.random() * items.length)];
      const priceChange = (Math.random() - 0.5) * 50; // +/- $25
      dcupl.data.update(
        [
          {
            key: randomItem.key,
            price: Number((randomItem.price + priceChange).toFixed(2)),
            lastUpdated: new Date().toISOString(),
          },
        ],
        { model: 'Inventory' },
      );
      await dcupl.update();
      console.log(`Price updated: ${randomItem.name}`);
    },
    async () => {
      // Stock arrival
      const items = inventoryList.catalog.query.items();
      const randomItem = items[Math.floor(Math.random() * items.length)];
      const arrival = Math.floor(Math.random() * 20) + 5;
      dcupl.data.update(
        [
          {
            key: randomItem.key,
            stock: randomItem.stock + arrival,
            lastUpdated: new Date().toISOString(),
          },
        ],
        { model: 'Inventory' },
      );
      await dcupl.update();
      console.log(`Stock arrival: ${randomItem.name} +${arrival}`);
    },
  ];

  // Run random operation every 2 seconds
  const interval = setInterval(async () => {
    const operation = operations[Math.floor(Math.random() * operations.length)];
    await operation();
  }, 2000);

  return () => clearInterval(interval);
}

// Start simulation
console.log('\n--- Starting Real-time Simulation ---');
console.log('Updates will occur every 2 seconds...\n');
const stopSimulation = simulateRealTimeUpdates();

// Stop after 10 seconds
setTimeout(() => {
  stopSimulation();
  console.log('\n--- Simulation Stopped ---');

  // Cleanup subscription
  unsubscribe();
  console.log('Unsubscribed from events');
}, 10000);

Step 11: implement remove and reset

Handle item removal and full data reset:

dashboard.ts: add inside main()
// Remove discontinued items
async function removeItem(itemKey: string) {
  dcupl.data.remove([{ key: itemKey }], { model: 'Inventory' });
  await dcupl.update();
  console.log(`Removed item: ${itemKey}`);
}

// Reset all inventory (e.g., end of year)
async function resetInventory(newData: any[]) {
  // data.set() replaces ALL items of the model and makes update() rebuild everything - use sparingly
  dcupl.data.set(newData, { model: 'Inventory' });
  await dcupl.update();
  console.log('Inventory completely reset');
}

To empty a model, call dcupl.data.reset({ model: 'Inventory' }). Like data.set([]) it is staged until await dcupl.update(), which then rebuilds everything.

Step 12: enable auto-updating lists

Every await dcupl.update() drops the cached results of all affected lists, so a plain list like inventoryList already shows new, changed and removed items on its next query. A scoped list, created with listItemKeys or a query, keeps the key set it got at creation: after dcupl.update() it serves those items' current values and drops removed ones, but an item that newly matches a query scope doesn't join. Auto-update matters for query-scoped lists: with autoUpdate, the list re-runs its scope query on every dcupl.update(), so new matching items join it (a listItemKeys list keeps exactly its keys either way):

dashboard.ts: add inside main()
// Add to the imports at the top of dashboard.ts:
// import type { DcuplListOptions } from '@dcupl/common';

// Option 1: Enable auto-update for a specific scoped list.
// autoUpdate is missing from the create() parameter type,
// so pass the options as a DcuplListOptions variable
const electronicsOptions: DcuplListOptions = {
  modelKey: 'Inventory',
  query: {
    modelKey: 'Inventory',
    queries: [{ attribute: 'category', operator: 'eq', value: 'Electronics' }],
  },
  autoUpdate: true, // re-run the scope query on every dcupl.update()
};
const electronicsList = dcupl.lists.create(electronicsOptions);

// Option 2: Override auto-update behavior per update() call
await dcupl.update({ autoUpdateLists: true }); // Re-derive every affected list, even without autoUpdate
await dcupl.update({ autoUpdateLists: false }); // Re-derive no list (cached results are still dropped)

To turn auto-update on for every list of an instance, set it when you create the instance (not part of this dashboard):

Enable auto-update for all lists
const dcupl = new Dcupl({
  performance: {
    autoUpdateLists: {
      enabled: true, // All lists auto-update by default
    },
  },
});

Condensed version

Here is a condensed version of the dashboard in one file. It keeps the model, the data, the list subscription, a sale, an upsert and a batch price update, and leaves out the stock replenishment, the simulation, remove/reset and the auto-updating list:

dashboard.ts
import { Dcupl } from '@dcupl/core';

type InventoryItem = {
  key: string;
  sku: string;
  name: string;
  category: string;
  price: number;
  stock: number;
  lowStockThreshold: number;
  lastUpdated: string;
};

async function main() {
  const dcupl = new Dcupl();

  // Define model
  dcupl.models.set({
    key: 'Inventory',
    properties: [
      { key: 'sku', type: 'string' },
      { key: 'name', type: 'string' },
      { key: 'category', type: 'string' },
      { key: 'price', type: 'float' },
      { key: 'stock', type: 'int' },
      { key: 'lowStockThreshold', type: 'int' },
      { key: 'lastUpdated', type: 'string' },
    ],
  });

  // Load data
  dcupl.data.set(
    [
      {
        key: 'inv1',
        sku: 'LAPTOP-001',
        name: 'MacBook Pro 14"',
        category: 'Electronics',
        price: 1999.99,
        stock: 25,
        lowStockThreshold: 10,
        lastUpdated: new Date().toISOString(),
      },
      {
        key: 'inv2',
        sku: 'LAPTOP-002',
        name: 'Dell XPS 15',
        category: 'Electronics',
        price: 1499.99,
        stock: 18,
        lowStockThreshold: 8,
        lastUpdated: new Date().toISOString(),
      },
      {
        key: 'inv3',
        sku: 'PHONE-001',
        name: 'iPhone 15 Pro',
        category: 'Electronics',
        price: 1199.99,
        stock: 45,
        lowStockThreshold: 20,
        lastUpdated: new Date().toISOString(),
      },
      {
        key: 'inv4',
        sku: 'CHAIR-001',
        name: 'Ergonomic Office Chair',
        category: 'Furniture',
        price: 399.99,
        stock: 12,
        lowStockThreshold: 5,
        lastUpdated: new Date().toISOString(),
      },
      {
        key: 'inv5',
        sku: 'DESK-001',
        name: 'Standing Desk',
        category: 'Furniture',
        price: 599.99,
        stock: 4,
        lowStockThreshold: 5,
        lastUpdated: new Date().toISOString(),
      },
    ],
    { model: 'Inventory' },
  );

  await dcupl.init();
  const inventoryList = dcupl.lists.create<InventoryItem>({ modelKey: 'Inventory' });

  // Display function
  function displayDashboard() {
    console.log('\n=== INVENTORY DASHBOARD ===\n');
    const items = inventoryList.catalog.query.items({
      sort: { attributes: ['stock'], order: ['ASC'] },
    });
    items.forEach((item) => {
      const status = item.stock <= item.lowStockThreshold ? '[LOW]' : '[OK] ';
      console.log(`${status} ${item.name}: ${item.stock} units @ $${item.price}`);
    });
    console.log(`\nTotal: ${items.length} products, ${items.reduce((s, i) => s + i.stock, 0)} units`);
  }

  // Re-render whenever the list's result changes
  const unsubscribe = inventoryList.onResultChange(() => displayDashboard());

  // Initial display
  displayDashboard();

  // Demonstrate updates
  console.log('\n--- Simulating sale of 5 MacBooks ---');
  dcupl.data.update([{ key: 'inv1', stock: 20, lastUpdated: new Date().toISOString() }], { model: 'Inventory' });
  await dcupl.update();

  console.log('\n--- Adding new product ---');
  dcupl.data.upsert(
    [
      {
        key: 'inv6',
        sku: 'MONITOR-001',
        name: '4K Monitor',
        category: 'Electronics',
        price: 699.99,
        stock: 15,
        lowStockThreshold: 5,
        lastUpdated: new Date().toISOString(),
      },
    ],
    { model: 'Inventory' },
  );
  await dcupl.update();

  console.log('\n--- Batch price update (10% off Electronics) ---');
  const electronics = inventoryList.catalog.query.items().filter((i) => i.category === 'Electronics');
  const priceUpdates = electronics.map((i) => ({ key: i.key, price: Number((i.price * 0.9).toFixed(2)) }));
  dcupl.data.update(priceUpdates, { model: 'Inventory' });
  await dcupl.update();

  // Cleanup
  unsubscribe();
}

main();

Run it:

terminal
npx tsx dashboard.ts

What you learned

In this tutorial, you learned how to:

  1. Use data.update() for partial updates that only modify changed fields
  2. Use data.upsert() to insert or update items in a single operation
  3. Re-render a list with onResultChange(), and follow the instance's events with dcupl.on()
  4. Process batch updates efficiently
  5. Handle data.remove() for deleting items
  6. Use data.set() for complete data replacement (use sparingly)
  7. Configure auto-updating lists for automatic synchronization

Data methods summary

What each data method stages and when dcupl.update() rebuilds fully instead of partially is in the Data API overview. In this one-model dashboard every change touches all models, so the batch discount in Step 8 (5 of 7 items) is a full rebuild and a single sale is a partial one.

Update workflow

// 1. Make data changes (staged, not applied yet)
dcupl.data.update([...], { model: 'MyModel' });
dcupl.data.upsert([...], { model: 'MyModel' });
dcupl.data.remove([...], { model: 'MyModel' });
dcupl.data.reset({ model: 'OtherModel' }); // also staged

// 2. Apply all changes at once
await dcupl.update();

// 3. Event subscribers are notified (dcupl.on, list.onResultChange)
// 4. Lists serve the new data on their next query;
//    query-scoped lists with autoUpdate also re-run their seed query

Common issues

Updates not reflecting in UI

Make sure you call await dcupl.update() after making data changes. Data modifications are staged until you call update().

Slow performance with large updates

Use partial updates (data.update()) instead of full replacement (data.set()) whenever possible. For batch updates, combine multiple changes into a single data.update() call and apply them with one dcupl.update().

Event handler called multiple times

Each call to on() creates a new subscription. Store the unsubscribe function and call it when the component unmounts or before creating a new subscription.

// Store reference
const unsubscribe = dcupl.on(callback);

// Later: cleanup
unsubscribe();

List not updating automatically

await dcupl.update() drops the cached query results of every affected list, so plain lists serve the new data on the next query without any extra call. If new matching items don't show up in a list, it is a list scoped with a query: it serves current values but keeps the key set its query selected at creation until the query runs again. Enable auto-update when creating it (a listItemKeys list always serves the items that exist for its keys, with or without auto-update):

import type { DcuplListOptions } from '@dcupl/common';

// autoUpdate is missing from the create() parameter type,
// so pass the options as a DcuplListOptions variable
const options: DcuplListOptions = {
  modelKey: 'MyModel',
  query: {
    modelKey: 'MyModel',
    queries: [{ attribute: 'status', operator: 'eq', value: 'active' }],
  },
  autoUpdate: true,
};
const list = dcupl.lists.create(options);

Or manually call list.update() after dcupl.update(). list.update() emits list_updated, not result_updated: an onResultChange() handler has already run during dcupl.update() with the old item set, so re-read the list yourself after list.update().