Skip to content

Build a LOC form with the React hooks

Outcome: a React form in your own markup that quotes the collateral a Dynamic LOC needs, reports issues field by field as the user types, and creates the LOC through a wallet workflow whose progress and outcome your components render.

You need: React 18.2+ or 19 (the peer range is ^18.2.0 || ^19.0.0), package access, a Sepolia RPC URL, and a browser wallet with Sepolia test ETH and a supported test token. The ready-made form in Add a create-LOC form runs on the same hooks with the markup already written. This guide is for when the markup is yours.

Provide the SDK once

Every hook reads one AnvilSDK from context. Build it once with a public client and a profile, wrap the tree in AnvilProvider, and give it a wallet client only when a write is about to happen:

// One instance for the whole app. Hooks key their cache on it, so build
// it once (module scope or useMemo), never inside render.
const sdk = new AnvilSDK({
publicClient: createPublicClient({
chain: sepolia,
transport: http('https://your-rpc-url'),
}),
profile: testnetProfile,
});
async function connect() {
const provider = window.ethereum;
if (!provider) throw new Error('No browser wallet found.');
const [account] = await createWalletClient({
chain: sepolia,
transport: custom(provider),
}).requestAddresses();
if (!account) throw new Error('The wallet returned no account.');
// Reads never needed a wallet; writes do. Swapping the wallet client in
// place keeps every hook's cache and any workflow already prepared.
const transport = custom(provider);
sdk.updateWalletClient(
createWalletClient({ account, chain: sepolia, transport })
);
}
export function App() {
const [connected, setConnected] = useState(false);
const onConnect = () => void connect().then(() => setConnected(true));
return (
<AnvilProvider sdk={sdk}>
{connected ? (
<CreateDynamicLOC />
) : (
<button onClick={onConnect}>Connect wallet</button>
)}
</AnvilProvider>
);
}

Reads work before any wallet connects. updateWalletClient swaps the signer in place, so hooks keep their cache and a workflow prepared before the swap still executes. The provider uses your app’s TanStack Query client when one is rendered above it, and supplies its own otherwise.

Read protocol state

Read hooks wrap the core reads and carry their own loading and error state. A collection hook such as useCollateralTokens pages through a subgraph listing; a value hook such as useRequiredCollateral returns one answer and stays idle until every argument it needs is present:

function CollateralQuote({
collateralToken,
creditedToken,
creditedAmount,
}: {
collateralToken?: Address | undefined;
creditedToken?: Address | undefined;
creditedAmount?: bigint | undefined;
}) {
// Subgraph-backed and paged: `items` grows as pages arrive and
// `fetchNextPage` follows the cursor when `hasNextPage` is true.
const tokens = useCollateralTokens({ enabled: true });
// An undefined argument means "not yet": no request is made and `data`
// stays undefined until every input is present. Re-polls every 30s.
const required = useRequiredCollateral(
creditedAmount,
collateralToken,
creditedToken
);
const collateral = tokens.items.find(
({ token }) => token.address === collateralToken
)?.token;
if (tokens.error) return <p>{tokens.error.message}</p>;
if (required.isLoading) return <p>Pricing…</p>;
if (required.data === undefined) return null;
// null: the pair has no collateral factor, so it cannot back a LOC.
if (required.data === null) return <p>This pair is not enabled.</p>;
const amount = formatUnits(required.data, collateral?.decimals ?? 18);
return (
<p>
Lock at least {amount} {collateral?.symbol ?? 'collateral'}
</p>
);
}

Passing undefined says “not yet”: the hook waits, with no request and no cache entry, until every input is present. useRequiredCollateral re-polls every 30 seconds because the oracle price behind it moves; useTokenPrice and useTokenPairPrice do the same. A null result is an answer rather than a pending read: the pair has no collateral factor, so it can’t back a LOC.

Validate as the user types

useIncrementalValidation runs the same rules the workflow hook runs on submit, one field at a time. Each rule declares the fields it depends on and re-runs only when one of them changes, so editing the Beneficiary leaves the balance check alone:

type Draft = Partial<CreateDynamicLOCParams>;
function useDynamicLOCDraft() {
const sdk = useAnvilSDK();
const [values, setValues] = useState<Draft>({});
const [touched, setTouched] = useState(() => new Set<keyof Draft>());
// The same rule set `sdk.loc.validateCreateDynamic` runs on submit, run
// per field as it changes: a rule re-runs only when one of its
// `dependsOn` fields moves, and async rules (balances, the oracle) are
// debounced and abort when superseded.
const validation = useIncrementalValidation(
sdk,
createDynamicLOCRules,
values,
touched
);
function update<K extends keyof CreateDynamicLOCParams>(
field: K,
value: CreateDynamicLOCParams[K]
) {
setValues(current => ({ ...current, [field]: value }));
setTouched(current => new Set(current).add(field));
}
return { values, update, validation };
}
type FieldErrorsProps = { state: FieldValidationState | undefined };
function FieldErrors({ state }: FieldErrorsProps) {
// Undefined until the field is touched and its rules have run once.
if (!state) return null;
if (state.pending) return <p>Checking…</p>;
return (
<>
{state.errors.map(issue => (
<p key={issue.code}>{getValidationErrorMessage(issue)}</p>
))}
</>
);
}

A field’s state is undefined until it has been touched and its rules have run once, so an untouched form starts clean. pending is true while an asynchronous rule is in flight, and errors holds the issues from the highest-priority rule that failed. getValidationErrorMessage turns an issue into the SDK’s standard sentence for your user. A service failure inside a rule, such as an RPC error, lands on validation.error rather than on a field, so a flaky network leaves valid fields looking valid.

Create the LOC and render the run

A workflow hook such as useCreateLOCWorkflow runs every check, then plans the deposit, approval, and create steps this account actually needs. Hand the prepared workflow to useWorkflowLifecycle, which executes it and reports where the run stands:

function CreateDynamicLOC() {
const { values, update, validation } = useDynamicLOCDraft();
const create = useCreateLOCWorkflow();
const [workflow, setWorkflow] = useState<WalletOperationWorkflow>();
async function submit() {
const {
beneficiary,
creditedTokenAmount,
collateralTokenAmount,
expirationTimestampSeconds,
} = values;
if (
!beneficiary ||
!creditedTokenAmount ||
!collateralTokenAmount ||
!expirationTimestampSeconds
) {
return;
}
// Runs every rule eagerly, then plans the deposit, approval, and
// create steps this account actually needs. No wallet prompt yet.
const result = await create.prepareWorkflow({
kind: 'dynamic',
beneficiary,
creditedTokenAmount,
collateralTokenAmount,
expirationTimestampSeconds,
});
if (result.status === 'prepared') setWorkflow(result.workflow);
}
const reset = () => setWorkflow(undefined);
if (workflow) return <RunWorkflow workflow={workflow} onDone={reset} />;
return (
<form
onSubmit={event => {
event.preventDefault();
void submit();
}}>
<input
placeholder="Beneficiary address"
onChange={event => {
update('beneficiary', event.target.value as Address);
}}
/>
<FieldErrors state={validation.fields.beneficiary} />
{/* creditedTokenAmount, collateralTokenAmount, and expiration
follow the same update / FieldErrors pattern. */}
<CollateralQuote
collateralToken={values.collateralTokenAmount?.tokenAddress}
creditedToken={values.creditedTokenAmount?.tokenAddress}
creditedAmount={values.creditedTokenAmount?.amount}
/>
<button
type="submit"
disabled={!validation.isValid || create.isPreparing}>
Create LOC
</button>
{create.issues.map(issue => (
<p key={issue.field}>{getValidationErrorMessage(issue)}</p>
))}
{create.error && <p>{create.error.message}</p>}
</form>
);
}
function RunWorkflow({
workflow,
onDone,
}: {
workflow: WalletOperationWorkflow;
onDone: () => void;
}) {
// Executes on mount: revalidates against current chain state, then
// prompts the wallet once per operation, and ends in the one settlement
// vocabulary every SDK executor reports through.
const { phase, operations, settlement } = useWorkflowLifecycle(workflow);
const failure = getWorkflowSettlementMessage(settlement);
return (
<>
<p>{phase}</p>
<ol>
{operations?.map(operation => (
<li key={operation.type}>
{operation.type}: {operation.status}
</li>
))}
</ol>
{settlement?.status === 'succeeded' && <p>LOC created.</p>}
{failure && <p>{failure}</p>}
{phase === 'settled' && <button onClick={onDone}>Done</button>}
</>
);
}

prepareWorkflow stays away from the wallet: an invalid result carries the issues, and prepared carries a workflow whose operations list the planned steps before anything runs. Execution starts when the component that calls useWorkflowLifecycle mounts. It validates again against current chain state, prompts the wallet once per operation, and ends in a settlement that every SDK executor reports the same way: succeeded, or another status that getWorkflowSettlementMessage turns into a sentence.

One status needs care. wallet-indeterminate means the send’s outcome is unknown and the transaction may already be onchain, so ask the user to check their wallet activity. Never offer “try again” for it: a second send could repeat an operation that already landed. UnconfirmedTransaction is the screen the SDK’s own LOC flows show in place of the form, and getUnconfirmedSend reads its wording from the settlement.

Keep the component that runs the workflow mounted until the run settles. To show a finished run again later, pass its operations back through the hook’s options, and the hook renders that snapshot rather than executing.

What the SDK handles, and what your app handles

  • The SDK handles the protocol work. The hooks read and cache protocol state, run the same validation rules as the core SDK, plan the wallet steps, validate again before the first prompt, and report every run through one settlement vocabulary.
  • Your app owns the interface and the wallet connection. Your components decide the markup, which fields exist, when a wallet connects, and what the user sees for each settlement.

Reference