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 tutorial
- A supported Node.js version, see Compatibility (
node -vto 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 explains.
Step 1: set up the project
Create a new project:
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:
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:
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.
// 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:
// 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():
// 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:
// 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().
const unsubscribeLog = dcupl.on((event) => {
console.log(`[Event] ${event.type}`);
});
// Call unsubscribeLog() to stop loggingStep 7: implement upsert for new items
Use data.upsert() to add new items or update existing ones:
// 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:
// 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:
// 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:
// 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:
// 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):
// 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):
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:
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:
npx tsx dashboard.tsWhat you learned
In this tutorial, you learned how to:
- Use
data.update()for partial updates that only modify changed fields - Use
data.upsert()to insert or update items in a single operation - Re-render a list with
onResultChange(), and follow the instance's events withdcupl.on() - Process batch updates efficiently
- Handle
data.remove()for deleting items - Use
data.set()for complete data replacement (use sparingly) - 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 queryCommon 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().