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

- 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)
- Understanding of async/await patterns

## 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](/sdk/advanced/realtime-updates) explains.

## Step 1: set up the project

Create a new project:

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

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

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

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

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

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

```typescript title="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()`.

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

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

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

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

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

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

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

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

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

```bash filename="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](/sdk/api/data#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

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

## Next steps

- [Build a Product Catalog](/learn/tutorials/product-catalog) — Build faceted navigation with live facet counts
- [Building with React](/learn/tutorials/react-integration) — Integrate real-time updates with React
- [Building with Vue](/learn/tutorials/vue-integration) — Integrate real-time updates with Vue
- [Real-time updates](/sdk/advanced/realtime-updates) — List events, upserts, removals and batching
- [Data API](/sdk/api/data) — Every data call, staged until dcupl.update() applies it
- [Events reference](/sdk/api/events) — The events dcupl.on() and list.on() deliver
- [Lists API](/sdk/api/lists) — Scoped lists, onResultChange() and auto-update

## 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.

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

```typescript
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()`.
