Web razvoj
ReactTanStack QueryOfflinePWATestiranjeMSW

React aplikacije prilagođene radu offline uz TanStack Query: perzistencija, ponovni pokušaji i optimistični UI (vodič za 2026.)

AO
Adrijan Omićević
·15 min čitanja

# Što ćete izgraditi#

Ovaj vodič pokazuje kako isporučiti React aplikaciju prilagođenu radu offline uz TanStack Query, s fokusom na React Query offline perzistenciju, retry i backoff te optimistični UI koji se sigurno usklađuje kad se mreža vrati.

Implementirat ćete tri sloja koji rade zajedno:

  1. 1
    Perzistirani query cache kako bi korisnici vidjeli podatke nakon reloada i tijekom offline sesija.
  2. 2
    Strategiju ponovnih pokušaja i backoffa koja izbjegava “bombardiranje” nestabilnih mreža i održava UI responzivnim.
  3. 3
    Optimistične mutacije koje djeluju trenutno, a ipak ostaju ispravne nakon ponovnog spajanja.

Ako već koristite TanStack Query u većim sustavima, ovaj vodič nadopunjuje naše naprednije obrasce za velike aplikacije, invalidaciju cachea i ergonomiju mutacija: React Query u većim sustavima.

# Preduvjeti#

ZahtjevVerzijaNapomene
React18+Bilo koji router je OK
TanStack Query5+Ovaj vodič pretpostavlja v5 API-je
TypeScriptPreporučenoPrimjeri koriste TS-friendly obrasce
Service worker (opcionalno)Preporučeno za pravi PWA offline shell
MSWNajnovijeZa testiranje offline i nestabilne mreže

Za širu offline produkt strategiju, uključujući caching u service workeru i instalabilnost, pogledajte naš PWA vodič: Vodič za Progressive Web App.

# Offline-first vs offline-friendly: odaberite pravi cilj#

Većini produkt timova ne treba “offline-first za sve”. Praktičniji cilj je offline-friendly:

  • Offline u read-only načinu za ključne ekrane: zadnji poznati podaci, liste, detaljni prikazi.
  • Write-queue offline za odabrane mutacije: bilješke, stavke checkliste, lagani nacrti.
  • Degraded mode za sve ostalo: prikažite offline banner i onemogućite rizične akcije.

Ovo je važno jer perzistiranje i usklađivanje svih podataka povećava kompleksnost i može otvoriti sigurnosne probleme ako osjetljive payloadove spremate nešifrirane.

Dobro pravilo je krenuti s top 3 korisnička scenarija. Ako oni rade offline (ili se uredno degradiraju), dobit ćete mjerljive dobitke u zadržavanju korisnika i dovršavanju zadataka. Googleov Web.dev navodi da pouzdane performanse i otpornost mogu značajno povećati engagement; u praksi timovi obično vide manje “rage tapova”, manje propalih sesija na mobilnim mrežama i bolju konverziju u scenarijima poput putovanja na posao.

Odlučite što “offline” znači za vašu aplikaciju#

PodručjeOffline ciljZašto
Javni katalog / sadržajPerzistirati i prikazatiPotiče engagement i bez prijave
Autentificirani dashboardDjelomičnoPerzistirati zadnje poznato, ali izbjegavati tajne
Obrasci / nacrtiQueue gdje je sigurnoVisoka percipirana pouzdanost
Plaćanja / kritične transakcijeOnemogućiti offlineIzbjegnite duple naplate i audit probleme

🎯 Ključna poruka: Offline-friendly je prvo produkt odluka. Perzistirajte i stavljajte u queue samo ono što poboljšava stvarne korisničke tokove bez stvaranja sigurnosnog ili correctness duga.

# Korak 1: Postavite TanStack Query za perzistenciju#

TanStack Query podržava perzistenciju putem paketa @tanstack/query-persist-client. Ideja je jednostavna:

  • Pri startu aplikacije rehidrirajte cache iz pohrane.
  • Tijekom rada perzistirajte promjene natrag u pohranu.
  • Primijenite pravila za max age i buster kako ne biste zauvijek učitavali zastarjele podatke.

Instalirajte ovisnosti#

Bash
npm i @tanstack/react-query @tanstack/query-persist-client

Napravite persister#

Persister je most prema pohrani. Na webu obično koristite localStorage ili IndexedDB.

  • localStorage je jednostavan, sinkron i malen.
  • IndexedDB se bolje skalira i izbjegava blokiranje main threada.

Ako trebate velike skupove podataka ili česta zapisivanja, preferirajte IndexedDB. Ako perzistirate mali “zadnje poznato stanje” subset, localStorage je često dovoljan.

Ispod je minimalni localStorage persister. Namjerno je malen i lako testabilan.

TypeScript
// persister.ts
type Persisted = string | null;
 
export function createLocalStoragePersister(key: string) {
  return {
    persistClient: async (client: unknown) => {
      localStorage.setItem(key, JSON.stringify(client));
    },
    restoreClient: async (): Promise<unknown | undefined> => {
      const raw: Persisted = localStorage.getItem(key);
      return raw ? JSON.parse(raw) : undefined;
    },
    removeClient: async () => {
      localStorage.removeItem(key);
    },
  };
}

Povežite perzistenciju u rootu aplikacije#

Koristite PersistQueryClientProvider kako biste osigurali da se rehidracija dogodi prije nego što aplikacija ovisi o cacheiranim podacima.

TypeScript
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 1000 * 60 * 60 * 24,
      refetchOnWindowFocus: false,
    },
  },
});
TypeScript
// AppProviders.tsx
import React from 'react';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { queryClient } from './queryClient';
import { createLocalStoragePersister } from './persister';
 
const persister = createLocalStoragePersister('rq-cache-v1');
 
export function AppProviders(props: { children: React.ReactNode }) {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister,
        maxAge: 1000 * 60 * 60 * 24,
        buster: '2026-08-31',
      }}
      onSuccess={() => {
        queryClient.resumePausedMutations();
        queryClient.invalidateQueries();
      }}
    >
      {props.children}
    </PersistQueryClientProvider>
  );
}

Što time dobivate:

  • Cache preživljava refresh i restart browsera.
  • Pauzirane mutacije (kad ste offline) mogu se nastaviti nakon reloada.
  • Invalidate na startupu osigurava usklađivanje sa serverom kad ste online.

ℹ️ Napomena: invalidateQueries() nakon hidratacije je sigurna početna postavka, ali može biti skupa u velikim aplikacijama. Bolji pristup je invalidirati samo query keyeve kojima je svježina kritična, a ostale ostaviti isključivo za offline-read.

Perzistirajte samo ono što vam treba#

Slijepo perzistiranje svega može:

  • izložiti osjetljive podatke u pohrani na uređaju,
  • povećati vrijeme startupa i potrošnju prostora,
  • uzrokovati bugove tipa “zastarjelo, ali izgleda ispravno”.

Koristite dehydration filtre kako biste perzistirali određene upite. Čest pristup je: perzistirajte samo uspješne upite, samo određene query keyeve i samo one koji nisu osjetljivi za korisnika.

Primjer strategije:

  • Perzistirati “catalog”, “projects list”, “profile display fields”.
  • Ne perzistirati “access tokens”, “PII-heavy detail views”, “admin screens”.

Filtriranje možete implementirati pomoću dehydrateOptions ovisno o vašem persister setupu i verziji TanStack Queryja. Ako filtriranje nije jednostavno, pragmatična alternativa je namespacati offline-safe upite i hidratirati samo njih u dedicated clientu koji se koristi za offline ekrane.

⚠️ Upozorenje: Perzistiranje autentificiranih korisničkih podataka u localStorage može kršiti interna sigurnosna pravila. Ako morate perzistirati osjetljive podatke, koristite IndexedDB uz enkripciju i svejedno zadržite strogi max age.

# Korak 2: Izgradite offline signal i djelomični offline način rada#

Offline UX puca kad aplikacija ne komunicira jasno stanje. Trebate jedan izvor istine za “network status” koji se koristi za:

  • banner,
  • onemogućavanje akcija,
  • prilagodbu retry logike,
  • odluku treba li refetch.

Minimalni hook za status mreže#

TypeScript
// useNetworkStatus.ts
import { useEffect, useState } from 'react';
 
export function useNetworkStatus() {
  const [online, setOnline] = useState(() => navigator.onLine);
 
  useEffect(() => {
    const on = () => setOnline(true);
    const off = () => setOnline(false);
    window.addEventListener('online', on);
    window.addEventListener('offline', off);
    return () => {
      window.removeEventListener('online', on);
      window.removeEventListener('offline', off);
    };
  }, []);
 
  return { online };
}

Obrazac djelomičnog offline rada: “Čitaj iz cachea, blokiraj rizične upise”#

Praktičan obrazac je:

  • Queryji renderaju iz cachea.
  • Mutacije se ili:
    • stavljaju u queue i nastavljaju kasnije ako je sigurno, ili
    • onemogućuju uz jasan razlog.

Ovu odluku možete centralizirati:

Tip operacijeOffline ponašanjePrimjer
Idempotentni upisiQueueToggle “starred”, update lokalnog nacrta
Ne-idempotentni upisiBlokiratiPlaćanje, kreiranje faktura, slanje emailova
ČitanjaPoslužiti iz cacheaLista projekata, ranije otvoreni detalji

💡 Savjet: Stavite offline politiku uz API klijent, ne u nasumične UI gumbe. Konzistentnost sprječava zbrku tipa “neke akcije rade offline, neke ne”.

# Korak 3: Retries i backoff koji ne kažnjavaju korisnike#

TanStack Query retries su odlični za nestabilne mreže, ali default ponašanje može izgledati pokvareno offline. Ako je mobitel u airplane modu, ne želite:

  • ponavljane spinnere,
  • drenažu baterije,
  • ili desetke queued requestova.

Preporučena retry strategija#

  • Ako ste offline, ne pokušavajte ponovno.
  • Ako ste online, retry samo za prolazne greške.
  • Koristite eksponencijalni backoff s gornjom granicom.
  • Nemojte retryati 400-level validation greške.

Evo reusable postavki za retry i retryDelay:

TypeScript
// queryDefaults.ts
type AnyError = unknown;
 
function isRetryableStatus(status?: number) {
  return status === 408 || status === 429 || (status !== undefined && status >= 500);
}
 
export function createRetryOptions(isOnline: () => boolean) {
  return {
    retry: (failureCount: number, error: AnyError) => {
      if (!isOnline()) return false;
 
      const status = (error as any)?.status ?? (error as any)?.response?.status;
      if (status && !isRetryableStatus(status)) return false;
 
      return failureCount < 3;
    },
    retryDelay: (attemptIndex: number) => {
      const base = 1000;
      const delay = base * 2 ** attemptIndex;
      return Math.min(delay, 30_000);
    },
  };
}

Zatim to primijenite:

TypeScript
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { createRetryOptions } from './queryDefaults';
 
const isOnline = () => navigator.onLine;
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      ...createRetryOptions(isOnline),
      staleTime: 30_000,
      refetchOnReconnect: true,
      refetchOnWindowFocus: false,
    },
    mutations: {
      ...createRetryOptions(isOnline),
    },
  },
});

Zašto je ovo važno:

  • Izbjegavate gubitak vremena na sigurne neuspjehe.
  • Korisnici vide stabilna UI stanja umjesto beskonačnog učitavanja.
  • Smanjuje se opterećenje servera tijekom ispada.

Backoff i UX stanja#

Nemojte skrivati backoff iza spinnera. Prikažite:

  • “Ponovni pokušaj za 4 s…” na kritičnim ekranima, ili
  • non-blocking toast, ili
  • gumb “Pokušaj ponovno” nakon zadnjeg pokušaja.

To poboljšava i support, jer korisnici mogu razumjeti je li aplikacija zapela ili namjerno čeka.

# Korak 4: Optimistični updateovi koji se sigurno usklađuju#

Optimistični UI je ono što offline-friendly aplikacije čini brzim. Zamka je ispravnost: optimistični updateovi moraju se moći poništiti i uskladiti sa serverom.

Sigurna optimistična mutacija tipično ima ove korake:

  1. 1
    Otkažite odlazne refetchove za pogođene queryje.
  2. 2
    Snimite prethodnu vrijednost cachea (snapshot).
  3. 3
    Primijenite patch update na cache.
  4. 4
    Pokušajte izvršiti mutaciju.
  5. 5
    Na grešku napravite rollback iz snapshota.
  6. 6
    Na uspjeh uskladite stanje pomoću server odgovora.
  7. 7
    Na settle napravite refetch ili invalidate kako biste osigurali kanoničko stanje.

Primjer: optimistični toggle s rollbackom#

Pretpostavimo da imate listu zadataka i toggle mutaciju. Ovaj primjer updatea jednu stavku u cacheiranoj listi.

TypeScript
// useToggleTask.ts
import { useMutation } from '@tanstack/react-query';
import { queryClient } from './queryClient';
 
type Task = { id: string; done: boolean; updatedAt: string };
type Tasks = Task[];
 
async function apiToggleTask(id: string, done: boolean): Promise<Task> {
  const res = await fetch(`/api/tasks/${id}`, {
    method: 'PATCH',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ done }),
  });
  if (!res.ok) throw Object.assign(new Error('Request failed'), { status: res.status });
  return res.json();
}
 
export function useToggleTask() {
  return useMutation({
    mutationFn: async (vars: { id: string; done: boolean }) =>
      apiToggleTask(vars.id, vars.done),
    onMutate: async (vars) => {
      await queryClient.cancelQueries({ queryKey: ['tasks'] });
 
      const previous = queryClient.getQueryData<Tasks>(['tasks']);
 
      queryClient.setQueryData<Tasks>(['tasks'], (current) => {
        if (!current) return current;
        return current.map((t) => (t.id === vars.id ? { ...t, done: vars.done } : t));
      });
 
      return { previous };
    },
    onError: (_err, _vars, ctx) => {
      if (ctx?.previous) queryClient.setQueryData(['tasks'], ctx.previous);
    },
    onSuccess: (serverTask) => {
      queryClient.setQueryData<Tasks>(['tasks'], (current) => {
        if (!current) return current;
        return current.map((t) => (t.id === serverTask.id ? serverTask : t));
      });
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['tasks'] });
    },
  });
}

Ovo je sigurno jer:

  • Rollback je determinističan.
  • Server odgovor postaje kanoničan kad je dostupan.
  • Refetch nakon settle-a čisti rubne slučajeve.

Dizajniranje optimističnih updateova za offline queueing#

Ako želite da mutacije rade offline, trebate dodatno ograničenje: optimistično stanje mora sadržavati dovoljno informacija da se kasnije može mergeati.

Praktični obrasci:

ObrazacRadi offlineKada koristiti
Zamjena server odgovoromDjelomičnoSamo kad očekujete brz uspjeh
Patch-based update s rollbackomDaJednostavni updateovi poput togglea
Lokalni “pending” flag po entitetuDaListe gdje se redoslijed može mijenjati
Client-generated ID-jeviDaKreiranje stavki offline, kasnija sinkronizacija

Robustan create flow treba client ID-jeve kako biste izbjegli duplikate:

  • Kreirajte lokalno s id = client-uuid.
  • Prikažite odmah uz status = pending.
  • Na uspjeh mapirajte na server ID i ažurirajte reference.
  • Na neuspjeh zadržite i prikažite “Tap to retry” ili napravite rollback.

⚠️ Upozorenje: Optimistični “create” bez client ID-jeva uzrokuje duple redove nakon reconnecta jer UI ne može povezati lokalnu stavku s onom koju je server kreirao.

Pravila usklađivanja koja izbjegavaju korupciju podataka#

Pri ponovnom spajanju morate obraditi konflikte. Držite to jednostavno:

  1. 1
    Ako server vrati noviji updatedAt, server pobjeđuje.
  2. 2
    Ako imate pending lokalne promjene, ponovno ih primijenite kao patcheve nakon refetcha.
  3. 3
    Ako API to podržava, šaljite If-Match s ETagovima ili brojem verzije kako biste detektirali konflikte.

Čak i bez formalnog rješavanja konflikata, probleme možete smanjiti invalidacijom i refetchom nakon settle-a te sužavanjem optimističnih updateova na najmanji mogući segment cachea.

Za više obrazaca mutacija i skaliranje, pogledajte: invalidacija cachea, paginacija, mutacije u većim sustavima.

# Korak 5: Offline perzistencija susreće optimistični UI#

Perzistencija mijenja životni ciklus:

  • Pending optimistična promjena može ostati u cacheu i nakon reloada.
  • Korisnici mogu ponovno otvoriti aplikaciju offline i vidjeti pending stanje.

Vaš UI to mora eksplicitno modelirati:

UI elementOnline stanjeOffline s pending mutacijama
Stavka listeNormalnoPrikažite oznaku “Pending”
Gumb “Spremi”Omogućen“Sinkronizirat će se kad budete online” ili onemogućen
Toast / bannerSkrivenOffline banner + broj stavki u queueu

Ako ne prikazujete pending stanje, korisnici će pretpostaviti da je akcija uspjela na serveru i kasnije se neugodno iznenaditi.

Minimalni banner može čitati broj cacheiranih mutacija i prikazati ga:

  • Koristite duljinu mutation cachea u TanStack Queryju.
  • Ili spremite lagani “outbox count” u state aplikacije ako gradite vlastiti queue.

# Korak 6: Testiranje offline rada, retryja i optimističnog UI-ja s MSW-om#

Offline funkcionalnosti koje ne testirate će regresirati. Trebate testove koji simuliraju:

  • mrežni pad,
  • sporu mrežu,
  • reconnect,
  • i server usklađivanje.

MSW je idealan jer presreće requestove na mrežnom sloju. Ako vaš tim treba širi baseline za testiranje, ovdje smo opisali širu strategiju: strategija testiranja s Vitest, React Testing Library i MSW.

Primjer MSW handlera za nestabilnu mrežu#

TypeScript
// test/handlers.ts
import { http, HttpResponse } from 'msw';
 
let failNext = true;
 
export const handlers = [
  http.get('/api/tasks', () => {
    return HttpResponse.json([
      { id: '1', done: false, updatedAt: '2026-08-01T10:00:00Z' },
    ]);
  }),
 
  http.patch('/api/tasks/:id', async ({ request, params }) => {
    if (failNext) {
      failNext = false;
      return new HttpResponse(null, { status: 503 });
    }
    const body = (await request.json()) as any;
    return HttpResponse.json({
      id: String(params.id),
      done: Boolean(body.done),
      updatedAt: '2026-08-31T12:00:00Z',
    });
  }),
];

Testirajte rollback optimističnog UI-ja i retry ponašanje#

Ovaj test provjerava da:

  • UI radi optimistični update.
  • Prvi request pada.
  • Cache se vraća (rollback) ili refetch ispravlja stanje.
  • Drugi pokušaj uspije i uskladi stanje.
TypeScript
// test/offline-optimistic.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
 
test('optimistic toggle reconciles after transient failure', async () => {
  render(/* your app wrapped with AppProviders */);
 
  const toggle = await screen.findByRole('button', { name: /toggle task 1/i });
 
  await userEvent.click(toggle);
  expect(await screen.findByText(/done: true/i)).toBeInTheDocument();
 
  await userEvent.click(toggle);
  expect(await screen.findByText(/done: false/i)).toBeInTheDocument();
});

Da biste eksplicitno testirali offline, simulirajte offline status pomoću:

  • mockanja navigator.onLine,
  • dispatchanja offline i online eventova,
  • MSW-a koji vraća network error.

U unit testovima determinističan pristup je bolji nego oslanjanje na stvarne browser offline togglove.

💡 Savjet: Dodajte jedan “offline smoke test” po kritičnom korisničkom toku. Za većinu aplikacija to su 3 do 5 testova i hvataju većinu regresija: startup hidratacija, čitanje liste, jedan queued write i usklađivanje nakon reconnecta.

# Česte zamke i kako ih izbjeći#

Perzistiranje previše podataka#

Ako perzistirate cijele odgovore za svaki query, s vremenom ćete isporučiti:

  • sporu hidrataciju,
  • napuhanu pohranu,
  • i slučajno izlaganje osjetljivih podataka.

Rješenje: perzistirajte samo query keyeve koji pogone offline UX i postavite strogi maxAge.

Agresivno retryanje tijekom ispada#

Ako backend padne, default retries pomnožen s tisućama klijenata može uzrokovati thundering herd. Korisnici također vide ponavljana loading stanja.

Rješenje: zaustavite retry kad ste offline, ograničite broj retryja i delay, i failajte brzo za statuse koji se ne retryaju.

Optimistični updateovi bez puta za usklađivanje#

Optimistični UI nije “postavi stanje i nadaj se”. Bez snapshot rollbacka i server usklađivanja, akumuliraju se nekonzistentnosti.

Rješenje: uvijek implementirajte onMutate snapshot, onError rollback i onSuccess kanonički update.

Neprikazivanje pending statusa#

Ako UI skriva queued posao, korisnici će dvaput slati ili pretpostaviti uspjeh.

Rješenje: prikažite offline banner plus pending indikatore po stavci i po ekranu.

# Ključne poruke#

  • Perzistirajte samo offline-kritične query keyeve i primijenite strogi max age kako bi React Query offline perzistencija bila sigurna i brza.
  • Zaustavite retry kad ste offline i koristite ograničeni eksponencijalni backoff za prolazne greške kako biste zaštitili i UX i opterećenje backenda.
  • Implementirajte optimistične updateove sa snapshot rollbackom i server usklađivanjem, a zatim invalidirajte kako biste garantirali kanoničko stanje.
  • Koristite djelomični offline mode: čitajte iz cachea svugdje, queueajte samo idempotentne upise i blokirajte rizične akcije uz jasne poruke.
  • Testirajte offline i tokove s nestabilnom mrežom uz MSW, uključujući hidrataciju, retry ponašanje i optimistično usklađivanje nakon reconnecta.

# Zaključak#

Offline-friendly UX je složen sustav: perzistencija čini podatke dostupnima, retry strategija sprječava loše mrežne petlje, a optimistični UI održava osjećaj instantnosti uz zadržavanje ispravnosti.

Ako želite pomoć pri implementaciji offline obrazaca u produkcijskom React i Next.js codebaseu, Samioda može auditirati vaš TanStack Query setup, definirati offline politiku po featureu te isporučiti perzistenciju, queueing i MSW test coverage potreban da sve ostane stabilno. Javite se putem naše stranice i podijelite ključne korisničke tokove i API ograničenja 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.