How to Set Up Real-Time UI Synchronization in Agent-Native Using the useDbSync Hook
Agent-Native provides a built-in useDbSync hook that automatically wires your React UI to the framework's real-time change-event stream, invalidating React Query caches whenever the SQL database updates via Server-Sent Events (SSE) or polling.
The useDbSync hook in the BuilderIO/agent-native repository eliminates manual refresh logic by subscribing to database change events. When any write occurs—whether from an agent action, user input, or external script—the server emits a SyncEvent that triggers automatic UI updates. This guide explains the hook's architecture and implementation based on the source code in packages/core/src/client/use-db-sync.ts.
Architecture of Real-Time Synchronization
Transport Layer
At the heart of the system is a singleton SyncTransport (defined in packages/core/src/client/use-db-sync.ts lines 22-30) that manages a single SSE connection and poll loop per (pollUrl, sseUrl) pair. All useDbSync instances share this transport, guaranteeing only one network connection per browser tab regardless of how many components use the hook.
The transport aggregates subscriber preferences such as interval, fallbackInterval, and pauseWhenHidden (see lines 35-48). When the transport receives a batch of events via SSE or poll, it calls each subscriber’s onEvents callback (lines 23-27).
Version Cursor and Event Ordering
Each subscriber maintains its own version cursor (lines 28-33). Because the server writes a monotonically increasing version on each database change, every poll request includes a ?since= parameter (lines 39-42). The transport updates its internal cursor via applyVersion (lines 26-33) and only forwards events newer than a subscriber’s cursor, guaranteeing event ordering and exactly-once UI updates.
Cache Invalidation Strategy
Inside useDbSync, fresh events are mapped to per-source change counters via bumpChangeVersion (lines 54-58). React Query queries that embed those counters in their queryKey automatically refetch. For backward compatibility, the hook also invalidates a broader set of framework-owned query keys including "action", "extension", and "app-state" (lines 59-88).
Lifecycle Management
When a component mounts, useEffect registers the subscriber with transport.add(id, …) (lines 29-36). On unmount, it removes the subscriber and releases the transport if empty (lines 37-46).
Configuration Options
The hook accepts several parameters to control synchronization behavior:
pollUrl– Defaults to/_agent-native/pollsseUrl– Defaults to/_agent-native/events(set tofalseto disable SSE)interval/fallbackInterval– Control polling cadence in millisecondspauseWhenHidden– Avoids network traffic when the tab is backgrounded (seepackages/core/src/client/route-state.ts)ignoreSource– Lets a tab ignore its own writes to prevent echo updatesonEvent– Optional callback for custom event handling
Implementation Examples
Root-Level Setup
Initialize useDbSync once at the top level of your application to establish the global sync connection:
// src/app/root.tsx
import { useDbSync } from "@agent-native/core/client";
import { useQueryClient } from "@tanstack/react-query";
export default function RootApp() {
const queryClient = useQueryClient();
useDbSync({
queryClient,
pollUrl: "/_agent-native/poll",
sseUrl: "/_agent-native/events",
interval: 2000,
fallbackInterval: 15000,
pauseWhenHidden: true,
ignoreSource: "my-tab-id",
onEvent: (e) => console.log("Sync event:", e),
});
return (
<AppLayout>
<MainContent />
</AppLayout>
);
}
Component-Level Integration
Use useChangeVersion (from packages/core/src/client/use-change-version.ts) to attach source-specific versions to your query keys:
import { useQuery } from "@tanstack/react-query";
import { useChangeVersion } from "@agent-native/core/client";
function TodoList() {
const version = useChangeVersion("todos");
const { data } = useQuery(
["todos", version],
() => fetch("/api/todos").then((r) => r.json()),
{ staleTime: 30_000 }
);
return <ul>{data?.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}
Poll-Only Mode
To disable SSE and rely solely on polling:
useDbSync({ sseUrl: false, interval: 5000 });
Preventing Echo Updates
Generate a unique tab identifier to prevent the UI from re-fetching data that originated from the same tab:
const tabId = useMemo(() => crypto.randomUUID(), []);
useDbSync({ ignoreSource: tabId });
// When mutating, include the source identifier
await mutate({ ...payload, requestSource: tabId });
Summary
useDbSyncestablishes a single shared transport connection per browser tab via theSyncTransportsingleton.- The hook subscribes to
/_agent-native/eventsvia SSE, falling back to/_agent-native/pollwhen necessary. - Version cursors ensure exactly-once delivery of change events, preventing stale updates from triggering redundant refetches.
- Cache invalidation occurs automatically via
bumpChangeVersionand React Query key embedding. - Configuration options include
pauseWhenHidden,ignoreSource, and custom intervals for bandwidth optimization.
Frequently Asked Questions
What transport protocol does useDbSync use?
The hook uses Server-Sent Events (SSE) on the /_agent-native/events endpoint by default, with automatic fallback to HTTP polling on /_agent-native/poll if SSE is unavailable or explicitly disabled. This dual-transport approach ensures real-time updates across diverse network environments.
How does useDbSync prevent duplicate UI updates?
The implementation relies on version cursors maintained per subscriber. As implemented in packages/core/src/client/use-db-sync.ts lines 28-33, each subscriber tracks the last seen event version and ignores any events with versions less than or equal to its cursor. The transport layer also uses applyVersion (lines 26-33) to manage the global ?since= parameter for poll requests.
Can I use useDbSync without Server-Sent Events?
Yes. Set sseUrl: false in the hook options to disable SSE entirely. The transport will then use HTTP polling exclusively at the interval specified by the interval parameter (defaulting to 2000ms). This configuration is useful in environments with strict proxy rules or when working with older browsers that lack SSE support.
How do I prevent my own mutations from triggering refetches?
Pass a unique ignoreSource identifier to useDbSync, then include that same identifier as requestSource in your mutation payloads. According to the route-state.ts implementation, events matching the ignored source are filtered out before reaching the cache invalidation logic. Generate the tab ID using crypto.randomUUID() or similar, and provide it via React Context or a global store to both the sync hook and your mutation functions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →