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
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
RebootClient
as its client
prop and defines the endpoint the
generated React code will use to connect to the Reboot backend.
<React.StrictMode>
<RebootClientProvider client={client}>
<App />
</RebootClientProvider>
</React.StrictMode>
All generated Reboot React Custom Hooks can now be used inside <App />
or
any of App
's children.
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,
the argument passed to RebootClient
's constructor.
For example:
const client = new RebootClient(
process.env.REACT_APP_REBOOT_ENDPOINT as string
);
How environment variables are managed in your codebase depends on the React framework or library you use.
Reboot generates TypeScript code. Learn how to use Reboot in a vanilla JS environment.
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).
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
.
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.
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 generates typed code from the data types outlined in your protobuf file. These types are incredibly useful but they aren't strictly necessary in a JavaScript environment. You can use Reboot in a vanilla JS environment by taking the extra step of compiling the generated TypeScript to JavaScript.
While vanilla JS practitioners might find this extra step burdensome, it can be used as an opportunity to target a specific version of JavaScript.
To make sure this happens on every change, use npx tsc
with the
--watch
flag enabled or use the --transpile
flag in your .rbtrc
and a
tsconfig.json
correctly configured for the web.
Programming model
Reboot React provides conveniences beyond querying and mutating states, chief among them:
- Guarantee the local ordering of mutations.
- 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.