Skip to main content

React

So far, these docs have discussed Reboot as a cloud-native backend. Reboot also generates code that allows web or mobile React components to easily query and mutate Reboot state.

Overview

Consider hello.proto with one state data type, Hello, one reader, and one writer method.

message Hello {
option (rbt.v1alpha1.state) = {
};
repeated string messages = 1;
}

service HelloMethods {
// Returns the current list of recorded messages.
rpc Messages(MessagesRequest) returns (MessagesResponse) {
option (rbt.v1alpha1.method).reader = {
};
}

// Adds a new message to the list of recorded messages.
rpc Send(SendRequest) returns (SendResponse) {
option (rbt.v1alpha1.method).writer = {
};
}
}

message MessagesRequest {}

message MessagesResponse {
repeated string messages = 1;
}

message SendRequest {
string message = 1; // E.g. "Hello, World".
}

message SendResponse {}

The rest of this document will discuss the React code that is generated from from this .proto file.

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 protoc, add the --react flags to .rbtrc. For example:

protoc --react=web/src/api

This flag specifies where to put the generated React code. It often makes sense to have these files written inside the root directory of your React app, as shown above, because many React frameworks prevent referencing files outside the root directory.

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 to take advantage of HTTP/2 multiplexing, a requirement for any Reboot React app with more than ~100 outstanding RPC calls, e.g., reactive readers or mutations.

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

For example:

const url =
(process.env.REACT_APP_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

Reading state

You can call a reader reactively very simply:

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

Let's break this down.

useHello is a generated React custom hook that provides access to all of the methods defined on the Hello 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 Hello 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 } = useHello({ 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 in the .proto as Send using the lower camel case name send:

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 (they 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 in hello.proto 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.

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 the 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. In that 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.lookup(id).count(context))
);

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

The main change 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 immediately be rendered because it is passed into the component as a prop.

const TakeableCounter: 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 on the response.count object.

count={response ? response.count : initialCount}

To learn more about React Server Components, take a look at the React Server Components docs or, for Next.js specific information, 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. Guarantee the 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 for any error that is retryable, like a temporary network outage. These retries are idempotent, so this is perfectly safe to do.

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