TanStack Query
Every route carries its own query and mutation factories, so a cache key derives from route identity and canonicalized input instead of hand-written keys.
Overview
TanStack Query needs two things from a data layer: a stable key and a function that fetches. Writing those by hand at every call site is where cache bugs come from, because a key that differs by one character is a second cache entry and a key that collides is one entry serving two things.
The SDK derives both. Every route node carries queryOptions and mutationOptions beside the call, and they return the plain object shapes TanStack expects. 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} />;
}queryOptions takes the same flat input the call takes and returns { queryKey, queryFn }. 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 canonicalized 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", "user/me"];Which means 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.
To build a key without building options, import the helper:
import { queryKeyFor } from "@settlemint/dalp-sdk/react-query";
queryKeyFor("token/list", { page: { limit: 50 } });There is no version segment in the key. The Platform API surface is v2 and only v2, so a literal version would discriminate nothing while reading as something a caller could vary.
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 { mutationKey, mutationFn }.
The mutation factory sets its own settlement bound, because a mutation hook holds a spinner for the whole wait and a long spinner is not an interface. 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. That is usually what you want on a button, and it is worth knowing when you are timing something.
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,
});Mutations should generally not retry automatically. A stable idempotency key alone does not make a route replay-safe; TanStack's default of no mutation retry is the right one to leave alone.
Validating input before a write
The SDK ships no runtime schemas, so validating a form before submitting it is your choice of library rather than something the client imposes. 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 it again on arrival, and that is the check that counts. Client-side validation exists to give the user an answer without a round trip, not to be the authority.
Next
Rendering on a server: Server-side rendering.
Streaming routes
Read streaming routes as async iterables through the DALP TypeScript SDK, with decoded events, cancellation, and error handling.
Server-side rendering
On a server render the browser holds the session and the server does not, so the SDK forwards the request's headers under an origin allowlist.