Tutorial — Data fetching
Our task list has to come from somewhere: a backend. On this page you'll mount the
providers at the top of the app, create a typed HTTP client with
createApiClient, organize the cache keys with createQueryKeys, and fetch and
mutate data with useQuery and useMutation, handling loading and error states.
Step 1 — The providers at the top
For React Query to work, the app needs a cache provider at the top of the tree.
<AppProviders> mounts that provider (and the theme, and the error boundary) at
once. It already ships in the generated App.tsx:
// src/App.tsx
import { AppProviders, AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";
export function App() {
return (
<AppProviders errorBoundary={{ fallback: <p>Something went wrong.</p> }}>
<AppRouter routes={routes} fallback={<p>Loading…</p>} />
</AppProviders>
);
}
<AppProviders> nests from the outside in:
ErrorBoundary → QueryProvider → ThemeProvider → I18nProvider → children. Query
and Theme come on by default with the SDK's defaults — you configure nothing to
get data caching.
Cache defaults
When on by default, the QueryProvider uses: staleTime of 5 minutes,
gcTime of 30 minutes, retry: 1 and refetchOnWindowFocus: false. To
adjust, pass query={{ defaultOptions: { queries: { retry: 3 } } }}.
Step 2 — The HTTP client with createApiClient
createApiClient creates a typed fetch client that handles JSON, query params,
bearer token and 401 handling. Let's centralize it in src/lib/api.ts:
// src/lib/api.ts
import { createApiClient, createQueryKeys } from "tempest-react-sdk";
import { useAuth } from "@/stores/auth";
export const api = createApiClient({
baseURL: import.meta.env.VITE_API_URL,
getToken: () => useAuth.getState().token,
onUnauthorized: () => useAuth.getState().logout(),
});
Piece by piece:
baseURLcomes from.env(VITE_API_URL) — the base for every request.getTokenis called on every request; reading the token viagetState(), each call uses the current token and sends theAuthorization: Bearer ...header.onUnauthorizedfires on 401 responses — here we log the user out.
The client exposes get, post, put, patch, delete (all generic in the
return type) and upload. Each returns the parsed, typed JSON.
Remember getToken with getState()?
The HTTP client runs outside React, so it reads the auth store with
getState() — exactly the pattern you saw on the State page. No
hook, no subscription: just a snapshot of the token at call time. 💡
Step 3 — Cache keys with createQueryKeys
React Query identifies each cache slice by a query key. Instead of scattering
magic arrays through your code, centralize them with createQueryKeys:
// src/lib/api.ts (continued)
export const taskKeys = createQueryKeys("tasks", {
all: () => ["all"] as const,
byId: (id: string) => [id] as const,
});
// taskKeys.all() === ["tasks", "all"]
// taskKeys.byId("42") === ["tasks", "42"]
Each key is prefixed with the domain ("tasks"), so there's no collision between
different domains.
Step 4 — Reading data with useQuery
Now the task list. useQuery comes from @tanstack/react-query (a direct
dependency of the SDK, installed alongside it — you don't install anything
separately).
Where useQuery comes from
The useQuery and useMutation hooks are imported from
"@tanstack/react-query", not from "tempest-react-sdk". The SDK provides
the provider (QueryProvider, via AppProviders), the keys
(createQueryKeys) and the time presets; the hooks you use straight from
React Query. Since it's a direct dependency of the SDK, it's already
installed.
// src/pages/Home.tsx
import { useQuery } from "@tanstack/react-query";
import { api, taskKeys } from "@/lib/api";
interface Task {
id: string;
title: string;
done: boolean;
}
export function Home() {
const { data, isLoading, isError, error } = useQuery({
queryKey: taskKeys.all(),
queryFn: () => api.get<Task[]>("/tasks"),
});
if (isLoading) return <p>Loading tasks…</p>;
if (isError) return <p>Error: {(error as Error).message}</p>;
return (
<ul>
{data?.map((task) => (
<li key={task.id}>
{task.done ? "✅" : "⬜"} {task.title}
</li>
))}
</ul>
);
}
Piece by piece:
queryKey: taskKeys.all()identifies this cache — other components using the same key share the same data.queryFnmakes the request.api.get<Task[]>("/tasks")returns a typedTask[].isLoading/isError/errorare the states React Query keeps for you — handle them before rendering the data.
Step 5 — Mutating data with useMutation
Fetching is only half. To create a task, use useMutation (also from
@tanstack/react-query). After success, we invalidate the list query so it's
refetched with the new task.
// src/components/AddTask.tsx
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, taskKeys } from "@/lib/api";
interface Task {
id: string;
title: string;
done: boolean;
}
export function AddTask() {
const [title, setTitle] = useState("");
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTitle: string) =>
api.post<Task>("/tasks", { body: { title: newTitle, done: false } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: taskKeys.all() });
setTitle("");
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate(title);
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="New task" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Add"}
</button>
{mutation.isError && <p>Failed to save.</p>}
</form>
);
}
Piece by piece:
mutationFnreceives the argument frommutation.mutate(...)and does thePOST. Note the{ body: {...} }—createApiClientserializesbodyto JSON for you.onSuccessinvalidatestaskKeys.all(): React Query refetches the list automatically, and<Home>shows the new task without you reloading the page.mutation.isPendingdisables the button during submit;mutation.isErrorshows the error.
Why invalidate instead of inserting by hand
Invalidating the query (invalidateQueries) lets the server be the source of
truth: you refetch the list and get the real id, timestamps, any field the
backend filled in. It's more robust than trying to insert the optimistic
object into the local list. ✅
Recap
<AppProviders>mounts theQueryProvider(cache) on by default — with no config you already have React Query working. ✅createApiClient({ baseURL, getToken, onUnauthorized })creates a typedfetchclient:getTokeninjects the bearer on every call,onUnauthorizedfires on 401. It reads the auth store withgetState().createQueryKeys("domain", {...})centralizes cache keys, prefixed by the domain.useQuery(from@tanstack/react-query, a direct SDK dependency) fetches data and gives youisLoading/isError/data.useMutation+queryClient.invalidateQueries(...)mutates data and refetches the list — the server stays the source of truth.
➡️ Next page: Forms — zod validation and BR masks