Data fetching
Handle server state with a managed TanStack Query (useQuery).
Vantage ships a managed TanStack Query for server state. There is no QueryClient to set up —
just import useQuery and friends from @squadbase/vantage/query. To call your own server/api,
useApiQuery below is the shortcut.
import { useQuery } from "@squadbase/vantage/query";
export default function Customers() {
const { data, isLoading, error } = useQuery({
queryKey: ["customers"],
queryFn: async () => {
const res = await fetch("/api/customers");
if (!res.ok) throw new Error("failed to load");
return res.json();
},
});
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Something went wrong</p>;
return (
<ul>
{data.map((c: { id: string; name: string }) => (
<li key={c.id}>{c.name}</li>
))}
</ul>
);
}
A single managed QueryClient
Vantage provides exactly one QueryClient with these defaults:
| Option | Value |
|---|---|
staleTime |
30 seconds |
retry |
1 |
refetchOnWindowFocus |
false |
networkMode |
"always" (queries and mutations) |
networkMode: "always" requests and retries regardless of the browser’s offline heuristic. Under
the default "online", a failed query is paused the moment that heuristic reads offline — no
error, just a loading state that never resolves. A dashboard talks to its own origin (or the
configured API base), where that heuristic says nothing useful.
Customize by overriding per query. Swapping the whole QueryClient is intentionally not exposed,
to keep the stack managed.
// Change behavior for this query only
useQuery({
queryKey: ["metrics"],
queryFn: fetchMetrics,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 3,
});
Call your own API — useApiQuery
For routes under server/api, use useApiQuery. It is useQuery plus the boilerplate every page
was repeating: base-URL resolution, JSON parsing, non-2xx to an error, and a query key.
import { useApiQuery } from "@squadbase/vantage/query";
interface Monthly {
monthly: { month: string; revenue: number }[];
}
export default function MonthlyAnalysis() {
const { data, isPending, error } = useApiQuery<Monthly>("/api/monthly-analysis");
if (isPending) return <p>Loading…</p>;
if (error) return <p>{error.message}</p>;
return <Chart data={data.monthly} />;
}
Pass the query string as search. undefined entries are dropped, so an unset filter appears
neither in the URL nor in the query key.
const query = useApiQuery<Row[]>("/api/customers", {
search: { segment, from: range.from, to: range.to },
enabled: Boolean(segment), // any useQuery option still works
});
The query key defaults to ["api", <url with query string>], so changing search refetches, and
invalidateQueries({ queryKey: ["api"] }) drops everything that came from the API.
Errors arrive as ApiError
A non-2xx response is thrown as an ApiError. Its message is what the API chose to disclose with
HttpError; anything else surfaces as generic status text.
| Property | Contents |
|---|---|
message |
The message the server set with HttpError |
status |
HTTP status |
body |
Parsed response body |
requestId |
x-vantage-request-id, to correlate with the dev terminal log |
Writes with useApiMutation
The mutation variables are sent as the JSON body. The path may be a function.
import { useApiMutation, useQueryClient } from "@squadbase/vantage/query";
const queryClient = useQueryClient();
const save = useApiMutation<Customer, { id: string; name: string }>(
(vars) => `/api/customers/${vars.id}`,
{
method: "PUT",
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["api"] }),
},
);
save.mutate({ id: "1", name: "Northwind" });
Where a hook doesn’t fit (an event handler, say), call apiJson(path, init) directly. For the raw
Response, use apiFetch.
What to read next
- API & server — write a backend