Skip to content

Use the generated client from React

Generating a TypeScript client is only half the promise. The other half is that the client is pleasant to use — that a command is a class you construct and execute, that a live query is a hook, and that neither one makes you write a URL, a request body, or a response type by hand.

This guide is about that half. TypeScript proxies covers producing the files; everything here assumes you already have them.

Generation writes into the directory you point it at:

cratisArc {
proxies {
outputDirectory.set(file("../frontend/src/generated"))
}
}

Treat that directory as build output. Do not edit it, do not commit it, and regenerate it whenever the backend changes — a generated client that is one build behind the server it talks to is worse than no generated client, because it compiles.

Install the runtime packages the generated code imports:

Terminal window
npm install @cratis/arc @cratis/arc.react @cratis/fundamentals

Everything below needs an Arc context. It carries the transport choice, the base path, and the callback that supplies request headers:

import { Arc } from '@cratis/arc.react';
createRoot(document.getElementById('root')!).render(
<Arc development={true}>
<App />
</Arc>
);

That is the whole setup for a single-origin application. If your page is served from somewhere other than the backend — a Vite dev server, typically — read Serving the page from a dev server before you go further, because one thing does need saying out loud.

A generated command is a class with a use() hook. The hook hands back the command instance and a setter for its properties:

import { CreateTask } from './generated/CreateTask';
export const CreateTaskForm = () => {
const [command, setValues] = CreateTask.use();
const create = async () => {
const result = await command.execute();
if (result.isSuccess) {
setValues({ title: '' });
}
};
return (
<>
<input value={command.title ?? ''} onChange={event => setValues({ title: event.target.value })} />
<button onClick={create}>Create</button>
</>
);
};

execute() returns the same envelope the HTTP endpoint returns, already typed: isSuccess, response, validationResults, isAuthorized. A rejected command is not an exception — it is a result you render:

const result = await command.execute();
if (!result.isSuccess) {
setErrors(result.validationResults.map(validation => validation.message));
}

Each validation result carries the members it applies to, so you can put the message next to the field that caused it rather than in a banner at the top of the form.

command.validate() runs the same validation without the side effect, which is what you want on blur rather than on submit.

A query method that returns a plain value or a list generates a one-shot query. use() performs it and re-performs it when its arguments change:

import { ById } from './generated/features/queryshowcase/ById';
const [result] = ById.use({ id });
if (result.isPerforming) return <p>Loading…</p>;
return <p>{result.data.name}</p>;

result.hasData tells you whether anything arrived, result.isAuthorized whether the caller was allowed, and result.data is typed from the read model.

A query method returning a Flow — or a Flow.Publisher in Java — generates an observable query. The hook looks almost identical, and that is the point:

import { All } from './generated/features/changestream/All';
const [result] = All.use();

There is no polling loop, no refetch after a mutation, no cache to invalidate. A command that changes the underlying data causes a push, and every component subscribed to that query re-renders. A change made by another browser tab, or by curl, arrives the same way.

Several components calling All.use() share one subscription through the query instance cache, so ten panels on a page cost one connection and one push, not ten.

When you care about the delta rather than the whole collection, useChangeStream reports it:

const changes = All.useChangeStream(item => item.id);
// changes.added, changes.replaced, changes.removed

The key selector is what makes identity meaningful — without it Arc can only compare whole items.

React hooks cannot be called conditionally, which is awkward when a query needs an argument the page does not have yet. when(condition) is the way out:

const [result] = ById.when(selectedId !== undefined).use({ id: selectedId });

While the condition is false, the hook sends no request and starts no subscription of its own. It is not a request whose result is thrown away. The hook can still create a query instance and use the shared query cache, and a subscription another component already holds stays open. The shared conditional queries guide covers the exact rules. Use it for the query that needs a selection, and for the query that needs a signed-in user:

const identity = useIdentity();
const [result] = Authenticated.when(identity.isSet).use();

useIdentity() reads what /.cratis/me returned — the identity your IdentityDetailsProvider produced, typed:

import { useIdentity } from '@cratis/arc.react/identity';
const identity = useIdentity<YourIdentityDetails>();
identity.isSet; // resolved and authenticated
identity.name;
identity.isInRole('administrator');
identity.details; // typed as YourIdentityDetails

A bare useIdentity() types details as object. The generic argument only changes the TypeScript type. To hydrate complex values such as a Guid into real instances, declare the details class with @field decorators and pass it once as <Arc detailsType={YourIdentityDetails}>. The shared identity guide describes both steps.

In development the page usually comes from Vite on one port while Arc runs on another. Proxy Arc’s two path prefixes so the browser still sees a single origin:

export default defineConfig({
server: {
proxy: {
'/api': { target: 'http://localhost:8080', ws: true },
'/.cratis': { target: 'http://localhost:8080', ws: true }
}
}
});

ws: true matters on both: observable queries upgrade to a WebSocket under /.cratis. The backend registers those WebSocket routes only when Spring WebSocket is on its classpath, as described in Consume an observable query.

A one-shot query can carry credentials in a header, because fetch can set headers. EventSource and the WebSocket handshake cannot. If your authentication reads a header only, every subscription will quietly drop to anonymous while ordinary requests keep working — which is a confusing way to find out.

Make sure whatever produces your ArcPrincipal reads a cookie as well as a header. The sample’s ArcPrincipalFactory does exactly that, and is a reasonable shape to copy.

Credentials are captured when a query instance is created, and an observable subscription is authorized once, at handshake. Changing the signed-in user therefore has to reach further than the next request: remount the Arc context and drop the shared connection, or you will keep sending the previous principal.

The sample frontend shows the whole shape — a key on the Arc element, IdentityProvider.clearIdentityCookie(), and resetSharedMultiplexer().

Every pattern on this page is exercised by a runnable application:

Terminal window
./Samples/run.sh

It starts the Kotlin backend, regenerates the proxies from it, and opens a React frontend against them. --language java runs the same frontend against the Java backend unchanged. See the samples for the full list of pages and what each one demonstrates.