Building a Real-Time Dashboard with Directive
Real-time dashboards look simple. A few numbers, a couple of charts, maybe a live feed. But underneath that surface is a coordination problem that scales poorly with imperative code.
Consider a sales analytics dashboard. It pulls historical data from a REST API, receives live transaction updates over a WebSocket, and polls a summary endpoint every 60 seconds for aggregate stats. Three data sources, each with its own loading state, error state, retry logic, and staleness window. Cross those with derived aggregations – total revenue, active users, error rate – and a single missed edge case means stale numbers on a screen that people make decisions from.
Most teams solve this with useEffect hooks scattered across components, manually tracking which source is loading, which failed, and when the last fetch happened. A WebSocket disconnects silently. A polling interval fires during a page transition. A derived value reads from a source that hasn't loaded yet.
Directive solves this with the same pattern it applies everywhere: declare what must be true, let the runtime handle how. Each data source becomes a module. The system composes them, and derivations compute aggregated views automatically.
Module Design: One Module Per Source
Each data source gets its own Directive module. Each source has independent state, independent failure modes, and independent lifecycle. Forcing them into one module would recreate the tangled imperative code we're trying to avoid.
historyModule– fetches historical sales data from a REST API on initializationliveModule– manages a WebSocket connection for real-time transaction updatespollModule– polls a summary stats endpoint on a recurring interval
They share nothing directly. Cross-module aggregation happens at the system level.
The History Module
The simplest of the three. It fetches once on load, with retry logic for transient failures:
import { createModule, t } from '@directive-run/core';
interface SaleRecord {
id: string;
amount: number;
region: string;
timestamp: number;
}
const historyModule = createModule("history", {
schema: {
facts: {
records: t.array<SaleRecord>(),
loading: t.boolean(),
error: t.string().nullable(),
fetchedAt: t.number(),
},
requirements: {
FETCH_HISTORY: t.object<{ since: number }>(),
},
},
init: (facts) => {
facts.records = [];
facts.loading = false;
facts.error = null;
facts.fetchedAt = 0;
},
derive: {
historicalRevenue: (facts) =>
facts.records.reduce((sum, r) => sum + r.amount, 0),
recordCount: (facts) => facts.records.length,
},
constraints: {
needsHistory: {
when: (facts) =>
facts.fetchedAt === 0 && !facts.loading && facts.error === null,
require: () => ({
type: "FETCH_HISTORY",
since: Date.now() - 24 * 60 * 60 * 1000,
}),
},
},
resolvers: {
fetchHistory: {
requirement: "FETCH_HISTORY",
retry: { attempts: 3, backoff: "exponential" },
timeout: 15000,
resolve: async (req, context) => {
context.facts.loading = true;
context.facts.error = null;
try {
const res = await fetch(`/api/sales/history?since=${req.since}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
context.facts.records = await res.json();
context.facts.fetchedAt = Date.now();
} catch (err) {
context.facts.error = err.message;
} finally {
context.facts.loading = false;
}
},
},
},
});
The constraint fires exactly once – when fetchedAt is zero and no request is in flight. After the resolver sets fetchedAt, it's satisfied permanently. If the fetch fails and the user clears the error, the constraint reactivates automatically.
The Live Module
WebSocket lifecycle management – connecting, disconnecting, reconnecting – is notoriously messy in imperative code. With Directive, it becomes a set of declarative rules:
interface LiveTransaction {
id: string;
amount: number;
region: string;
timestamp: number;
}
const liveModule = createModule("live", {
schema: {
facts: {
connected: t.boolean(),
shouldConnect: t.boolean(),
transactions: t.array<LiveTransaction>(),
lastMessageAt: t.number(),
reconnectCount: t.number(),
error: t.string().nullable(),
},
},
init: (facts) => {
facts.connected = false;
facts.shouldConnect = true;
facts.transactions = [];
facts.lastMessageAt = 0;
facts.reconnectCount = 0;
facts.error = null;
},
derive: {
liveRevenue: (facts) =>
facts.transactions.reduce((sum, tx) => sum + tx.amount, 0),
isStale: (facts) =>
facts.connected && facts.lastMessageAt > 0 &&
Date.now() - facts.lastMessageAt > 30000,
},
constraints: {
needsConnection: {
when: (facts) =>
facts.shouldConnect && !facts.connected && facts.error === null,
require: { type: "CONNECT_WS" },
priority: 80,
},
needsReconnect: {
when: (facts) =>
facts.shouldConnect && !facts.connected && facts.error !== null,
require: { type: "RECONNECT_WS" },
priority: 60,
},
},
resolvers: {
connect: {
requirement: "CONNECT_WS",
resolve: async (_req, context) => {
const ws = new WebSocket("wss://api.example.com/sales/live");
ws.onopen = () => {
context.facts.connected = true;
context.facts.reconnectCount = 0;
};
ws.onmessage = (event) => {
const tx: LiveTransaction = JSON.parse(event.data);
context.facts.transactions = [...context.facts.transactions, tx];
context.facts.lastMessageAt = Date.now();
};
ws.onclose = () => { context.facts.connected = false; };
ws.onerror = () => {
context.facts.connected = false;
context.facts.error = "WebSocket connection failed";
};
},
},
reconnect: {
requirement: "RECONNECT_WS",
retry: { attempts: 5, backoff: "exponential" },
resolve: async (_req, context) => {
context.facts.reconnectCount += 1;
context.facts.error = null;
// Clearing error re-activates needsConnection
},
},
},
effects: {
capBuffer: {
run: (facts) => {
if (facts.transactions.length > 500) {
facts.transactions = facts.transactions.slice(-500);
}
},
},
},
});
Two constraints handle the full lifecycle. needsConnection fires when the socket should be connected but isn't. needsReconnect fires when a previous connection failed – its resolver clears the error with exponential backoff, which cycles back to needsConnection for the actual reconnection. The effect capBuffer keeps memory bounded, trimming to the last 500 entries whenever transactions changes.
The Polling Module
The polling module refreshes summary statistics on a fixed interval. The constraint detects when data is stale and triggers a refresh:
interface SummaryStats {
activeUsers: number;
avgOrderValue: number;
errorRate: number;
topRegion: string;
}
const pollModule = createModule("poll", {
schema: {
facts: {
stats: t.object<SummaryStats>().nullable(),
loading: t.boolean(),
error: t.string().nullable(),
lastFetchAt: t.number(),
pollIntervalMs: t.number(),
},
},
init: (facts) => {
facts.stats = null;
facts.loading = false;
facts.error = null;
facts.lastFetchAt = 0;
facts.pollIntervalMs = 60000;
},
derive: {
isStale: (facts) =>
facts.lastFetchAt > 0 &&
Date.now() - facts.lastFetchAt > facts.pollIntervalMs,
secondsSinceUpdate: (facts) =>
facts.lastFetchAt > 0
? Math.floor((Date.now() - facts.lastFetchAt) / 1000)
: null,
},
constraints: {
needsInitialFetch: {
when: (facts) =>
facts.lastFetchAt === 0 && !facts.loading && facts.error === null,
require: { type: "REFRESH_STATS" },
priority: 70,
},
needsRefresh: {
when: (facts) =>
facts.lastFetchAt > 0 &&
Date.now() - facts.lastFetchAt > facts.pollIntervalMs &&
!facts.loading,
require: { type: "REFRESH_STATS" },
priority: 40,
},
},
resolvers: {
refreshStats: {
requirement: "REFRESH_STATS",
retry: { attempts: 2, backoff: "exponential" },
timeout: 10000,
resolve: async (_req, context) => {
context.facts.loading = true;
context.facts.error = null;
try {
const res = await fetch("/api/sales/summary");
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
context.facts.stats = await res.json();
context.facts.lastFetchAt = Date.now();
} catch (err) {
context.facts.error = err.message;
} finally {
context.facts.loading = false;
}
},
},
},
});
The needsRefresh constraint checks whether enough time has elapsed since the last fetch. After a successful fetch updates lastFetchAt, the constraint goes quiet until the interval elapses. No setInterval. No cleanup. No forgotten timers leaking after unmount.
Composing the System
All three modules compose into a single system with one call:
import { createSystem } from '@directive-run/core';
import { loggingPlugin } from '@directive-run/core/plugins';
const dashboard = createSystem({
modules: {
history: historyModule,
live: liveModule,
poll: pollModule,
},
plugins: [loggingPlugin({ level: "warn" })],
});
dashboard.start();
Each module's facts live under its namespace – dashboard.facts.history.records, dashboard.facts.live.transactions, dashboard.facts.poll.stats. If the WebSocket disconnects while a poll is in flight, each module handles its own situation without blocking the others.
Cross-Module Derivations and React
The real power of composition shows up in aggregated views. useSelector computes values that span module namespaces with auto-tracked dependencies:
import { useSelector, useFact, useDerived, useInspect } from '@directive-run/react';
function DashboardPage() {
const inspection = useInspect(dashboard);
return (
<div className="dashboard">
<header>
<h1>Sales Analytics</h1>
{!inspection.isSettled && <span className="syncing">Syncing...</span>}
</header>
<RevenueCard />
<LiveFeed />
<StatsPanel />
<SourceStatus />
</div>
);
}
function RevenueCard() {
const totalRevenue = useSelector(dashboard, (state) =>
state.history.records.reduce((sum, r) => sum + r.amount, 0) +
state.live.transactions.reduce((sum, tx) => sum + tx.amount, 0),
);
return (
<div className="card">
<h2>Total Revenue</h2>
<p className="value">${totalRevenue.toLocaleString()}</p>
</div>
);
}
function LiveFeed() {
const transactions = useFact(dashboard, "live.transactions");
const connected = useFact(dashboard, "live.connected");
const recent = transactions.slice(-10).reverse();
return (
<div className="card">
<h2>Live Transactions <span className={connected ? "dot green" : "dot red"} /></h2>
<ul>
{recent.map((tx) => (
<li key={tx.id}>${tx.amount.toFixed(2)} – {tx.region}</li>
))}
</ul>
</div>
);
}
function StatsPanel() {
const stats = useFact(dashboard, "poll.stats");
const secondsSince = useDerived(dashboard, "poll.secondsSinceUpdate");
if (!stats) {
return <div className="card">Loading stats...</div>;
}
return (
<div className="card">
<h2>Summary Stats</h2>
<dl>
<dt>Active Users</dt><dd>{stats.activeUsers}</dd>
<dt>Avg Order Value</dt><dd>${stats.avgOrderValue.toFixed(2)}</dd>
<dt>Error Rate</dt><dd>{(stats.errorRate * 100).toFixed(1)}%</dd>
</dl>
{secondsSince !== null && <p className="meta">Updated {secondsSince}s ago</p>}
</div>
);
}
function SourceStatus() {
const historyError = useFact(dashboard, "history.error");
const connected = useFact(dashboard, "live.connected");
const pollError = useFact(dashboard, "poll.error");
const reconnects = useFact(dashboard, "live.reconnectCount");
return (
<div className="card">
<h2>Data Sources</h2>
<div className={historyError ? "source-error" : "source-ok"}>Historical</div>
<div className={connected ? "source-ok" : "source-error"}>
Live Feed {reconnects > 0 && <span>({reconnects} reconnects)</span>}
</div>
<div className={pollError ? "source-error" : "source-ok"}>Stats</div>
</div>
);
}
Each component subscribes to exactly the facts it needs. RevenueCard re-renders when either history or live data changes. LiveFeed re-renders on new transactions. StatsPanel re-renders on poll updates.
The totalRevenue selector reads from two module namespaces and recomputes whenever either changes. When the history module is still loading, its contribution is zero. No null checks, no conditional logic based on which source loaded first.
Error Resilience
With separate modules, failures are isolated by design. If the WebSocket disconnects:
liveModuleactivates itsneedsReconnectconstraint with exponential backoffhistoryModulecontinues serving cached data from the initial fetchpollModulecontinues its polling cycle on schedule- Derivations still work – they use the last known live data plus fresh data from other sources
No module knows or cares about the others' error states. The system degrades gracefully because each module handles its own recovery. The SourceStatus component surfaces per-source health so users know exactly which data is fresh and which is reconnecting.
Getting Started
Install Directive and start building:
npm install @directive-run/core
Explore the patterns used in this tutorial:
- Multi-Module Composition – composing independent modules into a single system
- Multi-Module Example – full e-commerce example with auth, cart, and checkout
- Data Fetching Example – constraints and resolvers for async data
- Effects – fire-and-forget side effects
- React Hooks –
useSelector,useFact,useDerived, and more
If you haven't read the first article in this series, Constraint-Driven Architecture explains the paradigm from scratch.
The same reconciliation loop that manages a traffic light manages a production dashboard. Declare what must be true. Let the runtime handle the rest.
Related
- Data Fetching with Directive – The complete guide to fetching, caching, invalidation, deduplication, cancellation, batching, optimistic updates, and polling – all with constraints and resolvers.
- Directive on the Server – Distributable snapshots, signed verification, audit trails, and GDPR compliance – Directive runs on Node.js without React.
- A/B Testing with Directive – Build a complete A/B testing engine using constraints, resolvers, and effects. Deterministic assignment, exposure tracking, and variant gating – no third-party service required.
Directive is free and open source. If this was useful, consider supporting the project.

