Data Fetching
TanStack Query as the standard for all server/async state — HTTP clients, the api/schemas conventions, and query keys.
TanStack Query is the standard for all server and async state at Vesperia. Anything that comes from the network — lists, details, mutations — goes through TanStack Query. Don't hand-roll useEffect + useState fetching, and don't keep server data in a client store (that's what the cache is for; see State Management).
This page describes how data fetching is structured in idxnet-web.
HTTP clients
All requests go through one of the shared Axios clients in src/lib/http-client.ts:
protectedHttpClient— attaches the auth token (via a request interceptor) for authenticated endpoints. This is the default.publicHttpClient— no auth, for public endpoints.
Both share a response interceptor that normalizes the API envelope into typed result classes — SuccessResponse, SuccessResponseWithPagination, and ErrorResponse — so callers get a consistent shape and errors reject as an ErrorResponse (with status, message, and field errors).
import { protectedHttpClient } from "@/lib/http-client";
import type { SuccessResponseWithPagination } from "@/lib/http-client";Never create ad-hoc axios instances or call fetch directly — use these clients so auth, error normalization, and base URL are handled for you.
Where data code lives
Inside a feature module, the data layer is split across conventional files:
<domain>.api.ts— query options and mutations (the functions components call)<domain>.schemas.ts— Zod schemas that validate responses<domain>.config.ts— the query-key factory and related config<domain>.types.ts— types, usually inferred from the schemas
Queries with queryOptions
Define queries as reusable queryOptions in <domain>.api.ts. The query function calls the HTTP client, then parses the response with the Zod schema before returning — runtime validation at the network boundary is part of the standard.
// src/app/holiday-schedule/holiday-schedule.api.ts
import { queryOptions } from "@tanstack/react-query";
import { z } from "zod";
import { holidayScheduleConfig } from "@/app/holiday-schedule/holiday-schedule.config";
import { holidayScheduleSchema } from "@/app/holiday-schedule/holiday-schedule.schemas";
import type { HolidayScheduleListFilterConfig } from "@/app/holiday-schedule/holiday-schedule.types";
import { protectedHttpClient } from "@/lib/http-client";
import type { SuccessResponseWithPagination } from "@/lib/http-client";
function fetchList(filter?: HolidayScheduleListFilterConfig) {
return queryOptions({
queryKey: holidayScheduleConfig.queryKey.list(filter),
queryFn: async ({ signal, queryKey: [{ filter }] }) => {
const { data: res } =
await protectedHttpClient.get<SuccessResponseWithPagination>(
"/holiday-management/",
{ signal, params: filter },
);
return {
pagination: res.pagination,
data: z.array(holidayScheduleSchema).parse(res.data),
};
},
});
}Note that:
- The
queryKeycomes from the config's key factory (below), and the filter is read back off the key insidequeryFn— keys are the single source of truth for query inputs. signalis forwarded to Axios so queries cancel correctly.- The response
datais validated withschema.parse(...); the component receives typed, trusted data.
Components consume these with useQuery(fetchList(filter)) (or useSuspenseQuery). Because the options are reusable, the same definition can be prefetched (queryClient.prefetchQuery) or invalidated by key.
Schemas and the shared zod helpers
Response schemas live in <domain>.schemas.ts and are built from the shared helpers in src/lib/zod.ts:
// src/app/holiday-schedule/holiday-schedule.schemas.ts
import { z } from "zod";
import {
nullableStringSchema,
requiredDateSchema,
requiredUuidSchema,
} from "@/lib/zod";
const holidayScheduleSchema = z.object({
id: requiredUuidSchema,
date: requiredDateSchema,
notes: nullableStringSchema,
});
// derive the form schema instead of duplicating fields
const holidayScheduleFormSchema = holidayScheduleSchema.omit({ id: true });
export { holidayScheduleSchema, holidayScheduleFormSchema };Types are inferred from these schemas (z.infer<typeof holidayScheduleSchema>) so the validated shape and the TypeScript type can't drift.
Query keys
Query keys are produced by a key factory in <domain>.config.ts. Keys are structured (an array of objects) and hierarchical, so related queries can be invalidated together by prefix:
// src/app/holiday-schedule/holiday-schedule.config.ts
const queryKey = {
all: [{ module: "holiday-schedule" }] as const,
list: (filter?: DefaultListFilterConfig) =>
[{ ...queryKey.all[0], type: "list", filter }] as const,
dateRange: (date_from: string, date_to: string) =>
[{ ...queryKey.all[0], type: "date-range", date_from, date_to }] as const,
};This pattern means:
queryKey.allinvalidates every query for the module.queryKey.list(filter)is specific enough to cache per-filter.- The
as constkeeps the keys strongly typed end-to-end, which is what letsqueryFndestructurequeryKey: [{ filter }]safely.
Mutations
Mutations also live in <domain>.api.ts, take the QueryClient, and invalidate the relevant keys on success (so the UI reflects the change). Wire them up through the shared form-submission/mutation helpers (src/hooks/use-mutation, src/hooks/use-form-submission) rather than calling useMutation ad hoc, so confirmation dialogs, toasts, and cache invalidation stay consistent.
Linting
The @tanstack/eslint-plugin-query recommended rules are enabled (flat/recommended in eslint.config.js). They catch common mistakes — unstable query keys, missing dependencies in keys, and incorrect destructuring — so heed them rather than disabling.