Frontend
Building a React frontend against a backend usually means writing the same plumbing over and over: a
fetch call here, a hand-written request type there, validation rules copied from the server, and a
pile of state to track whether the request is in flight, succeeded, or failed. Every time the backend
changes, you go hunting for the frontend code that drifted out of sync.
Arc removes that whole layer. C#, JVM, and the TypeScript source preview
can generate typed TypeScript proxies from backend commands and queries. On the
frontend you import a proxy and call its .use() hook — request and response
types and supported client-safe validation rules come along. Regenerate after a
backend change and type-check the frontend to catch structural drift. The
frontend uses the same @cratis/arc runtime, but backend capabilities and
generator coverage differ. Check the TypeScript capability reference
before applying a C# or JVM example to the preview.
How the pieces fit
Section titled “How the pieces fit”You write the backend once. Configure and run that backend’s proxy generator to emit a typed client;
TypeScript generation is an explicit source-based step, not implicit in every backend build. Your React
code consumes it through each proxy’s static .use() hook — and because the types flow across the
boundary, there is no DTO to keep in sync and no untyped JSON to second-guess.
Execute a command from React
Section titled “Execute a command from React”A command is an intent to change something — open an account, check out a book. You define it on
the backend; Arc generates a proxy whose static .use() hook gives you an instance, explicit React value updates, change tracking,
and execution. The following backend fragment assumes the application’s AccountId, AccountHolder, Account, and its persistence registration; it is not a complete backend bootstrap.
[Command]public record OpenAccount(AccountId Id, AccountHolder Owner){ public Task Handle(IMongoCollection<Account> accounts) => accounts.InsertOneAsync(new Account(Id, Owner));}@Commandpublic record OpenAccount(AccountId id, AccountHolder owner) { public void handle(@FromServices AccountRepository accounts) { accounts.save(new Account(id, owner)); }}@Commanddata class OpenAccount(val id: AccountId, val owner: AccountHolder) { suspend fun handle(@FromServices accounts: AccountRepository) { accounts.save(Account(id, owner)) }}import { field } from '@cratis/fundamentals';import { command, inject } from '@cratis/arc.core';
@command()export class OpenAccount { @field(AccountId) id!: AccountId; @field(AccountHolder) owner!: AccountHolder;
@inject(AccountRepository) handle(accounts: AccountRepository): Promise<void> { return accounts.save({ id: this.id, owner: this.owner }); }}The generated clients use the same frontend runtime, so the React side below is written once:
// Frontend — the generated proxy, driven by its static .use() hookimport { useState } from 'react';import { Guid } from '@cratis/fundamentals';import { OpenAccount } from './api/accounts/OpenAccount';
export const OpenAccountForm = () => { const [id] = useState(() => Guid.create()); const [command, setValues] = OpenAccount.use({ id, owner: '' }); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [message, setMessage] = useState('');
const submit = async () => { setSaving(true); try { const result = await command.execute(); setSaved(result.isSuccess); setMessage(result.isSuccess ? 'Account opened.' : 'Could not open account. You can retry.'); } finally { setSaving(false); } };
return ( <> <input value={command.owner ?? ''} disabled={saving || saved} onChange={(e) => setValues({ owner: e.target.value })} /> <button onClick={submit} disabled={saving || saved}>Open account</button> <p role="status">{message}</p> </> );};The .use() hook returns [command, setValues, clearValues]. Use the setter for reliable per-edit React rendering; raw assignment is change tracking, not a guarantee of every render or automatic validation. Set required properties explicitly: here a Guid-based AccountId needs a Fundamentals Guid, created once for this operation. A reusable create form must start a fresh operation after success, as the getting-started checkpoint shows.
Call command.execute() and check result.isSuccess. The button tracks in-flight and successful completion explicitly, not hasChanges: a rejected request can update the change-tracking baseline, so that flag alone can disable a needed retry. See execution baseline limitations. Supported projected validation rules run on the client; server validation remains authoritative. Not every backend rule can be projected.
Show live data with an observable query
Section titled “Show live data with an observable query”A query reads data. An observable query keeps reading — its .use() hook holds a live connection and
re-renders your component whenever the underlying read model changes, so your UI stays current without
polling.
import { AllAccounts } from './api/accounts/AllAccounts';
export const AccountsList = () => { const [accounts] = AllAccounts.use(); // observable: re-renders as the read model changes
return ( <ul> {accounts.data.map((account) => ( <li key={String(account.id)}>{account.owner}</li> ))} </ul> );};The result is a QueryResultWithState — accounts.data holds the rows, with isPerforming, hasData,
and validation state alongside. The backend must supply an emitting observable source, such as MongoDB Observe() on C# or an ObservableSource on TypeScript. When that source emits changed data, subscribed consumers receive the new list without polling. A command write alone does not establish this live connection; see backend observable queries.
Two ways to build: hooks or MVVM
Section titled “Two ways to build: hooks or MVVM”Most screens are simplest with the hooks shown above — they keep state in the component and read naturally. For complex, stateful screens you can opt into a Model-View-ViewModel structure that moves logic into a view model class.
| Reach for… | When |
|---|---|
| Hooks | The default. Forms, lists, and most screens — less boilerplate, state stays in the component. |
| MVVM for React | Complex screens with substantial view logic you want to test and reuse apart from the markup. |
Start with hooks; move a screen to MVVM only when its logic outgrows the component.