SE Hub
Front End Development

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 queryKey comes from the config's key factory (below), and the filter is read back off the key inside queryFn — keys are the single source of truth for query inputs.
  • signal is forwarded to Axios so queries cancel correctly.
  • The response data is validated with schema.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.all invalidates every query for the module.
  • queryKey.list(filter) is specific enough to cache per-filter.
  • The as const keeps the keys strongly typed end-to-end, which is what lets queryFn destructure queryKey: [{ 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.

On this page