Jak zaprojektować warstwę API w dużej aplikacji SPA, używając React Query i TypeScript krok po kroku

0
55
2.7/5 - (3 votes)

Nawigacja:

Kontekst dużej aplikacji SPA i problemy z warstwą API

Skala problemu: kiedy fetch w każdym komponencie przestaje działać

Mała aplikacja SPA z kilkoma ekranami zniesie prawie wszystko: pojedynczy plik api.ts, kilka wywołań fetch w komponentach i ręcznie kopiowane ścieżki. W dużej aplikacji – z kilkudziesięcioma modułami, setkami endpointów i kilkoma zespołami – takie podejście kończy się totalnym chaosem.

Pojawiają się problemy:

  • różne moduły używają tych samych endpointów, ale z różnym mapowaniem danych,
  • adresy URL są wpisywane „z palca” w dziesiątkach miejsc, więc refaktoryzacja backendu boli podwójnie,
  • obsługa błędów jest niespójna – tu toast, tam modal, gdzie indziej console.log,
  • typy odpowiedzi są kopiowane, a drobna zmiana w API (np. nowe pole) wymaga polowania po całym repozytorium.

Skala rośnie też organizacyjnie: osobne zespoły utrzymują różne domeny (users, billing, orders, inventory), a każdy ma nieco inny styl pracy. Bez jasno zaprojektowanej warstwy API aplikacja zamienia się w mozaikę przypadkowych decyzji. Z zewnątrz wygląda jak SPA, w środku – jak „Single Big Ball of Mud Application”.

Typowy stan „przed”: rozproszony fetch, kopiowane URL-e, brak kontraktu

Najczęstszy obrazek w dojrzałej, ale źle poukładanej aplikacji SPA wygląda zaskakująco podobnie:

  • komponenty Reacta zawierają logikę fetch/axios, parsowanie JSON, mapowanie błędów i od razu renderowanie UI,
  • brakuje centralnych helperów do zapytań – każdy pisze własną funkcję request lub wywołuje fetch bezpośrednio,
  • React Query (jeśli już jest) używany jest ad-hoc: klucze zapytań są „z głowy”, bez standardu,
  • logika odświeżania tokena, retry czy parsowania dat jest kopiowana w kilku miejscach.

Efekt uboczny: debugowanie API zamienia się w pracę detektywa. Ten sam błąd backendu jest łapany w różny sposób, komponenty różnie reagują na te same statusy HTTP, a testy end-to-end pokazują dziwne, trudne do powtórzenia problemy z cache.

Dlaczego React Query i TypeScript pomagają – ale nie robią roboty za Ciebie

React Query wnosi:

  • cache danych z API,
  • deduplikację równoległych zapytań,
  • strategię stale-while-revalidate,
  • obsługę retry, anulowanie zapytań, integrację z React Suspense itd.

TypeScript zapewnia:

  • statyczne typowanie odpowiedzi i payloadów,
  • bezpieczne refaktoryzacje przy zmianie kontraktu API,
  • czytelny kontrakt pomiędzy frontendem a backendem w formie typów.

Same narzędzia jednak nie narzucają architektury. React Query nie powie, gdzie trzymać hooki domenowe. TypeScript nie wymusi oddzielenia DTO API od modeli domenowych. Bez przemyślanej warstwy API łatwo skończyć z setkami niepowiązanych ze sobą useQuery, w których każdy zespół robi „po swojemu”. Narzędzia pomagają, ale spójność trzeba zaprojektować.

Warstwa API jako kontrakt między frontendem a backendem

Warstwa API w SPA powinna być traktowana jak oddzielny, jasno zdefiniowany moduł. Jej zadaniem jest:

  • udostępnienie typowanego klienta do komunikacji z backendem (GET/POST/PUT/DELETE itp.),
  • zamknięcie w jednym miejscu szczegółów transportu: baseURL, nagłówki, tokeny, retry, serializacja parametrów,
  • zdefiniowanie funkcji domenowych: getUserById, createOrder, getOrdersList,
  • przygotowanie jasnego kontraktu typów: co przychodzi z backendu, a co widzi reszta aplikacji.

Frontend nie powinien znać szczegółów REST-a ani struktury URL-i. UI powinno jedynie korzystać z: „daj mi użytkownika”, „utwórz zamówienie”, „zwróć listę produktów z filtrami”. Reszta – w tym integracja z React Query – powinna być schowana w dobrze zorganizowanej warstwie API oraz dedykowanych hookach domenowych.

Założenia architektoniczne warstwy API w SPA

Oddzielenie transportu HTTP od logiki domenowej

Pierwsze kluczowe założenie: rozdzielić transport od domeny. Innymi słowy, co innego robi httpClient, a co innego moduł usersApi czy ordersApi.

Warstwa transportu powinna odpowiadać za:

  • wybór implementacji (fetch, Axios, Ky, inny klient),
  • obsługę nagłówków (Authorization, Content-Type itd.),
  • serializację query params,
  • time-outy, anulowanie zapytań,
  • globalną obsługę błędów (np. mapowanie statusów HTTP na ApiError).

Warstwa domenowa (np. api/modules/users) powinna:

  • definiować funkcje „surowego API” odpowiadające endpointom REST,
  • znać ścieżki URL tylko w jednym miejscu,
  • znać struktury DTO: request i response.

Powyżej warstwy domenowej są dopiero hooki React Query (np. useUserDetailsQuery), z których korzystają komponenty UI. Dzięki temu drobne zmiany w transporcie czy strukturze backendu nie rozlewają się po całej aplikacji.

Kontrakt odpowiedzialności: API vs React Query vs komponenty UI

Jasne rozdzielenie odpowiedzialności pozwala unikać „przeciekania” logiki w niekontrolowany sposób. Typowy kontrakt może wyglądać tak:

  • Warstwa API (domena + transport): wie, jak rozmawiać z backendem, mapuje DTO na modele domenowe (jeśli to potrzebne), zwraca dane lub rzuca jasno zdefiniowany typ błędu (ApiError).
  • React Query: zarządza stanem danych (cache, refetch, invalidacja, retry). Nie zna URL-i ani szczegółów HTTP, wywołuje jedynie funkcje z warstwy API.
  • Komponenty UI: wiedzą, których hooków użyć i jak zrenderować data, isLoading, error. Nie wywołują fetch bezpośrednio, nie znają endpointów.

Dzięki temu w komponentach nie ma przypadkowych fetch('/api/users'). Komponent używa np. useUserDetailsQuery(userId), a szczegóły, skąd biorą się dane i jaka jest struktura URL-a, są schowane głębiej.

Zasada: brak bezpośrednich wywołań HTTP w komponentach

Jeśli w kodzie UI pojawia się fetch lub bezpośredni httpClient.get, to sygnał alarmowy. Takie wywołania psują:

  • spójność obsługi błędów,
  • powtarzalność wzorca cache i retry,
  • czytelność struktury zapytań w projekcie.

Zamiast tego należy stosować zasadę: komponenty używają tylko hooków domenowych. Przykład złego i dobrego podejścia:

// ZŁE
const UserList = () => {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(setUsers);
  }, []);

  // ...
};

// DOBRE
const UserList = () => {
  const { data: users, isLoading } = useUserListQuery();

  // ...
};

Ta zasada wymusza zaprojektowanie warstwy API i hooków w sposób przemyślany. Na początku może to wydawać się „więcej roboty”, ale przy kilkuset endpointach nie ma realnej alternatywy, jeśli projekt ma pozostać utrzymywalny.

Myślenie w kategoriach modułów i domen

Projektując architekturę warstwy API warto od razu myśleć domenami, a nie „endykując” każdy endpoint z osobna. Innymi słowy – zamiast pojedynczego client.ts z setką funkcji:

  • getUsers,
  • getOrders,
  • createInvoice,
  • getProducts,

lepiej stworzyć moduły:

  • api/modules/users,
  • api/modules/orders,
  • api/modules/billing,
  • api/modules/catalog.

Takie moduły można mapować na granice domen (bounded contexty) w backendzie lub nawet na mikroserwisy. Ułatwia to:

  • przypisywanie odpowiedzialności zespołom (zespół X dba o moduł users),
  • wspólną ewolucję API i logiki biznesowej w obrębie jednej domeny,
  • selektywne ładowanie kodu (code splitting) według modułów domenowych.
Dłoń trzymająca naklejkę z napisem JSON, symbolizująca tworzenie API
Źródło: Pexels | Autor: RealToughCandy.com

Technologie w zestawie: React Query, TypeScript i klient HTTP

Dlaczego React Query dobrze „niesie” warstwę API

React Query rozwiązuje sporo problemów, z którymi w dużej aplikacji frontendu nie warto walczyć ręcznie:

  • Cache danych – dane z API są przechowywane i współdzielone między komponentami,
  • Deduplication – kilka równoległych zapytań o te same dane prowadzi do jednego requestu HTTP,
  • Stale-while-revalidate – użytkownik widzi od razu dane z cache, a w tle trwa odświeżenie,
  • Retry – tymczasowe błędy sieciowe można łatwo obsłużyć powtórzeniem zapytania,
  • Invalidation – po mutacji (np. zapisaniu formularza) można precyzyjnie odświeżyć wybrane listy i szczegóły.

W połączeniu z typowaną warstwą API React Query pozwala budować spójne hooki domenowe. Każdy moduł może mieć własne useXxxQuery i useXxxMutation, a globalna konfiguracja (domyślne retry, staleTime, refetchOnWindowFocus) jest zdefiniowana w jednym miejscu.

Rola TypeScriptu: typowanie odpowiedzi, payloadów i kluczy

TypeScript w warstwie API to nie tylko „ładne podpowiedzi w IDE”. Dobrze zaprojektowane typy:

  • wymuszają spójność kontraktu z backendem (np. brak możliwości zapomnienia o nowym polu),
  • pozwalają bezpiecznie refaktoryzować nazwy endpointów lub strukturę odpowiedzi,
  • pomagają typować klucze zapytań React Query, co redukuje liczbę pomyłek przy invalidacji cache.

Przykładowe miejsca, gdzie TypeScript pomaga szczególnie mocno:

  • typy DTO: UserApiResponse, CreateUserRequest, OrderListResponse,
  • typ ApiError z polami takimi jak status, code, message, details,
  • typowane klucze zapytań: np. QueryKey<['users', 'details', { id: string }]>,
  • generyczne metody get<TResponse>, post<TResponse, TBody> w kliencie HTTP.

Wybór klienta HTTP i kryteria architektoniczne

Biblioteka do HTTP (transport) jest wymienna. Najczęstsze wybory:

  • fetch – wbudowany, prosty, ale wymaga własnego opakowania,
  • Axios – popularny, ma interceptory, anulowanie zapytań i spójny interfejs,
  • Ky – lekka nakładka na fetch z intuicyjnym API.

Kryteria, na które warto patrzeć:

  • obsługa interceptorów (nagłówki, token, logging),
  • wbudowane anulowanie zapytań (np. poprzez AbortController),
  • łatwe ustawienie timeout,
  • prostota konfiguracji baseURL i nagłówków domyślnych.

Niezależnie od wyboru, i tak opłaca się stworzyć własny httpClient jako cienką abstrakcję. Dzięki temu wymiana biblioteki (np. z Axios na fetch) nie rozbije całej aplikacji, lecz zostanie zamknięta w jednym module.

Jednolity baseClient / httpClient

Dobry httpClient powinien udostępnić prosty i typowany interfejs:

Projekt interfejsu httpClient w TypeScript

Zanim powstaną moduły domenowe, przydaje się jasny interfejs dla transportu. Dobrze, jeśli jest minimalistyczny, ale rozszerzalny. Przykładowa wersja typowanego klienta HTTP:

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export interface HttpRequestConfig<TBody = unknown> {
  url: string;
  method?: HttpMethod;
  query?: Record<string, string | number | boolean | undefined>;
  body?: TBody;
  headers?: Record<string, string>;
  signal?: AbortSignal;
  timeoutMs?: number;
}

export interface HttpResponse<TData = unknown> {
  data: TData;
  status: number;
  headers: Headers;
}

export interface HttpClient {
  request<TResponse = unknown, TBody = unknown>(
    config: HttpRequestConfig<TBody>
  ): Promise<HttpResponse<TResponse>>;

  get<TResponse = unknown>(
    url: string,
    config?: Omit<HttpRequestConfig, 'url' | 'method' | 'body'>
  ): Promise<HttpResponse<TResponse>>;

  post<TResponse = unknown, TBody = unknown>(
    url: string,
    body?: TBody,
    config?: Omit<HttpRequestConfig<TBody>, 'url' | 'method' | 'body'>
  ): Promise<HttpResponse<TResponse>>;

  // analogicznie put, patch, delete
}

Interfejs jest prosty, ale spełnia kilka założeń:

  • jest generyczny po typie odpowiedzi i ciała,
  • pozwala przekazać signal z AbortController,
  • umożliwia ustawienie timeoutMs i nagłówków per-zapytanie.

Implementacja może używać fetch, Axiosa lub czegokolwiek innego, byle trzymała się tego kontraktu. Świat wyżej już tego nie obchodzi.

Implementacja httpClient na bazie fetch

Poniżej uproszczona implementacja na fetch, z obsługą timeoutu i mapowaniem błędów na wspólny typ:

export class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public code?: string,
    public details?: unknown
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

const BASE_URL = import.meta.env.VITE_API_BASE_URL;

const defaultHeaders: Record<string, string> = {
  'Content-Type': 'application/json',
};

function buildUrl(url: string, query?: HttpRequestConfig['query']): string {
  const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
  if (!query) return fullUrl;

  const params = new URLSearchParams();
  Object.entries(query).forEach(([key, value]) => {
    if (value === undefined) return;
    params.append(key, String(value));
  });

  const queryString = params.toString();
  return queryString ? `${fullUrl}?${queryString}` : fullUrl;
}

function withTimeout(signal: AbortSignal | undefined, timeoutMs?: number) {
  if (!timeoutMs) return { signal };

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  const compositeSignal = controller.signal;
  if (signal) {
    signal.addEventListener('abort', () => controller.abort(), { once: true });
  }

  return {
    signal: compositeSignal,
    cleanup: () => clearTimeout(timer),
  };
}

export const httpClient: HttpClient = {
  async request<TResponse, TBody = unknown>(
    config: HttpRequestConfig<TBody>
  ): Promise<HttpResponse<TResponse>> {
    const {
      url,
      method = 'GET',
      query,
      body,
      headers,
      signal,
      timeoutMs,
    } = config;

    const finalUrl = buildUrl(url, query);
    const { signal: finalSignal, cleanup } = withTimeout(signal, timeoutMs);

    try {
      const response = await fetch(finalUrl, {
        method,
        headers: {
          ...defaultHeaders,
          ...headers,
        },
        body: body ? JSON.stringify(body) : undefined,
        signal: finalSignal,
      });

      const contentType = response.headers.get('Content-Type') ?? '';

      let data: unknown = null;
      if (contentType.includes('application/json')) {
        data = await response.json();
      } else if (!response.ok) {
        // jeśli błąd i brak JSON-a, przynajmniej tekst
        data = await response.text();
      }

      if (!response.ok) {
        const errorPayload = data as { message?: string; code?: string };
        throw new ApiError(
          errorPayload?.message || `Request failed with status ${response.status}`,
          response.status,
          errorPayload?.code,
          errorPayload
        );
      }

      return {
        data: data as TResponse,
        status: response.status,
        headers: response.headers,
      };
    } catch (error) {
      if (error instanceof ApiError) {
        throw error;
      }
      if ((error as Error).name === 'AbortError') {
        throw new ApiError('Request aborted', 0, 'ABORTED');
      }
      throw new ApiError((error as Error).message, 0, 'NETWORK_ERROR');
    } finally {
      cleanup?.();
    }
  },

  get(url, config) {
    return this.request({ ...(config || {}), url, method: 'GET' });
  },

  post(url, body, config) {
    return this.request({ ...(config || {}), url, method: 'POST', body });
  },

  // implementacje put, patch, delete analogicznie...
};

Typ ApiError jest kluczowy – ten sam błąd trafia do hooków React Query, a dalej do komponentów. UI nie musi zgadywać, czy error jest obiektem Axiosa, Response z fetch czy może czymś zupełnie innym.

Dodawanie interceptorów: token, logowanie, multi-tenant

W większej aplikacji rzadko wystarcza „czysty” klient HTTP. Trzeba dołożyć:

  • token JWT lub inny mechanizm autoryzacji,
  • nagłówek z identyfikatorem tenant-a,
  • logowanie zapytań w środowisku deweloperskim.

Jeśli używany jest fetch, można zbudować prostą warstwę „przed” klientem, np. przez kompozycję:

type AuthTokenProvider = () => string | null;

let tokenProvider: AuthTokenProvider | null = null;

export function setAuthTokenProvider(provider: AuthTokenProvider) {
  tokenProvider = provider;
}

export const authedHttpClient: HttpClient = {
  async request(config) {
    const token = tokenProvider?.();
    const headers = {
      ...(config.headers || {}),
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      'X-App-Version': import.meta.env.VITE_APP_VERSION,
    };

    if (import.meta.env.DEV) {
      console.debug('[HTTP]', config.method ?? 'GET', config.url, {
        query: config.query,
        body: config.body,
      });
    }

    return httpClient.request({
      ...config,
      headers,
    });
  },

  get(url, config) {
    return this.request({ ...(config || {}), url, method: 'GET' });
  },

  post(url, body, config) {
    return this.request({ ...(config || {}), url, method: 'POST', body });
  },
};

Moduły API korzystają już z authedHttpClient, a miejsce, z którego pochodzi token, jest całkowicie odseparowane. Kiedy przyjdzie zmienić mechanizm autoryzacji, nie trzeba biegać po wszystkich endpointach z regexem.

Modele i typy: kontrakt z backendem a modele domenowe

DTO vs modele domenowe – kiedy rozdzielać?

Backend zwraca JSON-a. Na froncie można:

  • pracować bezpośrednio na typach z API (najprostsze),
  • lub mapować DTO na „wewnętrzne” modele domenowe.

Przy małych projektach jeden typ wystarcza. W dużym SPA mapowanie zaczyna mieć sens, gdy:

  • backend jest rozwijany przez inny zespół i bywa „niestabilny”,
  • na frontendzie istnieje własna logika (np. pola wyliczane, flags, lokalne ID),
  • trzeba zunifikować dane z kilku usług (np. różne formaty dat).

Przykład rozdzielenia typów:

// DTO z API
export interface UserDto {
  id: string;
  first_name: string;
  last_name: string;
  email: string;
  created_at: string; // ISO string
}

// Model domenowy na froncie
export interface User {
  id: string;
  fullName: string;
  email: string;
  createdAt: Date;
}

// Mapper
export function mapUserDtoToUser(dto: UserDto): User {
  return {
    id: dto.id,
    fullName: `${dto.first_name} ${dto.last_name}`,
    email: dto.email,
    createdAt: new Date(dto.created_at),
  };
}

Komponenty operują na User, a szczegóły formatu z backendu (np. first_name vs firstName) są ukryte w jednym, testowalnym miejscu. Jeżeli backend zmieni nazwy pól, TypeScript powie dokładnie, co poprawić. I jest to bardzo wyraźne „TODO”, a nie losowy błąd w runtime.

Organizacja typów DTO i modeli w katalogach

W strukturze modułów wygodnie jest trzymać typy blisko miejsca użycia. Przykładowa struktura dla modułu users:

src/
  api/
    http/
      httpClient.ts
      authedHttpClient.ts
      apiError.ts
    modules/
      users/
        users.api.ts        // funkcje "surowego" API
        users.mappers.ts    // mapowanie DTO <-> model
        users.types.ts      // DTO, modele domenowe
        users.keys.ts       // klucze React Query
        users.queries.ts    // hooki useUserXxxQuery
        users.mutations.ts  // hooki useUserXxxMutation

W users.types.ts można zebrać:

  • typy DTO używane tylko w tym module,
  • modele domenowe (User, UserFilters),
  • ewentualne typy wspólne (np. PaginationMeta) re-exportowane z modułu core.

Walidacja kontraktu: Zod / io-ts / inne biblioteki

W projektach, w których API czasem „mija się” z dokumentacją, samo typowanie w TypeScripcie to za mało. Warto wtedy walidować odpowiedzi z backendu:

import { z } from 'zod';

export const userDtoSchema = z.object({
  id: z.string(),
  first_name: z.string(),
  last_name: z.string(),
  email: z.string().email(),
  created_at: z.string(),
});

export type UserDto = z.infer<typeof userDtoSchema>;

export async function getUserApi(id: string): Promise<User> {
  const { data } = await authedHttpClient.get<unknown>(`/users/${id}`);

  const parsed = userDtoSchema.parse(data);
  return mapUserDtoToUser(parsed);
}

Podejście kosztuje trochę narzutu, ale w dużej aplikacji ratuje przed trudnymi do odtworzenia błędami, gdy backend doda/zmieni pola lub zwróci nieoczekiwany kształt danych.

Zbliżenie ciemnego ekranu z kodem JavaScript i TypeScript
Źródło: Pexels | Autor: Stanislav Kondratiev

Projekt kluczy zapytań i struktura cache w React Query

Dlaczego spójne klucze są tak ważne

React Query używa kluczy (queryKey) do identyfikacji danych w cache. Jeśli klucze są:

  • niekonsekwentne,
  • budowane „z palca” w różnych miejscach,
  • mieszają stringi z obiektami bez reguły,

to invalidacja szybko zmienia się w loterię. Jedno invalidateQueries czyści pół aplikacji, inne nie czyści nic. Dlatego w dużym SPA dobrze działa zasada: w każdym module domenowym trzymamy fabrykę kluczy.

Fabryka kluczy w module domenowym

Dla modułu users można stworzyć prosty „builder”:

export const usersKeys = {
  all: ['users'] as const,

  lists: () => [...usersKeys.all, 'list'] as const,
  list: (filters: { search?: string } = {}) =>
    [...usersKeys.lists(), { filters }] as const,

  details: () => [...usersKeys.all, 'detail'] as const,
  detail: (id: string) => [...usersKeys.details(), { id }] as const,
};

Zalety takiego podejścia:

  • komponenty nie muszą wiedzieć, jak zbudowany jest klucz,
  • łatwo zainwalidować całą rodzinę zapytań (usersKeys.lists()),
  • klucze są spójne w skali projektu.

W hookach React Query używa się już tylko tych fabryk:

useQuery({
  queryKey: usersKeys.detail(userId),
  queryFn: () => getUserApi(userId),
});

Konwencje nazewnicze i struktura kluczy

Kilka praktycznych ustaleń, które pomagają zespołom:

  • pierwszy element klucza – nazwa domeny (np. 'users', 'orders'),
  • drugi element – typ danych ('list', 'detail', 'stats'),
  • ostatni element – obiekt z parametrami ({ id }, { filters }),
  • klucze zawsze jako tablice (React Query to preferuje).

Przykłady:

['orders', 'list', { page: 1, status: 'OPEN' }]
['orders', 'detail', { id: 'ord_123' }]
['billing', 'invoices', 'list', { customerId: 'cus_1' }]

Dzięki temu w reakcji na mutację można precyzyjnie odświeżyć:

queryClient.invalidateQueries({ queryKey: usersKeys.lists() });
queryClient.invalidateQueries({ queryKey: usersKeys.detail(userId) });

Bez grzebania po kodzie, by „zgadnąć”, jaki klucz został użyty w innym miejscu aplikacji.

Typowanie kluczy zapytań

React Query używa typu QueryKey = readonly unknown[], ale da się to zawęzić. Jeśli fabryki zwracają as const, TypeScript ładnie zachowuje literalne typy:

Silniejsze typowanie kluczy i pomocnicze aliasy

Fabryki kluczy zwracające tablice z as const dają całkiem sporo, ale można pójść o krok dalej i ułatwić życie przy używaniu queryClient oraz przy refaktoryzacjach.

Najprostszy krok to zdefiniowanie aliasu na typ klucza danego modułu:

import type { QueryKey } from '@tanstack/react-query';

export const usersKeys = {
  all: ['users'] as const,
  lists: () => [...usersKeys.all, 'list'] as const,
  list: (filters: { search?: string } = {}) =>
    [...usersKeys.lists(), { filters }] as const,
  details: () => [...usersKeys.all, 'detail'] as const,
  detail: (id: string) => [...usersKeys.details(), { id }] as const,
};

export type UsersQueryKey = ReturnType<
  (typeof usersKeys)[keyof typeof usersKeys]
>;

// przykład użycia:
function invalidateUsersLists(queryClient: QueryClient) {
  const key: UsersQueryKey = usersKeys.lists();
  queryClient.invalidateQueries({ queryKey: key });
}

Dzięki temu mniejsza szansa, że ktoś „z palca” wpisze ['users', 'list', 'coś-nie-tak'] w losowym miejscu aplikacji. Jeśli będzie taki odruch, TypeScript zaprotestuje.

Strategie cache: kiedy krótkie, kiedy „wieczne”

React Query pozwala ustawić staleTime i cacheTime. W małych projektach często wszędzie ląduje ta sama wartość, ale w większym SPA opłaca się podejść do tego bardziej świadomie:

  • dane rzadko zmienne (słowniki, konfiguracje) – mogą mieć długie staleTime lub nawet podejście „cache-uj na wieczność, odświeżaj ręcznie”,
  • listy obiektów modyfikowane przez wielu użytkowników – krótsze staleTime, częstsza invalidacja po mutacjach,
  • dane zależne od URL-a (np. wynik wyszukiwarki) – można agresywnie cache’ować, o ile klucz jest wystarczająco szczegółowy.

Wygodnym trikiem jest zdefiniowanie „presetów” ustawień w jednym module:

// src/api/reactQueryConfig.ts
export const queryPresets = {
  shortLived: {
    staleTime: 5_000,
    cacheTime: 60_000,
  },
  list: {
    staleTime: 10_000,
    cacheTime: 5 * 60_000,
  },
  dictionary: {
    staleTime: Infinity,
    cacheTime: Infinity,
  },
} as const;

A potem używanie ich w hookach:

useQuery({
  queryKey: usersKeys.list(filters),
  queryFn: () => getUsersApi(filters),
  ...queryPresets.list,
});

Zamiast rozstrzelać po kodzie magiczne liczby, trzyma się je w jednym, zrozumiałym miejscu. Po pierwszych skargach użytkowników „czemu ten ekran wciąż pokazuje stare dane?” da się to łatwo skorygować.

Budowanie hooków React Query nad warstwą API krok po kroku

Warstwa API a hook React Query – jasny podział ról

Funkcje API powinny skupiać się na:

  • zbudowaniu URL-a,
  • wywołaniu klienta HTTP,
  • mapowaniu DTO na model domenowy,
  • rzuceniu sensownego błędu, jeśli coś pójdzie nie tak.

Hook React Query natomiast:

  • pakuje funkcję API w queryFn lub mutationFn,
  • ustawia queryKey,
  • konfiguruje cache, retry, enabled itp.,
  • opcjonalnie ustawia onSuccess, onError dla logiki UI.

Dzięki takiemu podziałowi funkcje API da się testować bez Reacta, a hooki są cienką „skórką” dopasowaną do komponentów i UX.

Prosty hook do pobierania detalu

Na początek klasyk – pobieranie pojedynczego użytkownika:

// src/api/modules/users/users.api.ts
export async function getUserApi(id: string): Promise<User> {
  const { data } = await authedHttpClient.get<UserDto>(`/users/${id}`);
  return mapUserDtoToUser(data);
}
// src/api/modules/users/users.queries.ts
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { usersKeys } from './users.keys';
import { getUserApi } from './users.api';
import type { User } from './users.types';

export function useUserDetailQuery(
  id: string | undefined,
  options?: Omit<
    UseQueryOptions<User, unknown, User, ReturnType<typeof usersKeys.detail>>,
    'queryKey' | 'queryFn' | 'enabled'
  >
) {
  return useQuery({
    queryKey: usersKeys.detail(id ?? 'unknown'),
    queryFn: () => {
      if (!id) {
        throw new Error('User id is required');
      }
      return getUserApi(id);
    },
    enabled: !!id,
    ...options,
  });
}

Hook:

  • zaszywa klucz zapytania,
  • pilnuje warunku enabled,
  • przyjmuje opcjonalne options, ale nie pozwala nadpisać kluczowych pól.

W komponencie użycie jest naprawdę mało „ceremonialne”:

const { data: user, isLoading, error } = useUserDetailQuery(userIdFromRoute);

Hook dla listy z filtrami i paginacją

Listy są ciekawsze, bo zwykle dochodzą filtry, sortowanie, paginacja. Można zacząć od prostego kontraktu:

// users.types.ts
export interface UsersListFilters {
  search?: string;
  page?: number;
  pageSize?: number;
}

export interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
}
// users.api.ts
export async function getUsersListApi(
  filters: UsersListFilters
): Promise<PaginatedResponse<User>> {
  const { data } = await authedHttpClient.get<PaginatedResponse<UserDto>>(
    '/users',
    {
      query: filters,
    }
  );

  return {
    ...data,
    items: data.items.map(mapUserDtoToUser),
  };
}
// users.queries.ts
import type { UsersListFilters, PaginatedResponse, User } from './users.types';

export function useUsersListQuery(
  filters: UsersListFilters,
  options?: Omit<
    UseQueryOptions<
      PaginatedResponse<User>,
      unknown,
      PaginatedResponse<User>,
      ReturnType<typeof usersKeys.list>
    >,
    'queryKey' | 'queryFn'
  >
) {
  return useQuery({
    queryKey: usersKeys.list(filters),
    queryFn: () => getUsersListApi(filters),
    keepPreviousData: true,
    ...options,
  });
}

keepPreviousData sprawia, że przy zmianie strony czy filtra tabela nie „miga” pustym stanem, tylko płynnie podmienia zawartość.

Hooki mutujące: tworzenie, edycja, usuwanie

Przy mutacjach kluczowe są dwa elementy:

  • dobrze typowany mutationFn,
  • ściśle określona strategia odświeżania cache (invalidacja lub aktualizacja „w locie”).
// users.api.ts
export interface CreateUserInput {
  firstName: string;
  lastName: string;
  email: string;
}

export async function createUserApi(
  input: CreateUserInput
): Promise<User> {
  const { data } = await authedHttpClient.post<UserDto>('/users', {
    first_name: input.firstName,
    last_name: input.lastName,
    email: input.email,
  });

  return mapUserDtoToUser(data);
}
// users.mutations.ts
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
import { createUserApi, type CreateUserInput } from './users.api';
import { usersKeys } from './users.keys';
import type { User } from './users.types';

export function useCreateUserMutation(
  options?: Omit<
    UseMutationOptions<User, unknown, CreateUserInput, unknown>,
    'mutationFn'
  >
) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input) => createUserApi(input),
    onSuccess: (newUser, variables, context) => {
      // prosty wariant: odśwież listy
      queryClient.invalidateQueries({ queryKey: usersKeys.lists() });

      options?.onSuccess?.(newUser, variables, context);
    },
    ...options,
  });
}

W prostszych modułach sama invalidacja wystarczy. W miejscach, gdzie liczy się responsywność, można zastosować aktualizację cache bez czekania na odświeżenie:

onSuccess: (newUser, variables, context) => {
  queryClient.setQueriesData<PaginatedResponse<User>>(
    { queryKey: usersKeys.lists() },
    (old) => {
      if (!old) return old;
      return {
        ...old,
        items: [newUser, ...old.items],
        total: old.total + 1,
      };
    }
  );

  options?.onSuccess?.(newUser, variables, context);
},

Ważne, żeby takie „ręczne” operacje na cache były dobrze przetestowane – literówka w kluczu lub pomyłka w strukturze łatwo wprowadza wizualny chaos.

Optymistyczne aktualizacje z rollbackiem

Przy edycji lub usuwaniu obiektów UX zyskuje, gdy zmiana pojawia się natychmiast, a backend jest „doganiany” w tle. React Query daje tutaj całkiem przyjazny wzorzec:

// users.api.ts
export interface UpdateUserInput {
  id: string;
  fullName?: string;
  email?: string;
}

export async function updateUserApi(
  input: UpdateUserInput
): Promise<User> {
  const body: Record<string, unknown> = {};
  if (input.fullName) {
    const [firstName, ...rest] = input.fullName.split(' ');
    body.first_name = firstName;
    body.last_name = rest.join(' ');
  }
  if (input.email) {
    body.email = input.email;
  }

  const { data } = await authedHttpClient.post<UserDto>(
    `/users/${input.id}`,
    body
  );

  return mapUserDtoToUser(data);
}
// users.mutations.ts
export function useUpdateUserMutation(
  options?: Omit<
    UseMutationOptions<User, unknown, UpdateUserInput, { previousUser?: User }>,
    'mutationFn' | 'onMutate' | 'onError' | 'onSettled'
  >
) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateUserApi,

    async onMutate(variables) {
      await queryClient.cancelQueries({ queryKey: usersKeys.detail(variables.id) });

      const previousUser = queryClient.getQueryData<User>(
        usersKeys.detail(variables.id)
      );

      if (previousUser) {
        const optimisticUser: User = {
          ...previousUser,
          fullName: variables.fullName ?? previousUser.fullName,
          email: variables.email ?? previousUser.email,
        };

        queryClient.setQueryData(usersKeys.detail(variables.id), optimisticUser);
      }

      return { previousUser };
    },

    onError(error, variables, context) {
      if (context?.previousUser) {
        queryClient.setQueryData(
          usersKeys.detail(variables.id),
          context.previousUser
        );
      }

      options?.onError?.(error, variables, context);
    },

    onSettled(data, error, variables, context) {
      queryClient.invalidateQueries({ queryKey: usersKeys.detail(variables.id) });
      queryClient.invalidateQueries({ queryKey: usersKeys.lists() });

      options?.onSettled?.(data, error, variables, context);
    },

    ...options,
  });
}

Użytkownik widzi zmianę od razu, a gdy backend odrzuci żądanie, dane wracają do stanu poprzedniego. Tylko przy błędach walidacji dobrze jest pokazać w UI czytelną informację, zamiast udawać, że „wszystko się udało”.

Kompozycja hooków API w hooki „biznesowe”

W większych modułach sam zestaw useXxxQuery / useXxxMutation nie wystarcza. Pojawia się potrzeba bardziej „biznesowych” hooków, które łączą kilka zapytań, mutacji i trochę lokalnego stanu.

Zamiast wpychać całą tę logikę w komponent kontenerowy, można tworzyć hooki kompozytowe, np.:

// users.hooks.ts (nad warstwą API)
import { useState } from 'react';
import { useUsersListQuery } from './users.queries';
import { useCreateUserMutation } from './users.mutations';

export function useUsersTable() {
  const [search, setSearch] = useState('');
  const [page, setPage] = useState(1);

  const listQuery = useUsersListQuery({ search, page, pageSize: 20 });
  const createUser = useCreateUserMutation();

  return {
    listQuery,
    createUser,
    filters: {
      search,
      setSearch,
      page,
      setPage,
    },
  };
}

Komponent widzi już tylko useUsersTable i nie musi znać szczegółów React Query. Jeśli w przyszłości zmieni się klient HTTP czy biblioteka cachująca, ingerencja w UI będzie znikoma.

Obsługa błędów i globalne toastery

React Query ma globalny QueryCache i MutationCache, gdzie można podpiąć obsługę błędów. To dobre miejsce na ogólne logowanie lub wyświetlanie domyślnych komunikatów, ale w dużym SPA często przydaje się mieszane podejście:

  • globalny handler – logi, „niezidentyfikowany błąd” dla niespodziewanych przypadków,
  • lokalne onError w hookach – dla przypadków stricte biznesowych (np. „nie można usunąć użytkownika, gdy ma aktywne zamówienia”).

Najczęściej zadawane pytania (FAQ)

Jak zaprojektować warstwę API w dużej aplikacji SPA z użyciem React Query i TypeScript?

Warstwę API warto potraktować jak osobny moduł, a nie „kilka helperów do fetch”. Na dole tworzysz warstwę transportu HTTP (np. httpClient oparty na fetch/Axios), która zna baseURL, nagłówki, obsługę tokena, retry, serializację parametrów i mapuje błędy na wspólny typ (np. ApiError).

Nad nią budujesz moduły domenowe, np. api/modules/users, api/modules/orders. Każdy moduł ma funkcje odpowiadające endpointom (np. getUserById, createOrder) oraz typy DTO request/response w TypeScripcie. Dopiero na samym wierzchu definiujesz hooki React Query, które wywołują te funkcje API i wystawiają do UI data, isLoading, error.

Dlaczego nie powinno się używać fetch bezpośrednio w komponentach React?

Bezpośredni fetch w komponentach powoduje chaos: każdy komponent inaczej obsługuje błędy, inaczej mapuje dane i inaczej zarządza stanem ładowania. Przy kilkudziesięciu endpointach jeszcze da się to ogarnąć, przy kilkuset kończy się „polowaniem” po całym repo przy każdej zmianie w backendzie.

Zamiast tego komponenty powinny korzystać z gotowych hooków domenowych, np. useUserListQuery() albo useCreateOrderMutation(). Wtedy React Query zajmuje się cache, retry i deduplikacją zapytań, a logika HTTP i struktura URL-i są schowane w warstwie API. Komponent renderuje tylko efekt: dane, loading, error.

Jak poprawnie podzielić API na moduły domenowe w dużej aplikacji?

Najprostszy klucz podziału to domeny biznesowe lub bounded contexty z backendu. Zamiast jednego pliku api.ts z setką funkcji, tworzysz katalogi w stylu:

  • api/modules/users
  • api/modules/orders
  • api/modules/billing
  • api/modules/catalog

Każdy moduł zna „swoje” endpointy i DTO, a zespoły mogą przejąć odpowiedzialność za konkretne obszary (np. team „Billing” dba o cały moduł billing). W praktyce ułatwia to też code splitting – możesz ładować tylko API potrzebne dla danej części aplikacji, zamiast wciągać wszystko wszędzie.

Jak połączyć React Query z warstwą API, żeby nie mieszać HTTP w hookach?

Hooki React Query powinny używać wyłącznie funkcji z warstwy API, bez znajomości URL-i czy szczegółów fetch/Axios. Przykładowo: useUserDetailsQuery(userId) wywołuje pod spodem usersApi.getUserById(userId), a React Query zarządza cache i refetch. Hook nie wie, czy transportem jest Axios, Ky czy „ręczny” fetch.

Dodatkowo warto trzymać klucze zapytań w jednym miejscu (np. queryKeys.users.details(userId)), żeby unikać magii typu ['user', id] wpisywanej z głowy w różnych plikach. Dzięki temu zmiana struktury kluczy nie rozsypie całego cache’u.

Jak TypeScript pomaga przy projektowaniu warstwy API w SPA?

TypeScript nadaje warstwie API konkretny kształt: definiujesz typy DTO dla requestów i response’ów, modele domenowe oraz wspólne typy błędów. Gdy backend zmienia kontrakt (np. dodaje pole, zmienia nazwę właściwości), kompilator pokaże wszystkie miejsca, które trzeba poprawić, zamiast czekać na niespodzianki w produkcji.

Dobrym wzorcem jest rozdzielenie typów DTO (taki kształt ma odpowiedź z backendu) od modeli domenowych używanych w UI. Dzięki temu możesz w jednym miejscu zmapować daty na obiekty Date, złączyć pola, przetłumaczyć nazwy itp. Reszta aplikacji widzi już uporządkowany model, a nie „surowy JSON z backa”.

Czym różni się warstwa transportu HTTP od warstwy domenowej API?

Warstwa transportu HTTP to najniższy poziom – realizuje faktyczne połączenie z backendem. Odpowiada za baseURL, nagłówki, tokeny, time-outy, anulowanie zapytań, serializację query params i mapowanie błędów na ujednolicony format. To tu decydujesz, czy używasz fetch, Axios czy innego klienta.

Warstwa domenowa API buduje na tym transportcie funkcje „biznesowe”: getOrdersList, createInvoice, getUserById. Zna ścieżki endpointów tylko w jednym miejscu, trzyma typy DTO i ewentualnie mapuje je na modele domenowe. W idealnym świecie komponenty nigdy nie dotykają ani transportu, ani „gołych” DTO – korzystają wyłącznie z hooków opartych na domenowych funkcjach API.

Jak uniknąć bałaganu w obsłudze błędów przy React Query i rozbudowanym API?

Klucz to centralizacja: wszystkie błędy HTTP powinny być mapowane w jednym miejscu (w warstwie transportu) na spójny typ, np. ApiError z polami typu status, code, message. React Query dostaje już ten ujednolicony błąd, a hooki lub komponenty decydują, jak na niego reagować (toast, modal, redirect na login).

Dobrą praktyką jest też ustalenie zasad „kto za co odpowiada”: transport mapuje błędy techniczne, warstwa domenowa może dodać kontekst (np. „produkt nie istnieje”), a komponent UI jedynie wybiera odpowiedni sposób prezentacji. Dzięki temu nie masz sytuacji, gdzie w jednym miejscu wyskakuje alert, w innym console.log, a w trzecim kompletna cisza.

Co warto zapamiętać

  • Ad-hocowe wywołania fetch w komponentach działają tylko w małych SPA – przy dziesiątkach modułów i endpointów kończy się to chaosem: kopiowaniem URL-i, niespójną obsługą błędów i trudnymi refaktoryzacjami.
  • React Query i TypeScript rozwiązują problemy techniczne (cache, retry, typy), ale same z siebie nie zapewniają architektury – bez spójnej warstwy API powstaje zbiór przypadkowych useQuery, z których każdy działa „po swojemu”.
  • Warstwa API powinna być traktowana jak osobny moduł–kontrakt: ukrywa szczegóły HTTP (baseURL, nagłówki, tokeny, serializację, błędy) i wystawia typowane funkcje domenowe typu getUserById, createOrder, zamiast kazać UI znać ścieżki REST.
  • Transport HTTP (np. httpClient) musi być oddzielony od logiki domenowej: pierwszy zajmuje się tylko „jak” (fetch/Axios, nagłówki, timeouty, globalne błędy), a moduły domenowe (usersApi, ordersApi) opisują „co” (konkretne endpointy, DTO request/response).
  • Kontrakt odpowiedzialności powinien być jasny: warstwa API rozmawia z backendem i zwraca uporządkowane dane/błędy, React Query zarządza stanem i cache, a komponenty UI tylko używają gotowych hooków i renderują data, isLoading, error – bez znajomości URL-i.
Poprzedni artykułSSH dla początkujących: bezpieczne łączenie się z serwerem krok po kroku
Następny artykułDIY: jak uszyć lnianą torebkę handmade na lato krok po kroku
Marcin Michalski
Marcin Michalski – inżynier systemowy i administrator sieci z kilkunastoletnim doświadczeniem w środowiskach korporacyjnych i projektach dla sektora MŚP. Na Pirat-Pirat.pl odpowiada głównie za treści dotyczące infrastruktury IT, bezpieczeństwa, VPN oraz praktycznych aspektów wdrażania nowych rozwiązań. W pracy stawia na testy w realistycznych scenariuszach, porównywanie konfiguracji i analizę kosztów utrzymania. Zanim poleci konkretne narzędzie, sprawdza je w praktyce i konfrontuje z dokumentacją producenta oraz niezależnymi źródłami. Ceni przejrzystość, dlatego w artykułach jasno oddziela fakty, własne doświadczenia i subiektywne wnioski.