@opake/react — Writing hooks
Mutation hooks wrap the SDK's write operations. Every tree-level mutation is paired with an optimistic overlay patch so the UI reflects the change synchronously; hooks that don't affect the tree (sharing, workspace creation, downloads) go through plain react-query mutations.
The keyringUri parameter #
Every tree mutation hook takes a keyringUri: string | null. Pass null for the cabinet
or a workspace's keyring URI otherwise. The keyringUri determines:
- Which
FileManagerthe hook acquires from the provider's cache. - Which optimistic overlay scope receives the patch (so
useDirectorywatching the same scope re-renders optimistically). - Which query-cache entry gets invalidated on settle.
If your component is rendered for a single context, thread the keyringUri in via props or a context value and pass it once. Don't rebuild it per call — each distinct keyringUri builds a separate overlay scope.
File operations #
useUpload(keyringUri) #
import { useUpload } from "@opake/react";
function UploadButton({ keyringUri, directoryUri }: {
keyringUri: string | null; // null for cabinet
directoryUri: string;
}) {
const upload = useUpload(keyringUri);
const onFile = async (file: File) => {
const data = new Uint8Array(await file.arrayBuffer());
await upload.mutateAsync({
data,
filename: file.name,
mimeType: file.type || "application/octet-stream",
directoryUri,
});
// A placeholder row appears in any useDirectory watching
// directoryUri immediately via the optimistic overlay; it's
// replaced by the real entry once the SSE echo arrives.
};
return <input type="file" onChange={(e) => onFile(e.target.files![0]!)} />;
}The input carries { data: Uint8Array, filename, mimeType, description?, tags?, directoryUri? }. Omit directoryUri to upload to the context root. The optimistic
overlay inserts a placeholder entry in the target directory right away; the real entry
replaces it when the SSE echo arrives (~1s after PDS write).
Results are UploadResult = { uri } from the SDK. In a workspace, the cascade also
supersedes every parent directory chain on your PDS — the indexer chain-follows and the
other members see the new entry on the next SSE echo. If a concurrent writer beat you to
the chain head, the hook automatically refetches and replays with exponential backoff
(capped at 4 retries). See workspaces for the federation model.
useDownload(keyringUri) #
import { useDownload } from "@opake/react";
function DownloadButton({ keyringUri, documentUri }: {
keyringUri: string | null;
documentUri: string;
}) {
const download = useDownload(keyringUri);
const onClick = async () => {
const { filename, data } = await download.mutateAsync(documentUri);
const url = URL.createObjectURL(new Blob([data]));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
return (
<button onClick={onClick} disabled={download.isPending}>
{download.isPending ? "Downloading…" : "Download"}
</button>
);
}Downloads aren't cached — the hook is a useMutation, not a useQuery, because caching
decrypted plaintext in react-query would leak it into memory for longer than necessary.
Each call re-fetches and re-decrypts.
Returns { filename, data: Uint8Array }. filename is the decrypted original name;
data is the plaintext. Both are safe to drop from memory as soon as you've handed them
off (to a Blob, a worker, whatever).
useDelete(keyringUri) #
import { useDelete } from "@opake/react";
function DeleteButton({ keyringUri, documentUri, parentDirectoryUri }: {
keyringUri: string | null;
documentUri: string;
parentDirectoryUri: string;
}) {
const del = useDelete(keyringUri);
return (
<button
onClick={() => del.mutate({ documentUri, parentDirectoryUri })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"}
</button>
);
}parentDirectoryUri is required. The delete is atomic with removing the entry from its
parent, so the hook needs to know which directory to patch. The optimistic overlay filters
the entry out of the parent immediately; the SSE echo confirms.
useMove(keyringUri) #
import { useMove } from "@opake/react";
function useDragDropMove(keyringUri: string | null) {
const move = useMove(keyringUri);
return (entry: { uri: string; parentUri: string }, targetDirUri: string) => {
move.mutate({
entryUri: entry.uri,
sourceDirUri: entry.parentUri,
targetDirUri,
});
// The overlay shows the entry in its new directory immediately;
// the real tree update arrives via SSE and dedups against the overlay.
};
}Moves work across directories in the same context only. A cabinet document can move between cabinet directories; a workspace document can move between directories in that workspace. Cross-context moves (workspace → cabinet, cabinet → workspace) aren't a move — they're a download + upload + delete, and the SDK deliberately doesn't hide that cost behind a single call.
The input is { entryUri, sourceDirUri, targetDirUri }. Works for both documents and
sub-directories — the tree operation is uniform.
Directory operations #
import {
useCreateDirectory,
useRenameDirectory,
useDeleteDirectory,
} from "@opake/react";
function DirectoryActions({ keyringUri, parentUri }: {
keyringUri: string | null;
parentUri: string;
}) {
const create = useCreateDirectory(keyringUri);
const rename = useRenameDirectory(keyringUri);
const remove = useDeleteDirectory(keyringUri);
return (
<>
<button onClick={() => create.mutate({ name: "New Folder", parentUri })}>
New folder
</button>
<button
onClick={() =>
rename.mutate({ directoryUri: parentUri, newName: "Renamed" })
}
>
Rename
</button>
<button onClick={() => remove.mutate({ directoryUri: parentUri })}>
Delete folder
</button>
</>
);
}useCreateDirectory(keyringUri)takes{ name, parentUri? }. OmitparentUrito create at the root. The optimistic overlay inserts a placeholder directory entry immediately; the real entry replaces it on echo.useRenameDirectory(keyringUri)takes{ directoryUri, newName }. Renames stay optimistic: the tree reflects the new name synchronously.useDeleteDirectory(keyringUri)takes{ directoryUri }and recursively deletes the directory and every document inside it. Returns a{ documentsDeleted, directoriesDeleted }count on success.
Sharing #
import { OpakeError } from "@opake/sdk";
import { useShareFile, useRevokeShare } from "@opake/react";
function ShareButton({ documentUri, handle }: {
documentUri: string;
handle: string;
}) {
const share = useShareFile();
const onClick = async () => {
const result = await share.mutateAsync({
documentUri,
handleOrDid: handle,
note: "Here's that file you asked for",
});
// result.pending === true means the recipient hasn't published
// an encryption key yet; the daemon retries until they do.
notify(result.pending ? "Queued until recipient is ready" : "Shared");
};
return (
<button onClick={onClick} disabled={share.isPending}>
Share with {handle}
</button>
);
}
function RevokeButton({ grantUri }: { grantUri: string }) {
const revoke = useRevokeShare();
return (
<button onClick={() => revoke.mutate(grantUri)} disabled={revoke.isPending}>
Revoke
</button>
);
}useShareFile takes { documentUri, handleOrDid, note? }. All grants are read-only;
there's no permissions parameter. On success the mutation returns { pending: false } for
a direct share, or { pending: true } when the recipient has a valid atproto identity but
hasn't published an Opake encryption key yet — at which point it's been queued as a
pending share for the daemon to retry.
On success the hook invalidates both the per-document useShares(documentUri) query and
the top-level usePendingShares() query, so the lists refresh automatically.
useRevokeShare takes a grant URI. Deletes the grant record from your PDS; the recipient
sees the grant disappear from their useInbox() on the next firehose round-trip. All
useShares queries are invalidated on success.
Workspaces #
import { useState } from "react";
import { useCreateWorkspace } from "@opake/react";
function NewWorkspaceForm() {
const create = useCreateWorkspace();
const [name, setName] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
create.mutate({ name });
// The new workspace appears in useWorkspaces() automatically
// via the SSE keyring:upsert echo. No cache invalidation needed.
}}
>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button disabled={!name || create.isPending}>Create workspace</button>
</form>
);
}useCreateWorkspace takes { name, description? } and returns { keyringUri, key }
(the key is the group key; you don't usually need it — subsequent calls re-resolve via
the URI and the caller's Identity).
The new workspace appears in useWorkspaces() automatically via the SSE keyring:upsert
echo — no cache invalidation needed. Membership management hooks (add/remove member,
leave workspace) aren't wrapped yet; call opake.addWorkspaceMember(...) /
opake.removeWorkspaceMember(...) / opake.leaveWorkspace(...) directly from useOpake.
How the optimistic overlay behaves #
// You don't interact with the overlay directly — mutation hooks
// apply patches in onMutate and release them 2s after settle. The
// timeline, for a useMove:
//
// t+0 move.mutate({...}) fires
// t+0 overlay patch applied → useDirectory re-renders with
// the entry in its new directory
// t+~150ms PDS write completes → mutation resolves
// t+~1s SSE echo arrives → TreeKeeper updates, base
// snapshot reflects the move
// t+2s overlay patch released → no-op because the base
// snapshot already agrees
//
// If the mutation fails (network hiccup, auth expired, permission
// denied), the overlay releases immediately in onError and the UI
// snaps back to the pre-mutation tree.Under the hood:
- Each mutation hook wraps
useTreeMutation, which takes an optionaloptimisticUpdatefunction(snapshot, input) => snapshot. That function is called ononMutateto compute the patched snapshot; the patch gets pushed onto the provider'sOptimisticOverlay. useDirectoryreadssnapshot.project(scope, base)— the base from the keeper, with overlay patches composed on top. The projection is a no-op when there are no patches, so there's no per-render cost when nothing is in flight.- Patches release 2 seconds after the mutation settles. The delay spans the typical PDS-write-to-SSE-echo round-trip; dropping the patch earlier would briefly reveal the pre-mutation tree between the SDK promise resolving and the echo arriving.
- On error, the patch releases immediately (no server-side state to wait for) and the UI snaps back.
If you build a custom mutation that needs the same behaviour, useTreeMutation is
exported. Pass your own mutationFn and optimisticUpdate — see the source of
useUpload / useMove for real examples.