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
- React provider and hooks — every hook this guide uses, grouped by what it is for.
useIncrementalValidationand the Validation category — how rules, fields, and issues fit together.WorkflowSettlement— the vocabulary a finished run speaks.- Letter of Credit functionality — who can create, redeem, and cancel, which is what the Beneficiary and expiration fields decide.