Overview
Every route node on the SDK client carries queryOptions and mutationOptions beside the call. They return the plain objects useQuery and useMutation take, with a cache key derived from the route and its input, so no call site writes a key by hand. Nothing here imports TanStack, so the library stays an optional peer a non-React consumer never resolves.
npm install @tanstack/react-queryReads
import { useQuery } from "@tanstack/react-query";
import { dalp } from "./dalp-client";
function TokenList() {
const { data, isPending, error } = useQuery(dalp.api.token.list.queryOptions({ page: { limit: 50 } }));
if (isPending) return <Spinner />;
if (error) return <ErrorState error={error} />;
return <Table rows={data.data} total={data.meta.total} />;
}dalp is the browser client from Install and configure, exported from ./dalp-client, and Spinner, ErrorState and Table stand for your own components. queryOptions takes the same flat input the call takes and returns { query. The query's own abort signal is passed through to the request, so a component that unmounts cancels its fetch without you wiring anything. Add whatever TanStack options you want by spreading:
useQuery({
...dalp.api.token.list.queryOptions({ page: { limit: 50 } }),
staleTime: 30_000,
placeholderData: keepPreviousData,
});Cache keys
A cache key must distinguish different requests and match equivalent requests. A mismatch can leave stale data. A collision can return data for the wrong input.
A key is the route id, then the canonicalised input:
["dalp", "token/list", { page: { limit: 50 } }];canonicalize sorts object keys recursively and drops undefined values before the key is built. That is what makes two callers passing { page: { limit: 50, offset: 0 } } and { page: { offset: 0, limit: 50 } } share one cache entry instead of fetching twice. Dropping undefined matters for the same reason: { page: { limit: 50 } } and { page: { limit: 50 }, filter: undefined } describe the same request, and a literal key would treat them as two.
A route with no input has a two-element key:
["dalp", "party.users.me"];Because the route id comes before the input, a prefix match invalidates a whole route regardless of its parameters, and the "dalp" head keeps DALP entries from colliding with anything else in the same cache.
The key holds no acting context and no credential. When the user switches organisation or signs out, remove the DALP entries with query as well as calling dalp., or the next screen can show the previous organisation's rows.
To build a key without building options, import the helper:
import { queryKeyFor } from "@settlemint/dalp-sdk/react-query";
queryKeyFor("token/list", { page: { limit: 50 } });The key has no version segment, because this SDK targets v2 only.
Writes
import { useMutation, useQueryClient } from "@tanstack/react-query";
function TransferButton({ tokenAddress }: { tokenAddress: string }) {
const queryClient = useQueryClient();
const transfer = useMutation({
...dalp.api.token.transfer.mutationOptions(),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["dalp", "token/list"] });
},
});
return (
<Button
disabled={transfer.isPending}
onClick={() => transfer.mutate({ assetId: tokenAddress, transfers: [{ recipient, amount }] })}
>
Transfer
</Button>
);
}mutationOptions takes call options rather than input, because the input arrives at mutate time. It returns { mutation.
The mutation factory sets its own settlement bound of 30 seconds, because a mutation hook holds a spinner for the whole wait. UI_SETTLEMENT_TIMEOUT_MS from @settlemint/ exports the value. Override it if your case differs:
dalp.api.token.transfer.mutationOptions({ settlementTimeoutMs: 60_000 });isPending is true for the whole wait, not only the request, and stays true until the mutation resolves with the settled result or a 202 handle.
Idempotency keys for separate submissions
A mutation binds its options once and can fire many times. A literal header object is therefore evaluated once, which pins every submission to the first key it produced. Pass a function so each new submission gets a key:
dalp.api.token.transfer.mutationOptions({
headers: () => ({ "Idempotency-Key": crypto.randomUUID() }),
});Use this factory for separate submissions with automatic mutation retry disabled. To retry one submission, generate the key once outside the factory and retain it across attempts.
After a write
A settled write changes what reads return. Invalidate by prefix, which is why the key shape matters:
// One route, every parameter combination.
await queryClient.invalidateQueries({ queryKey: ["dalp", "token/list"] });
// One route with one specific input.
await queryClient.invalidateQueries({ queryKey: dalp.api.token.read.queryOptions({ assetId: tokenAddress }).queryKey });
// Everything DALP.
await queryClient.invalidateQueries({ queryKey: ["dalp"] });onSuccess runs for both a settled result and a 202 handle. Check which one it received before invalidating reads that require the settled state. For a pending handle, follow its status and invalidate those reads after settlement.
Prefer the narrowest invalidation that is still correct. Invalidating everything is correct and expensive, and on a busy screen it is visible as a wave of refetches.
Errors in components
The error a query or mutation surfaces is the same DalpApiError a direct call throws, so error.why and error.fix are available to render and error.id is what to branch on. The full contract is under Errors.
For retry behaviour, defer to retryable rather than to a status code:
useQuery({
...dalp.api.token.list.queryOptions({ page: { limit: 50 } }),
retry: (failureCount, error) => isDalpApiError(error) && error.retryable && failureCount < 3,
});Do not turn on automatic retry for mutations. A stable idempotency key alone does not make a route replay-safe, and TanStack Query does not retry a mutation by default.
Validating input before a write
The SDK does not validate request input, so validating a form before submitting it is your choice of library rather than something the client imposes. Success responses are different: the client checks each one against the route's schema. Validate the shape you are about to send, then send it:
const parsed = TransferForm.safeParse(formValues);
if (!parsed.success) return showFieldErrors(parsed.error);
await transfer.mutateAsync({ assetId: tokenAddress, ...parsed.data });The platform validates the input again on arrival, and its check decides. Client-side validation gives the user an answer without a round trip.
Next
Rendering on a server: Server-side rendering.