@opake/react — Live updates
The provider auto-starts the SSE consumer, so most apps don't need to think about live updates at all. This page covers the escape hatches: gating the stream on runtime conditions, running the daemon for background maintenance, and invalidating the few queries that don't flow through SSE.
What's live by default #
Render an OpakeProvider and you get:
useDirectory— directory trees update asdocument:upsert,directory:upsert,document:delete,directory:deleteevents arrive from the indexer.useWorkspaces— workspace list updates onkeyring:upsert/keyring:delete.useInbox— incoming shares update ongrant:upsert/grant:deletefor the caller's personal topic.- Mutations — the optimistic overlay reflects writes in the same render tick, then dedups against the echo when it arrives.
No polling, no timers, no manual refetching. A typical PDS-write-to-SSE-echo round-trip is under a second, so remote changes from other devices surface within that window.
Gating the auto-start #
The provider's auto-start is unconditional: it fires a startSseConsumer call on mount.
That's fine when you only render the provider after the user is authenticated. It's a
problem when you wrap the whole app in the provider and rely on internal auth state to
decide who's logged in — an unauthenticated start triggers a token-exchange request that
fails with Auth.
Two options. The clean one: render the provider conditionally. Unmount when the user signs
out, mount when they sign in. OpakeProvider already cleans up on unmount (stops the
stream, wipes keeper state).
The escape hatch: set disableSseAutoStart and drive the start yourself with
useStartSseConsumer:
import { useStartSseConsumer } from "@opake/react";
function SseGate({ children }: { children: ReactNode }) {
const user = useAuthUser();
// Skip starting when there's no authenticated user. Starting anyway
// would trigger a token exchange that fails with Auth.
useStartSseConsumer(user ? undefined : null);
return <>{children}</>;
}useStartSseConsumer is deliberately start-only. There's no corresponding stop — it
exists to let you delay the start past provider mount, not to toggle the stream on and
off during a session. If you need stop-on-condition, use the conditional-provider pattern
above.
Override the indexer URL #
The consumer resolves its URL from a priority chain: runtime override →
accountConfig.indexerUrl on the user's PDS → the compile-time default. Pass an explicit
URL to useStartSseConsumer to win the chain:
// Override the indexer URL at runtime. Wins over the user's PDS
// accountConfig for the rest of the Opake instance's lifetime.
useStartSseConsumer("https://indexer.example.com");Typical use: pinning to a specific indexer for testing, or overriding the default in a development build.
The daemon #
@opake/react ships a side-effect hook for the background daemon:
import { useDaemon } from "@opake/react";
import { Opake } from "@opake/sdk";
function DaemonRunner({ taskStore }: { taskStore: TaskStore }) {
// Side-effect hook: starts the daemon on mount, stops it on unmount.
// Returns void — task state lives in your TaskStore, not in React.
useDaemon({
taskDefs: Opake.taskDefs(),
taskStore,
onSessionExpired: () => {
// Your app's logout path — the daemon hit a dead session and
// can't continue without re-auth.
window.location.href = "/login";
},
});
return null; // or a small status indicator wired to taskStore
}The daemon is pure maintenance now — SSE replaced the tree-sync job, but the daemon still runs:
- Pending-share retries (when a recipient publishes their encryption key, the daemon promotes queued pending shares to real grants).
- Stale pair-request cleanup (requests past their TTL get deleted from the PDS).
- Grant-healing (finding and repairing grants whose wrapped key is missing or invalidated by a keyring rotation).
useDaemon is imperative: it starts the daemon on mount, stops it on unmount, and returns
nothing. Task state lives in the TaskStore you pass in — implement the interface with
IndexedDB (for persistence across reloads) or an in-memory Map (for tests / SSR). The
daemon's own data flow is opaque to React; if you want to surface task status, subscribe
to the TaskStore from your own component state.
onSessionExpired is called exactly once when a task fails with an Auth error. Wire it
to your app's logout path — by the time this fires the session is dead and no further
Opake calls will succeed without re-auth.
Manual invalidation #
Most state is SSE-driven. The few queries that aren't:
useShares(documentUri)/usePendingShares()— react-query backed, invalidated automatically byuseShareFile/useRevokeShare/useCancelPendingShareon success. If you know an external write landed (e.g. a CLI on another device revoked a grant), invalidate manually.useDirectoryMetadata(keyringUri, directoryUri)— invalidated by every tree mutation the React layer runs. Not invalidated for writes from other devices.- Daemon task state — lives in your
TaskStore, separate from react-query.
For the first two, opakeKeys gives you stable query-key factories:
import { useQueryClient } from "@tanstack/react-query";
import { opakeKeys } from "@opake/react";
function RefreshButton({ documentUri }: { documentUri: string }) {
const qc = useQueryClient();
return (
<button
onClick={() => {
// Most state updates via SSE. These factories are for the few
// queries that don't (shares, pending shares, tasks) — hit
// them if you know an external write landed outside the stream.
qc.invalidateQueries({ queryKey: opakeKeys.shares(documentUri) });
qc.invalidateQueries({ queryKey: opakeKeys.pendingShares() });
}}
>
Refresh
</button>
);
}The full list of key factories:
opakeKeys.all(); // every opake query
opakeKeys.cabinetTree(); // cabinet directory tree
opakeKeys.workspaceTree(keyringUri); // one workspace's tree
opakeKeys.metadata(directoryUri); // decrypted doc metadata for a dir
opakeKeys.tasks(); // daemon task records
opakeKeys.identity(handleOrDid); // resolved identity cache
opakeKeys.inbox(); // incoming shares (legacy — SSE now)
opakeKeys.sharesAll(); // every shares query across docs
opakeKeys.shares(documentUri); // outgoing shares for one doc
opakeKeys.pendingShares(); // queued shares waiting on recipientInvalidating opakeKeys.all() nukes every Opake-owned query, which forces a cold refetch
of everything react-query caches. Useful as a "something's really off, start over" button;
heavy otherwise.
Account switching #
Passing a new Opake instance to the provider's opake prop creates a fresh cache. The
overlay, FileManager cache, and keeper state all rebuild from the new instance; patches
and cached FileManagers from the old identity don't leak across.
If your app supports multiple signed-in accounts, the typical pattern is: keep one
OpakeProvider at the root, re-key it when the active account changes. The WASM module
stops the old stream + wipes keeper state on unmount, then starts fresh on the new mount.