Web razvoj
ReactReact QueryTanStack QueryNext.jsApp RouterCachingPerformanseFrontend arhitektura

React Query u velikim aplikacijama: invalidacija cachea, paginacija i obrasci mutacija za stvarne aplikacije

AO
Adrijan Omićević
·15 min čitanja

# Što ovaj vodič pokriva#

React Query je jednostavno krenuti koristiti, a varljivo teško skalirati. Kako aplikacija raste, timovi obično nalete na iste probleme: široke invalidacije koje pokreću over-fetching, cachevi paginacije koji “odlutaju”, optimistična ažuriranja koja pucaju uz konkurentnost i zastarjeli UI koji tiho narušava povjerenje korisnika.

Ovaj vodič fokusira se na najbolje prakse invalidacije cachea u React Queryju i skalabilne obrasce za dizajn query keyjeva, ciljanu invalidaciju, paginaciju i infinite queryje, obrasce mutacija s optimističnim ažuriranjima te pozadinski refetch u stvarnim produkcijskim aplikacijama. Primjeri ciljaju Next.js App Router s client komponentama.

Ako odlučujete između biblioteka za dohvat podataka u App Routeru, pročitajte React Query vs SWR u Next.js App Routeru. Ako vas zanima kako se React Query uklapa s cachingom platforme, pogledajte Next.js strategije cachinga: SSR, ISR, SWR.

# Preduvjeti#

ZahtjevVerzijaNapomene
React18+Potrebno za App Router i ponašanje uz konkurentnost
Next.js14+Primjeri za App Router pretpostavljaju moderne routing konvencije
TanStack Query5+Obrasci i API-ji za React Query v5
Osnovni REST ili GraphQL APIPrimjeri koriste REST-slične endpointove
TypeScriptPreporučenoPuno pomaže oko sigurnosti query keyjeva

# Problem skaliranja: zašto invalidacija cachea postaje skupa#

React Query smanjuje mrežne pozive cachiranjem server statea. U velikim sustavima trošak se pomiče s “broja zahtjeva” na “predvidljivost ažuriranja”.

Tri stvarna failure modea se stalno ponavljaju:

  1. 1
    Over-fetching: jedna mutacija invalidira široke prefikse, refetchajući puno ekrana i trošeći API kvotu.
  2. 2
    Zastarjeli UI: korisnik nešto promijeni, mutacija uspije, ali neke liste i detail prikazi i dalje pokazuju stare podatke dok se ne dogodi slučajni refetch.
  3. 3
    Drift paginacije: paginirane liste i “countovi” se ne slažu, jer je ažuriran samo jedan cache entry.

Korisni mentalni model: server state u React Queryju je skup materijaliziranih prikaza (materialized views). Vaš posao je održavati te prikaze konzistentnima uz minimalan refetch.

🎯 Ključna poruka: U velikim aplikacijama cilj nije “nikad zastarjelo” niti “nikad refetch”. Cilj je predvidljiva svježina uz kontroliran opseg refetcha.

# Dizajn query keyjeva koji skalira#

Query keyjevi određuju identitet cachea. Dizajn ključeva je ono što kasnije omogućuje ciljanu invalidaciju. Ako ključeve postavite loše, kompenzirat ćete širokom invalidacijom, a široka invalidacija je mjesto gdje over-fetching počinje.

Pravila za produkcijske query keyjeve#

Primjenjujte ova pravila dosljedno kroz cijeli codebase:

  1. 1
    Hijerarhijski nizovi, ne stringovi.
  2. 2
    Stabilan poredak parametara u objektima, ili izbjegavajte objekte u keyjevima koristeći eksplicitne tupleove.
  3. 3
    Scope po tenant-u i authu kada je potrebno, da ne miješate cacheve.
  4. 4
    Odvojite “list” i “detail” ključeve kako biste mogli precizno ažurirati svako.
  5. 5
    Uključite filtre i sortiranje u list ključeve kako cachevi ne bi kolidirali.

Praktičan obrazac: factory za keyjeve#

Napravite jedan izvor istine za keyjeve po domeni. Time sprječavate “dovoljno slične” keyjeve koji kasnije razbijaju invalidaciju.

TypeScript
// queryKeys.ts
export const projectsKeys = {
  all: ['projects'] as const,
  lists: () => [...projectsKeys.all, 'list'] as const,
  list: (params: { q?: string; status?: 'active' | 'archived'; page: number; pageSize: number }) =>
    [...projectsKeys.lists(), params] as const,
  details: () => [...projectsKeys.all, 'detail'] as const,
  detail: (id: string) => [...projectsKeys.details(), id] as const,
};

Ovaj dizajn vam omogućuje invalidaciju:

  • jednog project detaila: projectsKeys.detail(id)
  • svih project listi bez obzira na filtre: projectsKeys.lists()
  • svega vezanog uz projekte: projectsKeys.all

⚠️ Upozorenje: Izbjegavajte stavljati ne-serializable vrijednosti u query keyjeve, poput funkcija, klasa, Date objekata ili mutabilnih objekata. To može uzrokovati cache missove i duplirane zahtjeve koji izgledaju kao “React Query refetch-a nasumično”.

Multi-tenant i user-specific keyjevi#

Ako se podaci razlikuju po organizaciji ili korisniku, eksplicitno to navedite. Najčešći produkcijski bug je prikaz cacheiranih podataka druge organizacije nakon promjene organizacije.

ScenarijPrimjer prefiksa ključaZašto je bitno
Single-tenant javni podaci['projects']Jednostavno i sigurno
Multi-tenant org podaci['org', orgId, 'projects']Sprječava miješanje cachea između organizacija
User-specific['me', userId, 'notifications']Izbjegava zastarjele podatke nakon promjene login-a
Role-based prikazi['org', orgId, 'admin', 'auditLogs']Izbjegava slučajno ponovno korištenje cachea

# Najbolje prakse invalidacije cachea u React Queryju#

Invalidacija je poluga koja drži cache koherentnim. Zamka je invalidirati preširoko da bi se “bugovi prestali događati”, što povećava mrežni promet i čini aplikaciju nepredvidljivom.

Dajte prednost ciljanoj invalidaciji umjesto globalne invalidacije#

Mutacija treba invalidirati samo ono na što može utjecati. U praksi to znači invalidirati:

  • detail cache mutiranog entiteta
  • list cacheve gdje se taj entitet pojavljuje
  • izvedene agregate koji ovise o njemu, poput countova i dashboarda
TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
function useUpdateProject() {
  const qc = useQueryClient();
 
  return useMutation({
    mutationFn: async (input: { id: string; name: string }) => {
      const res = await fetch(`/api/projects/${input.id}`, {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ name: input.name }),
      });
      if (!res.ok) throw new Error('Failed to update project');
      return (await res.json()) as { id: string; name: string };
    },
    onSuccess: (updated) => {
      qc.setQueryData(projectsKeys.detail(updated.id), updated);
      qc.invalidateQueries({ queryKey: projectsKeys.lists() });
    },
  });
}

Ovaj obrazac je učinkovit jer:

  • detail stranica odmah postaje konzistentna
  • liste se refetchaju kako bi se uskladile sa serverovim poretkom, filtrima i izračunatim poljima

Kada je invalidacija preskupa#

Neke liste su preskupe za refetch pri svakoj mutaciji, npr.:

  • teški search endpointovi
  • analytics s više joinova
  • spori third-party API-ji

U tim slučajevima, za hot path izravno ažurirajte cache, a refetchajte rjeđe.

Pristup:

  • patchajte najvidljivije cacheve koristeći setQueryData
  • zakazujte pozadinsko usklađivanje s invalidateQueries za uski podskup
  • koristite duži staleTime za teške queryje kako biste izbjegli ponavljani refetch

💡 Savjet: Ako imate rate limitove, pratite “refetch po mutaciji” kao KPI. U mnogim aplikacijama, samo sužavanje invalidacije s ['projects'] na projectsKeys.lists() smanji pozadinski refetch promet za 30 do 70 posto unutar tjedan dana, jer prestajete osvježavati nepovezane “detail” queryje i agregate.

Namjerno koristite djelomično podudaranje ključa#

React Query omogućuje invalidaciju po prefiksu. To je moćno i opasno.

Poziv invalidacijeOpsegTipična upotreba
invalidateQueries({ queryKey: projectsKeys.detail(id) })Jedan entity detailNakon ažuriranja tog entiteta
invalidateQueries({ queryKey: projectsKeys.lists() })Sve varijante listiNakon create, delete, reorder
invalidateQueries({ queryKey: projectsKeys.all })Sve u domeniRijetko, obično tijekom logouta ili velikog synca

Ako vaš tim često poseže za projectsKeys.all, to je signal: nedostaje vam bolja struktura ključeva ili korak ažuriranja cachea.

# Obrasci paginacije koji ostaju konzistentni#

Paginacija uvodi više cacheva za “isti” dataset. Morate odlučiti što konzistentnost znači: po stranici, kroz stranice ili po skupu filtera.

Numerirana paginacija s keepPreviousData#

Za tablice i admin prikaze, numerirana paginacija je predvidljiva i podržava deep-linking. Zadržite prethodnu stranicu vidljivom dok se sljedeća učitava, kako UI ne bi treperio.

TypeScript
import { useQuery } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
export function useProjectsPage(params: {
  q?: string;
  status?: 'active' | 'archived';
  page: number;
  pageSize: number;
}) {
  return useQuery({
    queryKey: projectsKeys.list(params),
    queryFn: async () => {
      const qs = new URLSearchParams({
        q: params.q ?? '',
        status: params.status ?? '',
        page: String(params.page),
        pageSize: String(params.pageSize),
      });
      const res = await fetch(`/api/projects?${qs.toString()}`);
      if (!res.ok) throw new Error('Failed to load projects');
      return (await res.json()) as { items: Array<{ id: string; name: string }>; total: number };
    },
    placeholderData: (prev) => prev,
    staleTime: 30_000,
  });
}

Zašto je staleTime bitan: bez njega možete pokrenuti refetch petlje kad korisnici prebacuju tabove ili kad se komponente remountaju, što izgleda kao “spora paginacija”.

Kako uskladiti “total count” i “items”#

Ako vaš API vraća total, to postaje izvedena vrijednost u cacheu koja može “odlutati” nakon create ili delete. Odaberite jedno:

  • Refetchajte liste na create ili delete, što je najjednostavnije.
  • Ili ažurirajte totale u cacheu, što je brže, ali sklono greškama preko filtera.

Za većinu aplikacija, pouzdan pristup je: nakon create ili delete, invalidirajte prefiks liste i pustite server da ponovno izračuna totale.

# Infinite queryji za feedove i timelineove#

Infinite scrolling je najbolji za feedove gdje korisnici nastavljaju konzumirati sadržaj. Ključ je da query funkcija i getNextPageParam budu deterministični te da izbjegnete invalidiranje cijelog feeda pri svakoj maloj mutaciji.

Postavljanje infinite queryja#

TypeScript
import { useInfiniteQuery } from '@tanstack/react-query';
 
export function useActivityFeed(params: { projectId: string }) {
  return useInfiniteQuery({
    queryKey: ['projects', params.projectId, 'activity', 'infinite'],
    queryFn: async ({ pageParam }) => {
      const cursor = pageParam ? String(pageParam) : '';
      const res = await fetch(`/api/projects/${params.projectId}/activity?cursor=${cursor}`);
      if (!res.ok) throw new Error('Failed to load activity');
      return (await res.json()) as {
        items: Array<{ id: string; message: string; createdAt: string }>;
        nextCursor: string | null;
      };
    },
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    staleTime: 15_000,
  });
}

Strategija mutacija za infinite liste#

Izbjegavajte invalidirati cijeli infinite query ako dodajete samo jednu stavku. Primjerice, kod objave komentara ili activity itema:

  • optimistično ubacite na početak stranice 1
  • zatim invalidirajte samo ako server može presložiti stavke ili primijeniti moderation pravila

Ovo čini feed responzivnim i sprječava skupo ponašanje “reloadaj cijeli svijet”.

ℹ️ Napomena: Infinite queryji se često uparuju s cursor paginacijom. Ako vaš API koristi offset paginaciju za infinite scroll, brisanja i umetanja pomiču offsete i uzrokuju duplikate ili rupe. Cursor-based paginacija izbjegava tu klasu bugova.

# Obrasci mutacija za stvarne aplikacije#

Mutacije su mjesto gdje se UX i ispravnost cachea sudaraju. Skalabilan obrazac omogućuje korisniku trenutačnu povratnu informaciju uz jamstvo eventualne konzistentnosti.

Obrazac 1: Jednostavne mutacije sa setQueryData + invalidate#

Koristite kada:

  • mutacija ažurira jedan entitet
  • imate detail view koji mora odmah odražavati promjene
  • liste se mogu refetchati u pozadini

Ovo smo već koristili u useUpdateProject. To je zadani obrazac za većinu CRUD-a.

Obrazac 2: Optimistična ažuriranja s rollbackom#

Optimistična ažuriranja koristite kada bi čekanje servera vidljivo pokvarilo UX, npr. toggleovi, lajkovi, zvjezdice (starring) ili promjena statusa u tablici.

TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
export function useToggleArchived(projectId: string) {
  const qc = useQueryClient();
 
  return useMutation({
    mutationFn: async (archived: boolean) => {
      const res = await fetch(`/api/projects/${projectId}`, {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ archived }),
      });
      if (!res.ok) throw new Error('Failed to update');
      return (await res.json()) as { id: string; archived: boolean; name: string };
    },
    onMutate: async (archived) => {
      await qc.cancelQueries({ queryKey: projectsKeys.detail(projectId) });
 
      const prev = qc.getQueryData<{ id: string; archived: boolean; name: string }>(
        projectsKeys.detail(projectId)
      );
 
      qc.setQueryData(projectsKeys.detail(projectId), (current: any) =>
        current ? { ...current, archived } : current
      );
 
      return { prev };
    },
    onError: (_err, _archived, ctx) => {
      if (ctx?.prev) qc.setQueryData(projectsKeys.detail(projectId), ctx.prev);
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: projectsKeys.detail(projectId) });
      qc.invalidateQueries({ queryKey: projectsKeys.lists() });
    },
  });
}

Ključne točke:

  • otkažite in-flight queryje da vaš optimistični patch ne bude prepisan
  • napravite snapshot prethodnog stanja za rollback
  • uvijek uskladite stanje nakon settle

Obrazac 3: Ažuriranje više cacheva bez refetchanja svega#

Ako se promijeni ime projekta, to utječe na:

  • detail query
  • svaku stranicu liste koja sadrži taj projekt
  • cacheve rezultata pretraživanja

U tom slučaju može se isplatiti patchati list cacheve kako biste izbjegli refetch “oluju”.

Strategija:

  • ažurirajte detail cache
  • patchajte list cacheve koji su trenutno u memoriji iteriranjem kroz podudarne queryje
TypeScript
import { useQueryClient } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
export function patchProjectNameEverywhere(qc: ReturnType<typeof useQueryClient>, input: { id: string; name: string }) {
  qc.setQueryData(projectsKeys.detail(input.id), (p: any) => (p ? { ...p, name: input.name } : p));
 
  const listQueries = qc.getQueriesData<{ items: Array<{ id: string; name: string }>; total: number }>({
    queryKey: projectsKeys.lists(),
  });
 
  for (const [key, data] of listQueries) {
    if (!data) continue;
    qc.setQueryData(key, {
      ...data,
      items: data.items.map((it) => (it.id === input.id ? { ...it, name: input.name } : it)),
    });
  }
}

Ovo smanjuje mrežni promet i čini UI konzistentnim kroz otvorene tabove. Posebno je korisno u aplikacijama s čestim izmjenama.

# Pozadinski refetch bez živciranja korisnika#

Pozadinski refetch drži podatke svježima, ali default postavke mogu biti bučne u velikim aplikacijama. Najčešći simptom je “moja aplikacija zasipa zahtjevima kad se vratim na tab”.

Preporučeni defaulti za mnoge B2B aplikacije#

PostavkaTipična vrijednostZašto
staleTime15 do 60 sekundiSmanjuje refetch thrash na remount
gcTime10 do 30 minutaDrži nedavno korištene podatke u memoriji
refetchOnWindowFocusfalse za ne-kritične queryjeSprječava focus “oluje”
refetchOnReconnecttrueDobar UX nakon prekida mreže
retry1 do 2Izbjegava retry “oluje” tijekom ispada

Konfigurirajte ovo u QueryClientu.

TypeScript
import { QueryClient } from '@tanstack/react-query';
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 15 * 60_000,
      refetchOnWindowFocus: false,
      refetchOnReconnect: true,
      retry: 1,
    },
  },
});

Ako trebate drugačije ponašanje po queryju, overrideajte lokalno umjesto da sve držite na globalnom defaultu.

Kako izbjeći zastarjeli UI bez over-fetchinga#

Najčišći način da izbjegnete zastarjeli UI nije postaviti staleTime na nulu. Umjesto toga:

  • Za kritične detail prikaze, držite staleTime umjerenim i refetchajte na navigaciju.
  • Nakon mutacija, uskladite stanje ciljanom invalidacijom.
  • Koristite optimistična ažuriranja za trenutačan feedback.
  • Za pozadinske dashboarde, postavite duži staleTime i ponudite gumb za ručno osvježavanje.

To rezultira s manje zahtjeva i boljim doživljajem performansi.

# Next.js App Router: gdje se React Query uklapa#

App Router uvodi server komponente, route-level caching i semantiku cachiranja u fetchu. React Query je i dalje vrijedan, posebno za interaktivni, autentificirani, client-side server state.

Praktična arhitektura za App Router#

Koristite ovaj split:

  • Server komponente obrađuju statične ili cacheable javne podatke, SEO stranice i početni skeleton.
  • Client komponente koriste React Query za autentificirane podatke, interaktivne liste i mutacije.

Ako koristite i Next.js fetch caching, namjerno odaberite koji sloj “posjeduje” svježinu. Čest obrazac je:

  • server fetch za initial render
  • hidratizirati React Query s initialData za prvi ekran
  • pustiti React Query da preuzme daljnji refetch i mutacije

Primjer client komponente u App Routeru#

TypeScript
'use client';
 
import { useQuery } from '@tanstack/react-query';
import { projectsKeys } from './queryKeys';
 
export function ProjectDetailsClient({ id }: { id: string }) {
  const q = useQuery({
    queryKey: projectsKeys.detail(id),
    queryFn: async () => {
      const res = await fetch(`/api/projects/${id}`);
      if (!res.ok) throw new Error('Failed to load project');
      return (await res.json()) as { id: string; name: string; archived: boolean };
    },
    staleTime: 20_000,
  });
 
  if (q.isLoading) return 'Loading...';
  if (q.isError) return 'Failed to load';
  return q.data.name;
}

Za širi kontekst cachiranja, povežite ovo s Next.js strategije cachinga: SSR, ISR, SWR. Bitno je izbjeći da se dva caching sustava međusobno “tuku” konfliktnim pravilima svježine.

# Observability: mjerite učinkovitost cachea, ne samo latenciju API-ja#

U velikim sustavima trebate feedback petlje. Problemi s invalidacijom cachea se manifestiraju kao:

  • neočekivani skokovi API poziva nakon releaseova
  • prijave korisnika tipa “spremilo se, ali i dalje vidim staru vrijednost”
  • treperenje UI-ja ili loading stanja tijekom malih interakcija

Pratite barem ove metrike:

  • stopu query zahtjeva po ruti
  • broj mutacija i stopu grešaka mutacija
  • prosječno vrijeme do svježih podataka nakon mutacije
  • refetch-on-focus događaje po sesiji

Zatim korelirajte s logovima i traceovima. Tu observability postaje isplativ, posebno za distribuirane backendove i third-party API-je. Praktične upute za setup su u Vodič za observability web aplikacija: logovi, metrike, tracing.

💡 Savjet: Dodajte lagani client-side brojač za pozive invalidateQueries po korisničkoj sesiji. Ako skoči nakon releasea featurea, vjerojatno ste uveli široke invalidacije ili nestabilne query keyjeve.

# Česte zamke i kako ih izbjeći#

Zamka 1: Korištenje nestabilnih objekata u query keyjevima#

Ako u svakom renderu kreirate novi params objekt i on nije stabilan, možete stvoriti više cache entryja.

Rješenje:

  • koristite key factory
  • držite parametre minimalnima
  • osigurajte stabilan poredak properties kada koristite objekte

Zamka 2: Preširoka invalidacija nakon svake mutacije#

Ako invalidirate ['projects'] nakon svakog ažuriranja projekta, refetchat ćete detail prikaze, liste, analitiku i sve ostalo pod tim prefiksom.

Rješenje:

  • invalidirajte detail(id) plus lists()
  • izravno ažurirajte cacheve za vidljive ekrane

Zamka 3: Invalidacija infinite queryja nakon svake male promjene#

Invalidiranje cijelog feeda nakon male akcije uništava korisnikov doživljaj performansi.

Rješenje:

  • optimistični prepend ili patch
  • usklađivanje ciljanim refetchom samo kada je nužno

Zamka 4: Oslanjanje na refetch-on-focus da “s vremenom popravi stanje”#

To stvara nekonzistentno ponašanje među browserima i navikama korisnika. Neki korisnici nikad ne napuste tab.

Rješenje:

  • usklađujte nakon mutacija
  • po potrebi odaberite eksplicitne intervale pozadinskog refetcha

# Ključne poruke#

  • Dizajnirajte hijerarhijske, stabilne query keyjeve s domain key factoryjima kako bi invalidacija bila uska i predvidljiva.
  • Kao default koristite ciljanu invalidaciju: invalidirajte entity detail plus relevantne list prefikse, ne cijelu domenu.
  • Kombinirajte setQueryData s invalidateQueries u stvarnim aplikacijama: trenutačna konzistentnost UI-ja sada, usklađivanje sa serverom ubrzo nakon.
  • Za paginaciju preferirajte keepPreviousData i invalidirajte list prefikse na create ili delete kako bi total ostao konzistentan.
  • Koristite infinite queryje s cursor paginacijom i izbjegavajte invalidirati cijeli feed za male mutacije patchanjem stranice 1.
  • Smanjite over-fetching razumnim defaultima za staleTime i refetch na fokus, a zatim utjecaj potvrdite kroz observability.

# Zaključak#

React Query dobro skalira kada invalidaciju cachea tretirate kao arhitektonsku odluku, a ne kao naknadnu misao. Stabilni query keyjevi, uska invalidacija i disciplinirani obrasci mutacija daju predvidljivu konzistentnost UI-ja bez pretvaranja svake korisničke akcije u refetch oluju.

Ako želite pomoć u primjeni ovih najboljih praksi invalidacije cachea u React Queryju u Next.js App Router codebaseu, Samioda može auditirati vaše query keyjeve i invalidation graf, smanjiti over-fetching i sigurno implementirati optimistična ažuriranja. Javite se putem samioda.com kako bismo razgovarali o data layeru vaše aplikacije.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.