Skip to content

React ​

@rayfold/react connects components to the Rayfold client's cache. When a command changes a book, every component showing that book re-renders with the new value. Nothing is fetched again, and components showing other books do not re-render. It works with React 18 and 19.

sh
npm install @rayfold/react @rayfold/client

Provide the client ​

tsx
import { RayfoldClient, createFetchTransport } from "@rayfold/client";
import { RayfoldProvider } from "@rayfold/react";

const client = new RayfoldClient({
  transport: createFetchTransport({ url: "/rayfold", headers: () => ({ authorization: `Bearer ${token()}` }) }),
});

export function App() {
  return (
    <RayfoldProvider client={client}>
      <BookPage id="b1" />
    </RayfoldProvider>
  );
}

Read with useQuery ​

tsx
import { useQuery } from "@rayfold/react";

interface Book { id: string; title: string; stock: number; author: { name: string } }

function BookPage({ id }: { id: string }) {
  const { data, error, loading, refetch } = useQuery<Book>("book", { id }, { shape: "{ id title stock author { name } }" });
  if (error) return <p>Could not load the book. <button onClick={() => refetch()}>Try again</button></p>;
  if (loading && !data) return <p>Loading...</p>;
  return <h1>{data?.title} by {data?.author.name}: {data?.stock} left</h1>;
}
  • shape picks the fields. Without it the type's default view is used.
  • loading is true until the first result. When the same query ran before, data holds the cached result at once while the fresh one loads.
  • A query that fails after it answered keeps its data beside error, so a screen can show what it had and say what went wrong; the next answer clears the error.
  • enabled: false sends nothing, for example until an id is known.
  • policy: "cache" uses a fresh cached result instead of asking the server.

Change with useCommand ​

tsx
import { useCommand } from "@rayfold/react";
import type { RayfoldClientError } from "@rayfold/client";

function BuyButton({ id }: { id: string }) {
  const [buy, { running, error }] = useCommand<Book>("buy");
  const e = error as RayfoldClientError | undefined;
  return (
    <>
      <button disabled={running} onClick={() => buy({ id, qty: 1 })}>Buy</button>
      {e?.is("OutOfStock") && <p>Only {(e.data as { available: number }).available} left.</p>}
    </>
  );
}

buy(...) returns a promise that rejects on failure. The outcome also lands in the hook's state: data is the result of the latest run, and a run that fails clears it and sets error. Calling it without await, as above, is fine. Each call gets its own idempotency key. When that call is sent again, as the offline queue does, the server answers from the first attempt and does not buy twice. A second click is a second call, so disable the button while it runs.

Follow other people's changes with useLive ​

tsx
import { useLive } from "@rayfold/react";

function StockBadge({ id }: { id: string }) {
  const { data } = useLive<{ stock: number }>("book", { id }, { shape: "{ id stock }" });
  return <span>{data?.stock ?? "..."} in stock</span>;
}

The server pushes every change to the result, whoever made it. The subscription ends when the component unmounts. Over HTTP, the TypeScript server streams a live query on the request that opened it. To share one connection between many, give the client createWebSocketTransport({ url: "wss://example.com/rayfold/ws" }).

Server rendering ​

On the server the hooks render their loading state and send nothing, so renderToString works as is. The browser fetches after hydration.

Released under the Apache-2.0 license.