Skip to content

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. When you build a command or query in C#, Arc generates a typed TypeScript proxy for it. On the frontend you import that proxy and call its .use() hook — the request shape, the response shape, and the validation rules all come along, type-checked end to end. Change the C# and the generated proxy changes with it, so the compiler catches the drift instead of your users.

proxy generator

.use() hook

executes / observes

C# command / query

Generated TS proxy

React component

Arc endpoint

You write the backend once. The proxy generator runs on build and emits a typed client. 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.

A command is an intent to change something — open an account, check out a book. You define it in C#; Arc generates a proxy whose static .use() hook gives you a reactive instance, change tracking, and execution.

// Backend — the command and the read model it serves live together
[Command]
public record OpenAccount(AccountId Id, AccountHolder Owner)
{
public Task Handle(IMongoCollection<Account> accounts) =>
accounts.InsertOneAsync(new Account(Id, Owner));
}

The .use() hook returns a tuple — the reactive command instance, then a setter for updating several properties at once. Set a property and the component re-renders; call command.execute() and check result.isSuccess. The validation you declare on the command is surfaced on the proxy, so invalid input is caught on the client before the request ever leaves the browser.

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 QueryResultWithStateaccounts.data holds the rows, with isPerforming, hasData, and validation state alongside. When a command writes and the AllAccounts read model changes, every browser observing this query re-renders with the new list — no refresh, no refetch. That live loop, from a C# command to a React list, is the payoff of building the whole stack on Arc.

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
HooksThe default. Forms, lists, and most screens — less boilerplate, state stays in the component.
MVVM for ReactComplex 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.