Skip to content

Get started with React ​

A book list, stock counts that follow the server as they change, and a Buy button that handles a sold-out book. The app talks to the bookshop server from any of the other guides on port 4000; the TypeScript one is the quickest to run beside it. The finished app is examples/react.

1. Create the app ​

sh
npm create vite@latest bookshop-web -- --template react-ts
cd bookshop-web
npm install @rayfold/client @rayfold/react

2. Send API calls to the server ​

In development the page comes from Vite on port 5173 and the API from port 4000. Let Vite pass /rayfold on:

ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  // the app calls /rayfold on the dev server, which passes it on to src/server.ts
  server: { proxy: { "/rayfold": "http://localhost:4000" } },
});

The browser still sends the page's origin with every command. A Rayfold server refuses commands from origins it does not know, and that check stops other websites from acting for your users. Behind the proxy above, the request arrives looking same-origin, so every server allows it, whatever it is written in. Name the origin anyway, so the app keeps working when you point it straight at port 4000. On the TypeScript server it is an option in src/bookshop.ts:

ts
const endpoint = createHttpHandler(server, {
  viewer: (req) => viewerFrom(req.headers.authorization),
  // the page comes from the Vite dev server, another origin, so the browser names that origin on every command
  allowedOrigins: ["http://localhost:5173"],
});

On the JVM it is HttpOptions(allowedOrigins = setOf("http://localhost:5173")) in Kotlin, .allowedOrigins(...) on Rayfold.http(server) in Java, and rayfold.allowed-origins=http://localhost:5173 under Spring Boot. Without the proxy, the listed origin also lets the browser's CORS preflight through. The server answers the preflight, and every response to that origin, with Access-Control-Allow-* headers that name it.

3. Provide the client ​

Replace src/main.tsx:

tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RayfoldClient, createFetchTransport } from "@rayfold/client";
import { RayfoldProvider } from "@rayfold/react";
import { App } from "./App.tsx";
import { accessToken } from "./session.ts";

const client = new RayfoldClient({
  transport: createFetchTransport({
    url: "/rayfold",
    // read on every request, so a token the sign-in refreshed is the one sent
    headers: async () => ({ authorization: `Bearer ${await accessToken()}` }),
  }),
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <RayfoldProvider client={client}>
      <App />
    </RayfoldProvider>
  </StrictMode>,
);

accessToken() is your sign-in. Your identity provider's SDK returns the signed-in user's current token, refreshed before it expires, and the client sends it with every request. The server verifies it and turns it into the viewer that the schema's @allow rules check.

ts
/// <reference types="vite/client" />
/**
 * The signed-in user's access token. In an application this is the identity provider's SDK: Auth0's
 * `getAccessTokenSilently()`, MSAL's `acquireTokenSilent()`, Keycloak's `updateToken()`, each of which hands back a
 * current token and refreshes it before it expires.
 *
 * Until one is wired in, the bookshop's development token stands in for it: `npm run token` prints one, which goes in
 * `.env.local` as `VITE_DEV_TOKEN`. Every example server accepts it, since they share one development key.
 */
export async function accessToken(): Promise<string> {
  const token = import.meta.env["VITE_DEV_TOKEN"] as string | undefined;
  if (!token) throw new Error("No access token: sign-in is not wired in, and VITE_DEV_TOKEN is not set in .env.local");
  return token;
}

Until sign-in is wired in, a development token stands in for it. Every example server accepts one, since they share a development key. Print one with that server's token command (npm run token for the TypeScript one) and put it in .env.local:

sh
VITE_DEV_TOKEN=<the token the command printed>

Development tokens last eight hours. When one expires, every request fails with unauthenticated. Print a new one and restart npm run dev, which reads .env.local when it starts.

4. List the books ​

Replace src/App.tsx. It starts with the page itself:

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

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

export function App() {
  return (
    <main>
      <h1>Bookshop</h1>
      <BookList />
    </main>
  );
}

and the list, which the rest of this page adds to:

tsx
function BookList() {
  const { data, error, loading } = useQuery<{ items: Book[] }>("books", { page: { first: 20 } }, { shape: "{ items { id title author { name } } }" });

  if (error) return <p role="alert">Could not load the books.</p>;
  if (loading && !data) return <p>Loading...</p>;
  return (
    <ul>
      {data?.items.map((book) => (
        <li key={book.id}>
          <strong>{book.title}</strong> by {book.author.name} <Stock id={book.id} /> <BuyButton id={book.id} />
        </li>
      ))}
    </ul>
  );
}

useQuery sends the query and keeps the result in the client cache. The shape names exactly the fields the list shows, and the authors of the whole page are loaded in one call on the server.

5. Follow the stock ​

tsx
// Follows the stock as it changes, whoever buys or restocks.
function Stock({ id }: { id: string }) {
  const { data } = useLive<{ stock: number }>("book", { id }, { shape: "{ id stock }" });
  return <span data-stock={id}>{data ? `${data.stock} in stock` : "..."}</span>;
}

useLive keeps the query open. When anyone buys or restocks, the server sends a patch and only this component renders again. Open the app in two tabs and buy in one of them to watch the other change.

6. Buy a copy ​

tsx
function BuyButton({ id }: { id: string }) {
  const [buy, { running, error }] = useCommand<Book>("buy");
  const soldOut = (error as RayfoldClientError | undefined)?.is("OutOfStock");

  return (
    <>
      <button disabled={running} onClick={() => buy({ bookId: id })}>
        Buy
      </button>
      {soldOut && <span role="alert"> Sold out</span>}
    </>
  );
}

useCommand gives each purchase its own idempotency key, so a retried request never buys twice. The book the command returns updates the cache, and every component showing that book re-renders. OutOfStock is declared in the schema, so the component checks for it by name.

7. Run it ​

With the server running on port 4000:

sh
npm run dev

Open http://localhost:5173.

Next ​

Released under the Apache-2.0 license.