Real-time updates with durable streams
In chapter 4 the table was live on the client — it refetched and mutated without a navigation.
Now you make it live from the server: a durable StreamDB pushes change events to the browser, and a
useLiveQuery hook re-renders rows the instant their state changes. This is the payoff of the whole
track: a table that updates by itself.
- 1 · Scaffold
- 2 · Contract to service
- 3 · Cache-first query
- 4 · definePage + island
- 5 · Live stream
- 6 · Deploy
What you will build
A live monitor island that subscribes to a durable StreamDB and renders rows that update in
real time. You will open a StreamDB handle pointed at the streams runtime on port 4437, drive a
table with useLiveQuery, and seed the island from the server so the first paint is instant. The
worked example is the sagas stream — the durable change-stream the showcase ships and the same
mechanism your order dashboard uses to go live.
Before you begin
You should have completed chapter 4: the
orders page rendering through definePage with a hydrated QueryIsland. The live layer needs the
streams runtime reachable. With aspire start up, confirm it answers on 4437:
curl http://localhost:4437/health
A healthy response means the durable-streams producer runtime is live. If the port is dead, the
sagas plugin (which brings the stream) is not installed or Aspire has not finished booting it — check
the dashboard resource list at :18888.
Step 1 — Open a StreamDB handle
createSagasStreamDB from @plugins/sagas/streams opens a typed StreamDB client against the streams
runtime. You give it the runtime's baseUrl; it gives you typed collections you can query. Build
it inside the island, memoized on the URL, and manage its lifecycle:
// apps/dashboard/islands/SagasLiveIsland.tsx (the StreamDB handle)
import { useEffect, useMemo } from 'preact/hooks';
import { createSagasStreamDB, type SagaInstance } from '@plugins/sagas/streams';
function SagasLiveInner(props: { streamsBaseUrl: string }) {
const sagasDb = useMemo(
() => createSagasStreamDB({ baseUrl: props.streamsBaseUrl }),
[props.streamsBaseUrl],
);
// Preload the stream on mount; close it on unmount.
useEffect(() => {
void sagasDb.preload();
return () => sagasDb.close();
}, [sagasDb]);
// … useLiveQuery below
}
preload() warms the stream so the first frame has data; close() tears the subscription down when
the island unmounts. Always pair them — a leaked subscription keeps a connection open.
Step 2 — Drive a table with useLiveQuery
useLiveQuery from @netscript/fresh/query runs a query against a StreamDB collection and
re-renders whenever the underlying data changes — no polling, no manual refetch. Query the
sagaInstance collection:
// apps/dashboard/islands/SagasLiveIsland.tsx (the live query)
import { useLiveQuery } from '@netscript/fresh/query';
const { data: instanceRows = [] } = useLiveQuery(
(query) => query.from({ instance: sagasDb.collections.sagaInstance }),
[sagasDb],
);
const instances = instanceRows as SagaInstance[];
// Render `instances` as a table — each row updates the moment its saga advances.
The callback shape is a tiny query builder: query.from({ instance: <collection> }) selects rows
from the sagaInstance collection. When the server pushes a change for any of those rows,
useLiveQuery returns the new array and the table re-renders. That is the entire real-time path on
the client.
Step 3 — Seed the island from the server
Real time should not mean a blank first paint. Seed the island on the server: resolve the streams
URL, pre-warm the SDK cache, and dehydrate a TanStack Query client so the island hydrates with data
already in hand. getStreamsUrl from @netscript/plugin-streams-core resolves the runtime address:
// apps/dashboard/routes/(dashboard)/dashboard/plugin/(_shared)/stream-loaders.ts
import { dehydrateQueryClient } from '@netscript/fresh/query';
import { createNetScriptQueryClient } from '@netscript/sdk/query-client';
import { getStreamsUrl } from '@netscript/plugin-streams-core';
import { sagasQueryUtils } from '@app/lib/api-clients.ts';
const INVENTORY_INPUT = { limit: '12', offset: '0' } as const;
export async function sagasStreamSeedLoader() {
// 1. Fetch through the SDK (also warms the KV cache for getCachedEntry hits).
const sagasData = await sagasQueryUtils.listSagas(INVENTORY_INPUT);
// 2. Populate a temporary QueryClient and dehydrate it for the island.
const queryClient = createNetScriptQueryClient();
queryClient.setQueryData(sagasQueryUtils.listSagas.clientKey(INVENTORY_INPUT), sagasData);
const dehydratedState = dehydrateQueryClient(queryClient);
return {
inventoryInput: INVENTORY_INPUT,
streamsBaseUrl: getStreamsUrl(),
dehydratedState,
};
}
The island receives streamsBaseUrl (for Step 1's StreamDB handle), inventoryInput (for the
useQuery inventory read), and dehydratedState (the pre-warmed TanStack cache). On mount the
island rehydrates from dehydratedState, so the inventory is present immediately and the live rows
stream in on top.
Step 4 — Wrap the island in QueryIsland
useLiveQuery and useQuery both need the TanStack Query context, so the live monitor lives inside
a QueryIsland exactly like chapter 4's orders island. Hydrate the dehydrated state on first render:
// apps/dashboard/islands/SagasLiveIsland.tsx (the island boundary)
import { getIslandQueryClient, hydrateFromDehydrated, QueryIsland } from '@netscript/fresh/query';
export default function SagasLiveIsland(props) {
return (
<QueryIsland>
<SagasLiveInner {...props} />
</QueryIsland>
);
}
// Inside SagasLiveInner, once, before the queries:
// const islandQueryClient = getIslandQueryClient();
// if (props.dehydratedState) hydrateFromDehydrated(islandQueryClient, props.dehydratedState);
getIslandQueryClient() returns the island's QueryClient; hydrateFromDehydrated seeds it from the
server's dehydratedState. After that, useQuery reads the seeded inventory and useLiveQuery
takes over the live rows.
Verify your progress
With aspire start up, open the live monitor in the browser and watch it update. The showcase serves
it at the plugin route; in your workspace the live saga monitor renders wherever you mount
SagasLiveIsland. To see it move, trigger a saga — creating an order publishes an OrderCreated
saga message (chapter 2's service does this), which advances a saga instance and pushes a change down
the stream:
curl -X POST http://localhost:3002/api/v1/orders/create \
-H 'content-type: application/json' \
-d '{ "userId": 1, "total": 49.9, "status": "pending", "shippingStreet": "1 Main", "shippingCity": "Berlin", "shippingCountry": "DE", "shippingZipCode": "10115", "items": [{ "productId": 1, "quantity": 1 }] }'
Within a moment a new saga instance row should appear and advance through its steps without a page reload. Type-check the new files:
deno task check
- [ ]
curl http://localhost:4437/healthreturns healthy (streams runtime up). - [ ] The live island opens a
createSagasStreamDBhandle and queries it withuseLiveQuery. - [ ] The island is seeded server-side (
getStreamsUrl+dehydrateQueryClient) and hydrates on mount. - [ ] Creating an order makes a row appear/advance live, with no reload.
- [ ]
deno task checkis clean.
What you built
A real-time table: a durable StreamDB handle (createSagasStreamDB) driving useLiveQuery, seeded
from the server with getStreamsUrl + dehydration, wrapped in a QueryIsland. Your dashboard now
updates the instant server state changes — the full contract → client → query → island → stream
spine, end to end. Next you run the whole graph locally under Aspire.