@opake/react — Reading hooks
Read-only hooks split by delivery method. Three are subscriptions wired to the WASM keepers and re-render on SSE events within a firehose round-trip. Three are react-query queries that cache and re-fetch on invalidation — useful for state that doesn't flow through the live stream.
Subscriptions (SSE-backed) #
These hooks install a watcher on the WASM side, get one eager fire with current state, and
then re-fire on every relevant SSE event. The OpakeProvider auto-starts the consumer, so
everything below is "just works" inside a provider — no bootstrap plumbing.
useDirectory(keyringUri, directoryUri) #
import { useDirectory } from "@opake/react";
function Directory({ keyringUri, uri }: {
keyringUri: string | null; // null for cabinet, keyring URI for a workspace
uri: string | null; // null to watch the context's root
}) {
const { snapshot, isReady, error, retry } = useDirectory(keyringUri, uri);
if (error) return <RetryBanner message={error.message} onRetry={retry} />;
if (!isReady) return <p>Loading…</p>;
if (!snapshot) return <p>This directory no longer exists.</p>;
return <DirectoryTreeView snapshot={snapshot} focusedUri={uri ?? snapshot.rootUri} />;
}The primary hook for rendering a directory tree. Pass keyringUri = null for the cabinet
or a workspace's keyring URI. Pass directoryUri = null to watch the root of that context,
or a specific directory URI otherwise.
snapshotis aDirectoryTreeSnapshotonce the initial load lands,nullbefore that (andnullagain if the watched directory was deleted).isReadyis the recommended "do I have data" check. It'strueexactly whensnapshotis non-null.erroris populated ifloadTreeor the watcher install threw. Show a retry UI and callretry()to rerun the acquisition.resolvedDirectoryUritells you the URI that's actually being watched — useful when you passednulland need to know where the root landed.
Snapshots fold in the optimistic overlay automatically, so a mutation you fire shows up in the same render tick. See the mutations page for details.
useWorkspaces() #
import { useWorkspaces } from "@opake/react";
function WorkspaceList() {
const { data: workspaces, isLoading } = useWorkspaces();
if (isLoading) return <p>Loading workspaces…</p>;
return (
<ul>
{workspaces.map((ws) => (
<li key={ws.uri}>
{ws.name || "Unnamed workspace"}{" "}
<small>({ws.memberCount} member{ws.memberCount === 1 ? "" : "s"})</small>
</li>
))}
</ul>
);
}Subscribes to the WorkspaceKeeper. First call bootstraps the keeper via listWorkspaces;
subsequent SSE keyring:upsert / keyring:delete events patch the list in place.
data is a readonly WorkspaceEntry[] with { uri, ownerDid, rotation, memberCount, createdAt, name, description, icon }. isLoading is true until the keeper has
bootstrapped once; after that it stays false even when the list is empty. The raw
snapshot.loaded flag is also exposed if you need to distinguish the cold-start state from
"loaded, currently empty."
useInbox() #
import { useInbox } from "@opake/react";
function Inbox() {
const { data: inbox, isLoading } = useInbox();
if (isLoading) return <p>Loading inbox…</p>;
return (
<ul>
{inbox.map((grant) => (
<li key={grant.uri}>
<code>{grant.documentUri}</code>{" "}
<small>from {grant.authorDid}</small>
</li>
))}
</ul>
);
}Same subscription shape as useWorkspaces. Backed by the InboxKeeper, which the indexer
fans into the caller's personal topic for both owner-side (you just shared something) and
recipient-side (someone shared with you) events.
Entries are InboxGrant values: { uri, ownerDid, documentUri, createdAt }. To actually
open an incoming share, call opake.downloadFromGrant(grantUri) or
opake.resolveGrantMetadata(grantUri) — see the SDK sharing page.
Cached reads (react-query) #
These hooks don't subscribe to SSE. They fetch once per query key and cache the result. The
OpakeProvider invalidates them automatically when a local mutation changes the relevant
state; updates from other devices surface on the next manual invalidation or refetch
trigger.
useDirectoryMetadata(keyringUri, directoryUri) #
import { useDirectoryMetadata } from "@opake/react";
function DirectoryFilenames({ keyringUri, directoryUri }: {
keyringUri: string | null;
directoryUri: string;
}) {
// Thin read: just the decrypted metadata for documents in one
// directory (filename, MIME type, size, tags, descriptions).
const { data: metadata, isLoading } = useDirectoryMetadata(keyringUri, directoryUri);
if (isLoading) return <p>Loading…</p>;
return (
<ul>
{Object.entries(metadata ?? {}).map(([docUri, meta]) => (
<li key={docUri}>
<a href={`/doc/${encodeURIComponent(docUri)}`}>{meta.name}</a>
<small> ({meta.mimeType}, {meta.size} bytes)</small>
</li>
))}
</ul>
);
}Thin read for a directory's document metadata — filenames, MIME types, sizes, tags,
descriptions. Useful when a list view needs names but you don't want the full subtree that
useDirectory carries.
Returns a react-query { data, isLoading, ... } where data is
Readonly<Record<string, DocumentMetadata>> keyed by document URI. The query is
invalidated whenever any tree mutation fires (to keep things simple, the mutation hook
invalidates the ["opake", "metadata"] prefix), which in practice means this is live for
the user's own writes but not for writes from other devices.
useShares(documentUri) #
import { useShares } from "@opake/react";
function ShareList({ documentUri }: { documentUri: string }) {
const { data: grants, isLoading } = useShares(documentUri);
if (isLoading) return <p>Loading…</p>;
return (
<ul>
{grants?.map((g) => (
<li key={g.uri}>
Shared with {g.recipient} on {new Date(g.createdAt).toLocaleDateString()}
</li>
))}
</ul>
);
}List every active grant for a specific document. The underlying listShares call
enumerates the caller's entire grant collection; the hook filters client-side to the single
document you asked about. Cabinet-only — workspace sharing doesn't use grants.
useShareFile and useRevokeShare invalidate this query automatically on success.
usePendingShares() #
import { usePendingShares, useCancelPendingShare } from "@opake/react";
function PendingList() {
const { data: pending, isLoading } = usePendingShares();
const cancel = useCancelPendingShare();
if (isLoading) return null;
return (
<ul>
{pending?.map((p) => (
<li key={p.uri}>
{p.document} → {p.recipient}{" "}
<button onClick={() => cancel.mutate(p.uri)} disabled={cancel.isPending}>
Cancel
</button>
</li>
))}
</ul>
);
}Pending shares are the queue of "I wanted to share this with Bob, but Bob hasn't published
an encryption key yet" entries. The daemon retries them on a schedule; the hook reads the
current queue, and useCancelPendingShare drops one.
The query is local-only — pending shares don't flow through SSE, they only exist in the caller's cabinet until the daemon completes or expires them.
Composing with suspense / error boundaries #
The subscription hooks don't throw. Errors are surfaced on the error field (for
useDirectory) or swallowed (for useWorkspaces / useInbox, since a bootstrap failure
just leaves isLoading pinned true). Wire your own error boundary around the components
that call these hooks if you want suspense-style flow.
The react-query hooks behave like any other useQuery — they respect QueryClient-level
defaultOptions.queries.useErrorBoundary, retry, and suspense if you opt in. See
react-query's docs for the boundary patterns.