Beta

@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) #

directory-view.tsx
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.

  • snapshot is a DirectoryTreeSnapshot once the initial load lands, null before that (and null again if the watched directory was deleted).
  • isReady is the recommended "do I have data" check. It's true exactly when snapshot is non-null.
  • error is populated if loadTree or the watcher install threw. Show a retry UI and call retry() to rerun the acquisition.
  • resolvedDirectoryUri tells you the URI that's actually being watched — useful when you passed null and 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() #

workspace-list.tsx
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() #

inbox.tsx
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) #

directory-filenames.tsx
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) #

shares-list.tsx
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() #

pending-shares.tsx
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.