Beta

@opake/react — Overview

React bindings on top of @opake/sdk. One provider at the root, a handful of hooks for common read/write patterns, and an optimistic overlay that keeps the UI in sync during in-flight mutations.

Install #

bun add @opake/react @opake/sdk @tanstack/react-query react

@opake/react depends on @opake/sdk and @tanstack/react-query. React 18+ is required; the hooks use modern concurrent features.

The provider #

provider-setup.tsx
import { Opake } from "@opake/sdk";
import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
import { OpakeProvider } from "@opake/react";

const storage = new IndexedDbStorage();
const opake = await Opake.init({ storage });

// Once per app, at or near the root. Everything under it gets useOpake,
// useFileManager, useDirectory, plus the subscription-backed hooks.
<OpakeProvider opake={opake}>
  <App />
</OpakeProvider>

OpakeProvider does three things:

  • Exposes the passed Opake instance via useOpake().
  • Holds a refcounted FileManagerCache so multiple components asking for the same FileManager share one handle. The cache clears when the provider unmounts.
  • Starts the WASM SSE consumer on mount and stops it + wipes keepers on unmount. This is why the provider is a component: its lifecycle is where the live-update stream lives.

All hooks described on the other pages assume they're rendered inside this provider.

A first hook #

hello-hook.tsx
import { useOpake, useDirectory } from "@opake/react";

function CabinetRoot() {
  const opake = useOpake(); // raw Opake instance, for things not wrapped yet

  // Live-subscribed snapshot of the cabinet's root directory. Pass null
  // for the keyringUri (= cabinet) and null for the directoryUri (= root).
  // Re-renders on every upload, rename, move, delete, or remote change.
  const { snapshot, isReady } = useDirectory(null, null);

  if (!isReady) return <p>Loading…</p>;
  if (!snapshot?.rootUri) return <p>Empty cabinet.</p>;

  const root = snapshot.directories[snapshot.rootUri];
  return (
    <ul>
      {root?.entries.map((entry) => (
        <li key={entry.uri}>{entry.uri.split("/").pop()}</li>
      ))}
    </ul>
  );
}

useDirectory(keyringUri, directoryUri) is a subscription. The first argument selects the context (null for the cabinet, a workspace keyring URI otherwise); the second picks the directory to watch (null means "resolve the root of this context"). The initial snapshot comes from cache plus a delta sync; subsequent snapshots come from SSE events. No polling, no manual invalidation.

The returned shape is { snapshot, isReady, error, resolvedDirectoryUri, retry }. While the watcher is installing, snapshot is null and isReady is false. Once the first fire lands, snapshot becomes a DirectoryTreeSnapshot and isReady flips to true. If the watched directory gets deleted (by a move, a recursive delete, or a remote peer), the watcher fires one last time with a null snapshot and closes — at that point isReady is false again and snapshot stays null. If you need to distinguish "still loading" from "was here, now gone", track a "have I ever been ready" latch in local state.

Custom QueryClient #

Most apps already have a @tanstack/react-query client. Pass it in so Opake's queries share the same cache:

with-query-client.tsx
import { QueryClient } from "@tanstack/react-query";
import { OpakeProvider } from "@opake/react";

// If your app already has a QueryClient (most TanStack-using apps do),
// pass it in. OpakeProvider registers its own queries inside your
// shared client instead of maintaining a parallel one.
const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000 } },
});

<OpakeProvider opake={opake} queryClient={queryClient}>
  <App />
</OpakeProvider>

Without the queryClient prop, OpakeProvider creates its own default. That's fine for small apps but means Opake and the rest of your app run on separate caches; invalidation from one side doesn't cascade to the other.

Disabling the auto-start #

Most apps want the SSE consumer to start automatically with the provider. A few don't: apps with an offline mode, apps behind a feature flag, apps that want to start the stream only after the user explicitly goes online.

disable-sse.tsx
// Rare: you're gating the SSE consumer on something app-specific
// (feature flag, explicit user opt-in, offline mode) and want to start
// it yourself.
<OpakeProvider opake={opake} disableSseAutoStart>
  <App />
</OpakeProvider>

// Somewhere else, when you're ready:
import { useStartSseConsumer } from "@opake/react";

function OfflineAwareGate() {
  const online = useNavigatorOnline();
  useStartSseConsumer(online ? undefined : null);
  return <Outlet />;
}

disableSseAutoStart turns off the provider's built-in start. Use useStartSseConsumer wherever you want to control the timing. Passing null to it skips the start entirely; passing undefined (or any string URL) triggers it. The WASM consumer is idempotent, so calling from multiple places is safe.

The optimistic overlay #

When a mutation hook fires (e.g. useMove), Opake doesn't wait for the round-trip to update your UI. It applies a local patch to the directory-tree snapshot, synchronously. Components subscribed via useDirectory re-render with the moved entry in its new location within the same render tick.

When the real event arrives from SSE (or the mutation resolves, whichever is first), the overlay dedups the patch against the real state and drops it. If the mutation fails, the overlay rolls back.

The overlay is per-Opake-instance. Account switching (passing a new Opake as opake={...}) creates a fresh overlay, so patches from one user don't project onto the next user's trees.

You don't interact with the overlay directly; it's transparent. Mutation hooks return react-query mutation objects with the usual mutate / mutateAsync / isPending / error API, and reads happen through useDirectory which folds overlay patches in automatically.