> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sumvin.com/llms.txt
> Use this file to discover all available pages before exploring further.

# TanStack Query integration patterns

> How Sumvin's own frontend integrates with the Platform API — recommended hooks, retry strategy, optimistic mutations, and cache invalidation patterns.

## Overview

Sumvin's web app integrates with the Platform API through [TanStack Query](https://tanstack.com/query). The hook patterns documented here are lifted from production code and cover the recommended way to consume `/v0/*` endpoints from a React app: stale-while-revalidate caching, non-retryable error gating, optimistic mutations with rollback, and a query-key invalidation pyramid.

If you're integrating from React, copy these patterns. If you're integrating from another stack, the underlying conventions — RFC 7807 errors, idempotency keys, `x-juno-jwt` auth — are documented in the [authentication guide](/authentication).

<Note>
  All examples use real exports from the production frontend. Hook names, query-key paths, and the `request()` wrapper signature match `src/lib/api/hooks/` 1:1.
</Note>

## Setup

Wrap the app in a `QueryClientProvider`. The Sumvin frontend co-locates this with the JWT provider so query hooks have access to the token via `useAuthEnabled()`.

```tsx theme={null}
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { JWTProvider } from '@/lib/auth/jwt-provider';

export function Providers({ children }: { children: React.ReactNode }) {
  const [client] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return (
    <JWTProvider>
      <QueryClientProvider client={client}>{children}</QueryClientProvider>
    </JWTProvider>
  );
}
```

The `request()` wrapper centralises auth, error parsing, and request deduplication. It expects four positional arguments — `method`, `endpoint`, `options`, `getToken` — and throws a typed `ApiError` on non-2xx responses:

```ts theme={null}
// src/lib/api/core/client.ts (simplified)
export async function request<T>(
  method: HttpMethod,
  endpoint: string,
  options?: RequestOptions<T>,
  getToken?: () => string | null
): Promise<T>;
```

The wrapper sets two headers automatically:

* `x-juno-jwt` — populated from the `getToken()` callback
* `x-sumvin-caller` — populated from `options.caller`, used for backend telemetry. Format: `frontend:<resource>:<action>` (e.g. `frontend:wallets:update`)

`useAuthEnabled()` is the small hook that gates queries on JWT readiness:

```ts theme={null}
// src/lib/auth/use-auth-enabled.ts
export function useAuthEnabled(options?: UseAuthEnabledOptions): UseAuthEnabledResult {
  const { getToken, token, isLoading: isTokenLoading } = useJWT();
  const enabled = !!token && !isTokenLoading && (options?.enabled ?? true);
  return { enabled, getToken };
}
```

Use it in every query that requires auth — without the gate, queries fire before the JWT is ready and get a 401 on every cold load.

## Query patterns

### Basic list query

`useWallets` is the canonical exemplar. It pulls a token, gates on auth, calls `request()` with a typed response, and configures stale-while-revalidate plus retry behaviour:

```tsx theme={null}
'use client';

import { useQuery } from '@tanstack/react-query';
import { useAuthEnabled } from '@/lib/auth/use-auth-enabled';
import { request } from '../../core/client';
import { isNonRetryable } from '../../errors';
import { queryKeys } from '../../query-keys';
import type { WalletFilters, WalletListResponse } from '../../types/wallet';

export function useWallets(filters?: WalletFilters) {
  const { getToken, enabled } = useAuthEnabled();

  return useQuery({
    queryKey: queryKeys.wallets.list(filters),
    queryFn: () =>
      request<WalletListResponse>(
        'GET',
        '/v0/wallets/',
        {
          params: buildWalletParams(filters),
          caller: 'frontend:wallets',
        },
        getToken
      ),
    enabled,
    staleTime: 5 * 60 * 1000,
    retry: (failureCount, error) => !isNonRetryable(error) && failureCount < 3,
  });
}
```

### Detail query with `expand`

Detail queries take an ID (and often `expand[]`) and bake both into the `queryKey` so the cache segments correctly:

<Note>
  The `useIPA` / `useIPAs` hooks (and the `queryKeys.ipas.*` paths) are the **current SDK names for Errand operations** — an Errand is an agent's scope-bound tasking run. The SDK rename to Errand is a separate effort, so these docs keep the existing hook and query-key names verbatim.
</Note>

```tsx theme={null}
export function useIPA(id: string | null) {
  const { getToken, token, isLoading: isTokenLoading } = useJWT();

  return useQuery({
    queryKey: queryKeys.ipas.detail(id ?? ''),
    queryFn: () =>
      request<IPADetailResponse>(
        'GET',
        `/v0/user/ipa/${id}`,
        { caller: 'frontend:ipas' },
        getToken
      ),
    enabled: !!token && !isTokenLoading && !!id,
    staleTime: 2 * 60 * 1000,
    retry: (failureCount, error) => !isNonRetryable(error) && failureCount < 3,
  });
}
```

Note the `!!id` clause in `enabled` — when `id` is `null` (e.g. a detail page with no selection), the query stays idle instead of firing against `/v0/user/ipa/null`.

<Tip>
  When passing `expand` arrays through the API, include them in the query key (`queryKeys.user.me(['wallets', 'kyc_documents'])`). Different expand sets are different cache entries.
</Tip>

## Mutation patterns

### Basic mutation with invalidation

The simplest mutation calls `request()`, then invalidates affected queries on success:

```tsx theme={null}
export function useCreateWallet() {
  const queryClient = useQueryClient();
  const { getToken } = useJWT();

  return useMutation({
    mutationFn: (data: WalletCreateRequest) =>
      request<WalletResponse>(
        'POST',
        '/v0/wallets/',
        { body: data, caller: 'frontend:wallets:create' },
        getToken
      ),
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: queryKeys.wallets.all });
    },
  });
}
```

### Forwarding `Idempotency-Key`

For non-idempotent endpoints (IPA creation, payment-link creation, transaction submission), pass the idempotency key through `options.headers`. The `request()` wrapper merges it into the request headers verbatim. Adapt this shape on top of any existing mutation hook that targets a non-idempotent endpoint:

```tsx theme={null}
// Illustrative — wrap an existing create mutation to accept an idempotency key
// from the caller and forward it as a header on the request().
export function useCreateIPAWithIdempotency() {
  const queryClient = useQueryClient();
  const { getToken } = useJWT();

  return useMutation({
    mutationFn: ({
      data,
      idempotencyKey,
    }: {
      data: CreateIPARequest;
      idempotencyKey: string;
    }) =>
      request<IPADetailResponse>(
        'POST',
        '/v0/user/ipa/',
        {
          body: data,
          caller: 'frontend:ipa:create',
          headers: { 'Idempotency-Key': idempotencyKey },
        },
        getToken
      ),

    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: queryKeys.ipas.all });
      toast.success('Purchase authorisation created');
    },

    retry: (failureCount, error) => !isNonRetryable(error) && failureCount < 2,
  });
}
```

<Note>
  The production `useCreateIPA` hook does not currently take an idempotency key — the snippet above shows the recommended shape for callers that need 208-Already-Reported semantics on retry. The `request()` wrapper accepts arbitrary headers via `options.headers`.
</Note>

<Warning>
  Generate the idempotency key once per logical request — typically when the user opens the form, not when they click submit. Re-generating on submit defeats the purpose: a duplicate submit produces a fresh key, and the backend creates a duplicate resource.
</Warning>

## Stale-while-revalidate

`staleTime` is how long a cached response is considered fresh. While fresh, hooks return cached data instantly without a network request. Once stale, the next mount or window-focus triggers a background refetch — but the cached value is still served immediately. Pick a value based on how fast the underlying data actually changes server-side:

| Domain                                           | Stale time | Hook                                   | Rationale                                                                                    |
| ------------------------------------------------ | ---------- | -------------------------------------- | -------------------------------------------------------------------------------------------- |
| User profile / `/v0/user/me`                     | 5 minutes  | `useUser`                              | Profile fields rarely change mid-session                                                     |
| Wallets / `/v0/wallets/`                         | 5 minutes  | `useWallets`                           | Wallet lists are stable; new wallets are rare                                                |
| Wallet assets / balances                         | 5 minutes  | `useWalletAssets`, `useWalletBalances` | Balance refresh is server-paced; clients can opt into a tighter `refetchInterval` per-screen |
| KYC required docs / `/v0/kyc/documents/required` | 30 seconds | `useKycRequiredDocs`                   | Tight enough that the UI feels live during upload flows                                      |
| IPAs / `/v0/user/ipa/`                           | 2 minutes  | `useIPAs`, `useIPA`                    | Status transitions matter for live UX                                                        |

The `useWallets` hook above sets `staleTime: 5 * 60 * 1000`. The `useIPAs` hook drops to 2 minutes because an Errand's state machine moves through `pending → preflight → awaiting_clarification → executing` and the UI needs to feel live.

<Tip>
  If a screen needs second-by-second freshness (e.g. a balance display during a transfer), set `staleTime: 0` on that specific call rather than dropping the global default — most queries don't need it.
</Tip>

## Error handling

The Platform API returns [RFC 7807 Problem Details](/concepts/hal-rfc7807-idempotency-pagination) on every non-2xx response. The `request()` wrapper parses the JSON body and throws a typed `ApiError`:

```ts theme={null}
export interface ProblemDetail {
  type: string;
  title: string;
  status: number;
  detail: string | ValidationError[];
  instance: string;
  error_code: string;
  trace_id?: string;
}

export class ApiError extends Error {
  constructor(
    public status: number,
    public problem: ProblemDetail,
    public isNetworkError = false,
    public isAlreadyReported = false
  ) {
    super(formatDetail(problem.detail));
    this.name = 'ApiError';
  }
}
```

### `isNonRetryable` — gate retries by status code

Retrying a 4xx is wasted bandwidth and a worse user experience: `401`/`403` won't change without a new token, `404`/`409`/`422` won't change without a different request body. The `isNonRetryable` helper is the single source of truth for which status codes should bypass the retry loop:

```ts theme={null}
export function isNonRetryable(error: unknown): boolean {
  if (error instanceof CircuitOpenError) return true;
  if (error instanceof ApiError) {
    return [400, 401, 403, 404, 409, 422].includes(error.status);
  }
  return false;
}
```

Wire it into every query and mutation that retries:

```ts theme={null}
retry: (failureCount, error) => !isNonRetryable(error) && failureCount < 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 8000),
```

This caps retries at 3 attempts with exponential backoff (1s, 2s, 4s, max 8s). Mutations typically use a lower `failureCount < 2` ceiling.

### Surfacing `error_code` to the UI

The `error_code` field on `ProblemDetail` is stable across versions and safe to map to user-facing copy:

```tsx theme={null}
function WalletErrorBanner({ error }: { error: unknown }) {
  if (!(error instanceof ApiError)) return null;
  const code = error.problem.error_code;

  if (code === 'WAL-403-003') {
    return <Banner>This wallet credential is not verified on your account.</Banner>;
  }
  if (code === 'WAL-409-001') {
    return <Banner>This wallet is already registered to another user.</Banner>;
  }
  return <Banner>{error.message}</Banner>;
}
```

<Note>
  Always check `error_code` rather than parsing `detail` — `detail` is a free-form string (or a list of validation errors) and not safe to switch on.
</Note>

## Optimistic mutations

Optimistic mutations apply the expected change to the cache immediately, before the network round-trip resolves. The user sees zero latency on rename, set-primary, and delete operations. The pattern is three-step: `onMutate` (cancel + snapshot + apply), `onError` (rollback from snapshot), `onSettled` (invalidate to reconcile).

`useUpdateWallet` is the cleanest exemplar in the codebase:

```tsx theme={null}
export function useUpdateWallet() {
  const queryClient = useQueryClient();
  const { getToken } = useJWT();

  return useMutation({
    mutationFn: ({ walletId, ...data }: WalletUpdateRequest & { walletId: string }) =>
      request<WalletResponse>(
        'PATCH',
        `/v0/wallets/${walletId}`,
        { body: data, caller: 'frontend:wallets:update' },
        getToken
      ),

    onMutate: async ({ walletId, is_primary, nickname }) => {
      // 1. Cancel any in-flight refetches that would clobber our optimistic write
      await queryClient.cancelQueries({ queryKey: queryKeys.wallets.all });

      // 2. Snapshot the current cache so onError can roll back
      const previous = queryClient.getQueryData<WalletListResponse>(queryKeys.wallets.list());

      // 3. Apply the optimistic update
      if (previous && (is_primary !== undefined || nickname !== undefined)) {
        queryClient.setQueryData<WalletListResponse>(queryKeys.wallets.list(), {
          ...previous,
          wallets: previous.wallets.map((w) => {
            if (w.id !== walletId) {
              // Setting a new primary demotes all other wallets
              return is_primary ? { ...w, is_primary: false } : w;
            }
            return {
              ...w,
              ...(is_primary !== undefined && { is_primary }),
              ...(nickname !== undefined && { nickname }),
            };
          }),
        });
      }

      return { previous };
    },

    onError: (_err, _vars, context) => {
      // Roll back to the snapshot if the request failed
      if (context?.previous) {
        queryClient.setQueryData(queryKeys.wallets.list(), context.previous);
      }
    },

    onSettled: () => {
      // Always reconcile against the server, success or failure
      queryClient.invalidateQueries({ queryKey: queryKeys.wallets.all });
    },
  });
}
```

A few non-obvious details in this hook:

* **The mutation cancels `wallets.all`, not just `wallets.list()`** — pending detail-query refetches need to be cancelled too, otherwise they'll overwrite the optimistic write when they resolve.
* **The optimistic update demotes peers when promoting a primary** — the backend enforces "only one primary wallet" so the optimistic update mirrors that invariant. Without this, the UI briefly shows two primaries.
* **`onSettled` invalidates regardless of success or failure** — the invalidation is a reconciliation, not a success signal. After rollback on error, the invalidate refetches authoritative server state.

<Warning>
  Optimistic updates are only safe when the client can correctly predict the server response. For mutations that depend on server-side computation (e.g. promoting a wallet to primary may trigger a 202 Accepted Safe creation flow), don't optimistically update fields the server controls.
</Warning>

## Invalidation strategy

Query keys are structured as a pyramid: a domain root, narrower list keys, narrowest detail keys. Invalidation targets the appropriate level so the cache stays coherent without overfetching.

The wallets domain illustrates the structure:

```ts theme={null}
// src/lib/api/query-keys.ts
export const queryKeys = {
  wallets: {
    all: ['wallets'] as const,
    list: (filters?: WalletFilters) => ['wallets', 'list', filters] as const,
    detail: (externalId: string) => ['wallets', 'detail', externalId] as const,
    balances: (walletExternalId: string) => ['wallets', 'balances', walletExternalId] as const,
    assets: (walletExternalId: string) => ['wallets', 'assets', walletExternalId] as const,
    assetTransactions: (walletExternalId: string, assetSymbol: string) =>
      ['wallets', 'assets', walletExternalId, assetSymbol, 'transactions'] as const,
  },
  // ...
} as const;
```

Invalidation uses prefix matching, so the call site picks the broadest key that's still correct:

| Mutation                                   | Invalidate                     | Why                                                           |
| ------------------------------------------ | ------------------------------ | ------------------------------------------------------------- |
| Create / delete wallet                     | `queryKeys.wallets.all`        | Lists, details, balances, assets are all potentially affected |
| Update wallet metadata (nickname, primary) | `queryKeys.wallets.all`        | Setting primary cascades to other wallets in the list         |
| Mutation on a single related resource      | `queryKeys.wallets.detail(id)` | Surgical — leaves siblings cached                             |

The `useUpdateWallet` hook above invalidates `wallets.all` because promoting a wallet to primary changes the `is_primary` flag on every wallet in the list, not just the target. A nickname-only edit could safely scope to `wallets.detail(id)`, but the broader invalidate is harmless and keeps the hook simple.

For mutations that affect multiple domains, invalidate each:

```ts theme={null}
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: queryKeys.ipas.all });
  queryClient.invalidateQueries({ queryKey: queryKeys.agentTasks.all });
  toast.success('Purchase authorisation created');
},
```

`useCreateIPA` invalidates both `ipas.all` (list page should show the new IPA) and `agentTasks.all` (the originating agent task picks up a new linked IPA).

## Prefetching

For predictable navigation paths — hovering a list row, focusing a search result — prefetch the detail query so the destination renders cache-hot.

The pattern uses `queryClient.prefetchQuery` wrapped in a `useCallback`:

```tsx theme={null}
export function usePrefetchUser() {
  const queryClient = useQueryClient();
  const { getToken } = useAuthEnabled();

  return useCallback(
    (options?: UseUserOptions) => {
      return queryClient.prefetchQuery({
        queryKey: queryKeys.user.me(options?.expand),
        queryFn: () =>
          request<GetUserResponse>(
            'GET',
            '/v0/user/me',
            {
              ...(options?.expand?.length ? { params: { expand: options.expand } } : {}),
              caller: 'frontend:user',
            },
            getToken
          ),
        staleTime: 5 * 60 * 1000,
      });
    },
    [queryClient, getToken]
  );
}
```

Wire it to `onMouseEnter` (links), `onFocus` (autocomplete suggestions), or route guards:

```tsx theme={null}
function ProfileLink() {
  const prefetch = usePrefetchUser();
  return (
    <Link href="/profile" onMouseEnter={() => prefetch({ expand: ['wallets'] })}>
      View profile
    </Link>
  );
}
```

<Note>
  Prefetching respects `staleTime` — a prefetch against a fresh cache entry is a no-op, so it's safe to wire it aggressively without worrying about request storms.
</Note>
