Skip to main content

From React

Reboot generates code that allows web or mobile React components to easily query and mutate Reboot data types.

Overview

Consider a ChatRoom API definition with one data type, ChatRoom, one reader, and one writer method.

import { reader, writer } from "@reboot-dev/reboot-api";
import { z } from "zod/v4";

export const ChatRoom = {
state: {
messages: z.array(z.string()).default([]).meta({ tag: 1 }),
},
methods: {
// Returns the current list of recorded messages.
messages: reader({
request: {},
response: {
messages: z.array(z.string()).meta({ tag: 1 }),
},
}),
// Adds a new message to the list of recorded messages.
send: writer({
request: {
message: z.string().meta({ tag: 1 }), // E.g. "Hello, World".
},
response: z.void(),
}),
},
};

export const api = {
ChatRoom,
};

The rest of this document will discuss the React code that is generated from your API definition.

Installation

To access Reboot from your React app, add @reboot-dev/reboot-react to your package.json.

npm install -S @reboot-dev/reboot-react
info

To see the complete code referenced in this page, go to the 'reboot-hello' example.

To have the rbt command line utility generate React code when running rbt generate, add the --react flag to .rbtrc. For example:

generate --react=frontend/api

This flag specifies where to put the generated React code. The examples generate into frontend/api/, a sibling of the app in frontend/web/, so that every frontend of an application (web, mobile, MCP UIs) shares one generated client. Some React frameworks prevent referencing files outside the app's root directory by default; for Vite, allow it with server.fs.allow: [".."] in vite.config.ts.

Setup

All calls to Reboot generated code must occur within a Reboot context. This context is provided by a RebootClientProvider. It takes a url prop that defines the endpoint the generated React code will use to connect to the Reboot backend.

<RebootClientProvider url={"http://localhost:9991"}>
<App />
</RebootClientProvider>

All generated Reboot React Custom Hooks can now be used inside <App /> or any of App's children.

tip

Use a TLS endpoint so the browser can use HTTP/2. Over plain HTTP, every reactive reader holds its own WebSocket, and browsers allow only around 200 of those per page (255 in Chrome). See the rbt CLI for local certificates.

In practice, it is best to use an environment variable for your API endpoint.

For example:

(import.meta.env.VITE_REBOOT_URL as string) || "http://localhost:9991";

How environment variables are managed in your codebase depends on the React framework or library you use.

info

Signing users in

When your application configures oauth=, three more hooks come into play:

  • useSignIn() and useSignOut() from @reboot-dev/reboot-react.
  • The generated use<User>() hook for your User type, which resolves the signed-in user with no ID passed.
const { user, isLoading } = useUser();
const signIn = useSignIn();

if (isLoading) return <p>Checking session…</p>;
if (user === undefined) return <button onClick={() => signIn()}>Sign in</button>;

See Web apps for the browser flow, and React Native apps for the native one.

In MCP UIs

The same generated hooks work unchanged inside an MCP client, where a UI method renders your component. A few things differ:

  • No url prop needed. Inside an MCP client, RebootClientProvider automatically detects the environment. You do not pass a url:
<RebootClientProvider>
<ClickerApp />
</RebootClientProvider>
  • Automatic ID resolution. For User methods, the hook resolves the state ID from the authenticated user automatically. For other types (like Counter), the ID is provided by the framework when the UI is opened. Called without an id, the hook returns { counter, isLoading }, where counter is undefined until the ID resolves, so check both before using the handle (see UI methods for a complete component):
export const ClickerApp: FC = () => {
const { counter, isLoading } = useCounter();

if (isLoading) {
return <div>loading...</div>;
}

// `isLoading` is checked first: while it is true, `counter` is
// still `undefined`. Reaching this check with `undefined` therefore
// means resolution finished and there is genuinely no default
// Counter id.
if (counter === undefined) {
console.error("No default Counter id was available; cannot render.");
return <div>An error occurred, sorry about that!</div>;
}

return <Clicker counter={counter} />;
};

const Clicker: FC<{ counter: UseCounterApi }> = ({ counter }) => {
const [isPending, setIsPending] = useState(false);
const { response, isLoading } = counter.useGet();

The resolved counter handle exposes the same API as useCounter({ id }) — e.g., counter.useGet() above, or calling a mutator with counter.increment({ amount: 1 }).

  • UI method props. For UI methods with a request type, the AI-provided fields are passed to the React component as props:
export const DashboardApp: FC<DashboardConfig> = ({ personalizedMessage }) => {
const { counter, isLoading } = useCounter();

The DashboardConfig props type is imported from the same generated file as useCounter.

Reading state

You can call a reader reactively very simply:

const { useMessages, send } = useChatRoom({ id: STATE_MACHINE_ID });
const { response } = useMessages();

Let's break this down.

useChatRoom is a generated React custom hook that provides access to all of the methods defined on the ChatRoom state data type. The id that is passed is the ID that uniquely identifies your state.

For every reader method, a React custom hook is generated, e.g., useMessages. Any time the ChatRoom state with the given id changes, response is updated and the component re-renders.

In this specific case, useMessages can be called with no arguments because Messages takes an empty request.

Mutating state

Mutators such as writer and transaction methods are both callable from React.

const { useMessages, send } = useChatRoom({ id: STATE_MACHINE_ID });

Reboot methods are accessed by their name in lower camel case, e.g., deleteAllMessages.

This line calls the writer method declared as send in the API definition, using its lower camel case name:

const { aborted } = await send({ message: message });

In the example above, send is passed a partial but it will also happily take a SendRequest as an argument instead.

Optimistic updates

To provide a snappy user experience, it is common to optimistically render the result of a mutation before the result has been committed.

Reboot attaches all in-flight mutations to a .pending property of every mutator to facilitate this, for example:

{send.pending.map(({ request: { message }, isLoading }) => (
<PendingMessage text={message} isLoading={isLoading} key={message} />
))}

In this example, all in-flight mutations are rendered using a PendingMessage wrapper that gives the user an indication that their message has been sent but not yet received.

You can be sure that a mutation is either pending, has been applied, or has failed (these are mutually exclusive).

info

Assume there is a mutation denoted 'mutation-xy'. As soon as response sees a version of state that has committed 'mutation-xy', 'mutation-xy' is atomically removed from .pending. No need to worry about data races!

Errors

Every call to a mutator returns both a response and an aborted; successful calls will ensure aborted is undefined.

Learn more about Reboot errors and error types.

const { aborted } = await send({ message: message });
if (aborted !== undefined) {
console.warn(aborted.error.getType());
console.warn(aborted.message);
}

In this case, because the send method does not define any specific error types, the only error that can be returned in the aborted object will be Reboot system errors such as StateAlreadyConstructed.

info

The aborted returned is not an exception and the generated React code does not throw.

Caching reads

Readers can be cached and persisted in the frontend for longer durations. In case the frontend is disconnected from the network or the Reboot backend when loading, the response for a reader will be read from an IndexedDB or as a fallback from the localStorage of the application's browser. This enables PWAs to start up in disconnected mode by showing the most recent persisted reader response and keeps your application in a readable state if the backend is not available.

Enable reader response caching by setting offlineCacheEnabled to true at the RebootClientProvider. Cache entries have no TTL set, that means they won't expire as long as the used OfflineCacheStorageType can hold those entries, which means usually forever.

<RebootClientProvider url={"http://localhost:9991"} offlineCacheEnabled>
<App />
</RebootClientProvider>

To determine the auto-detected OfflineCacheStorageType, you can check on offlineCacheStorageType, which is exported by your generated React hooks file. Possible values are IndexedDB, LocalStorage and InMemory.

InMemory is ephemeral when the browser tab closes and is only used, if the other types are not available.

info

Caching is disabled by default, as data may contain sensitive information and the browser's storage is potentially vulnerable to XSS attacks. You explicitly need to enable caching at the RebootClientProvider.

Next.js and Server Components

Using Reboot-generated TypeScript on the server and Reboot-generated React on the client allows you to create high-performance applications that show your users all the data they need on first-paint, while also allowing for all of the amazing reactive features that come with the Reboot React client-side library.

The Reboot React library is composed of client-side custom hooks that provide reactivity whenever Reboot state changes occur. In the case that you want to do something server-side with Reboot and React before returning to the client, use the Reboot-generated server-side TypeScript.

For instance, you might want to make a call to Reboot state on the server first, render HTML with data included and then send it to the client. This allows you to send a non-interactive and non-reactive page to the user that then becomes interactive and reactive upon hydration.

This can be achieved by combining Reboot TypeScript backend-generated code and Next.js Server Components.

tip

To see the complete example, check out the Reboot Counter example repo.

On the server, the code that is typically generated for TypeScript backends can be used to fetch initial data. In this case, we call .count(), a non-reactive, unary reader method, to supply the initial value.

Importantly, we also need to construct an ExternalContext to make this call safely.

export default async function Home() {
const context = new ExternalContext({
name: "react server context",
url: process.env.NEXT_PUBLIC_ENDPOINT,
});

const counts = await Promise.all(
COUNTER_IDS.map(async (id: string) => Counter.ref(id).count(context))
);

return COUNTER_IDS.map((id, index) => (
<TakeableCounter id={id} key={id} initialCount={counts[index].count} />
));
}

The main difference from a client-only component is that TakeableCounter now takes an initialCount prop that the server is expected to provide.

The client-side call is completed as normal, but the data can be rendered immediately because it is passed into the component as a prop.

const TakeableCounter: React.FC<{ id: string; initialCount: number }> = ({
id,
initialCount,
}) => {
const { useCount, increment, take } = useCounter({ id });
const { response } = useCount();

Now it is easy to render either the initialCount that was passed in initially or the current reactive value of the response.count object.

count={response ? response.count : initialCount}

To learn more about React Server Components, refer to the React Server Components docs or, for information specific to Next.js, visit Next's page on Server Components.

Vanilla JavaScript

Reboot automatically transpiles TypeScript into JavaScript by way of esbuild. If you wish to use a tool other than esbuild for transpilation, you can pass a command to the --transpile flag in your .rbtrc.

--transpile=npx tsc

Programming model

Reboot React provides conveniences beyond querying and mutating states, chief among them:

  1. Guaranteed local ordering of mutations.
  2. Automatic retries.

Ordering

The order in which the mutations are called on the client is the order in which they will be executed on the server. From the client's perspective, a 'happens-before' relationship is maintained.

Assume a client performs the following mutations A -> B -> C. Another client might be performing D -> E simultaneously. A possible global ordering might be A -> B -> D -> E -> C or D -> A -> E -> B -> C, but never D -> B -> E -> A -> C.

State mutations that have occurred while a client's mutations are in-flight won't be shown to the client until those mutations have committed or result in an error.

Automatic retries

All calls are retried if they result in any retryable error, such as a temporary network outage. These retries are idempotent, so this is perfectly safe to do.

An error is considered unretryable if it originates from the application. All other errors are retried with an exponential backoff.