# Data Fetching

Single axios instance, request/response interceptors, and TanStack Query integration. All API calls go through this layer — never use `fetch` directly in features.

## The Axios Instance

See the full setup in [AUTHENTICATION.md](AUTHENTICATION.md#axios-auth-interceptor). Summary of behavior:

- `baseURL` from `NEXT_PUBLIC_API_BASE_URL`.
- Request interceptor attaches `Authorization: Bearer <keycloak_access_token>`.
- Response interceptor normalizes `ApiError` and redirects on 401.
- 30s timeout.

## Query Keys

Centralize query keys per feature with a factory:

```ts
// src/features/users/hooks/users.keys.ts:1
export const usersKeys = {
  all: ["users"] as const,
  list: (filters: { search?: string; page?: number } = {}) =>
    [...usersKeys.all, "list", filters] as const,
  detail: (id: string) => [...usersKeys.all, "detail", id] as const,
};
```

Never inline `["users", id]` — always go through the factory so invalidations stay consistent.

## Feature API Module

```ts
// src/features/users/api/users.api.ts:1
import { z } from "zod";
import { api, ApiError } from "@/lib/axios";

export const userSchema = z.object({
  id: z.string(),
  nip: z.string(),
  name: z.string(),
  email: z.string().email(),
  roles: z.array(z.string()),
  isActive: z.boolean(),
  createdAt: z.string().datetime(),
});

export type User = z.infer<typeof userSchema>;

export const userListSchema = z.object({
  items: z.array(userSchema),
  total: z.number().int().nonnegative(),
  page: z.number().int().positive(),
  pageSize: z.number().int().positive(),
});

export type UserList = z.infer<typeof userListSchema>;

export async function listUsers(params: { search?: string; page?: number; pageSize?: number }) {
  try {
    const { data } = await api.get<UserList>("/api/users", { params });
    return userListSchema.parse(data);
  } catch (err) {
    if (err instanceof ApiError) throw err;
    throw new ApiError("Invalid response", 0);
  }
}

export async function getUser(id: string): Promise<User> {
  const { data } = await api.get(`/api/users/${id}`);
  return userSchema.parse(data);
}

export async function updateUser(id: string, patch: Partial<Pick<User, "name" | "isActive">>) {
  const { data } = await api.patch(`/api/users/${id}`, patch);
  return userSchema.parse(data);
}

export async function deleteUser(id: string): Promise<void> {
  await api.delete(`/api/users/${id}`);
}
```

Zod-parsing the response catches schema drift between backend and frontend at runtime — fail loud, not silent.

## Query Hooks

```ts
// src/features/users/hooks/useUsers.ts:1
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { listUsers } from "../api/users.api";
import { usersKeys } from "./users.keys";

export function useUsers(filters: { search?: string; page?: number; pageSize?: number }) {
  return useQuery({
    queryKey: usersKeys.list(filters),
    queryFn: () => listUsers(filters),
    placeholderData: keepPreviousData,
    staleTime: 30_000,
  });
}
```

```ts
// src/features/users/hooks/useUser.ts:1
import { useQuery } from "@tanstack/react-query";
import { getUser } from "../api/users.api";
import { usersKeys } from "./users.keys";

export function useUser(id: string) {
  return useQuery({
    queryKey: usersKeys.detail(id),
    queryFn: () => getUser(id),
    enabled: id.length > 0,
  });
}
```

## Mutation Hooks

```ts
// src/features/users/hooks/useUpdateUser.ts:1
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateUser } from "../api/users.api";
import { usersKeys } from "./users.keys";

export function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, patch }: { id: string; patch: Parameters<typeof updateUser>[1] }) =>
      updateUser(id, patch),
    onSuccess: (user) => {
      queryClient.setQueryData(usersKeys.detail(user.id), user);
      queryClient.invalidateQueries({ queryKey: usersKeys.all });
    },
  });
}
```

```ts
// src/features/users/hooks/useDeleteUser.ts:1
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteUser } from "../api/users.api";
import { usersKeys } from "./users.keys";

export function useDeleteUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => deleteUser(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: usersKeys.all });
    },
  });
}
```

## Optimistic Updates

```ts
// src/features/users/hooks/useToggleUserActive.ts:1
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateUser } from "../api/users.api";
import { usersKeys } from "./users.keys";

export function useToggleUserActive() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
      updateUser(id, { isActive }),
    onMutate: async ({ id, isActive }) => {
      await queryClient.cancelQueries({ queryKey: usersKeys.detail(id) });
      const previous = queryClient.getQueryData(usersKeys.detail(id));
      queryClient.setQueryData(usersKeys.detail(id), (old: unknown) => {
        if (!old || typeof old !== "object") return old;
        return { ...(old as Record<string, unknown>), isActive };
      });
      return { previous };
    },
    onError: (_err, { id }, context) => {
      if (context?.previous) {
        queryClient.setQueryData(usersKeys.detail(id), context.previous);
      }
    },
    onSettled: (_data, _err, { id }) => {
      queryClient.invalidateQueries({ queryKey: usersKeys.detail(id) });
    },
  });
}
```

## RSC Data Fetching

Server Components can call the API directly using the access token from the Better Auth session:

```tsx
// src/app/(authed)/dashboard/users/page.tsx:1
import { listUsers } from "@/features/users/api/users.api";
import { getAccessToken } from "@/lib/auth/server";
import { UsersTable } from "@/features/users/components/UsersTable";

export default async function UsersPage() {
  const token = await getAccessToken();
  const data = await listUsers({ page: 1 }, token);

  return <UsersTable initialData={data} />;
}
```

For RSC, the API module must accept the token explicitly — do not rely on cookies inside server code. Update `users.api.ts` to accept an optional token and pass it to axios:

```ts
export async function listUsers(
  params: { search?: string; page?: number; pageSize?: number },
  accessToken?: string,
) {
  const { data } = await api.get<UserList>("/api/users", {
    params,
    headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
  });
  return userListSchema.parse(data);
}
```

## Error Handling in Queries

Let `ApiError` propagate. Components handle via `query.error`:

```tsx
"use client";

import { Alert } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useUsers } from "../hooks/useUsers";

export function UsersList({ search }: { search?: string }) {
  const query = useUsers({ search });

  if (query.isError) {
    return (
      <Alert color="red" icon={<IconAlertCircle />} title="Failed to load users">
        {query.error.message}
      </Alert>
    );
  }

  if (query.isLoading) {
    return <UsersTableSkeleton />;
  }

  return <UsersTable data={query.data} />;
}
```

## Cancellation and Debouncing

For search-as-you-type, debounce at the hook layer:

```ts
// src/features/users/hooks/useDebouncedUsers.ts:1
import { useEffect, useState } from "react";
import { useUsers } from "./useUsers";

export function useDebouncedUsers(initialSearch = "") {
  const [search, setSearch] = useState(initialSearch);
  const [debounced, setDebounced] = useState(initialSearch);

  useEffect(() => {
    const t = setTimeout(() => setDebounced(search), 300);
    return () => clearTimeout(t);
  }, [search]);

  const query = useUsers({ search: debounced });
  return { search, setSearch, query };
}
```

TanStack Query v5 cancels in-flight requests on key change automatically when using `placeholderData: keepPreviousData` — no extra config needed.