# Š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:
- 1Perzistirani query cache kako bi korisnici vidjeli podatke nakon reloada i tijekom offline sesija.
- 2Strategiju ponovnih pokušaja i backoffa koja izbjegava “bombardiranje” nestabilnih mreža i održava UI responzivnim.
- 3Optimistič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#
| Zahtjev | Verzija | Napomene |
|---|---|---|
| React | 18+ | Bilo koji router je OK |
| TanStack Query | 5+ | Ovaj vodič pretpostavlja v5 API-je |
| TypeScript | Preporučeno | Primjeri koriste TS-friendly obrasce |
| Service worker (opcionalno) | — | Preporučeno za pravi PWA offline shell |
| MSW | Najnovije | Za 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čje | Offline cilj | Zašto |
|---|---|---|
| Javni katalog / sadržaj | Perzistirati i prikazati | Potiče engagement i bez prijave |
| Autentificirani dashboard | Djelomično | Perzistirati zadnje poznato, ali izbjegavati tajne |
| Obrasci / nacrti | Queue gdje je sigurno | Visoka percipirana pouzdanost |
| Plaćanja / kritične transakcije | Onemogućiti offline | Izbjegnite 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#
npm i @tanstack/react-query @tanstack/query-persist-clientNapravite persister#
Persister je most prema pohrani. Na webu obično koristite localStorage ili IndexedDB.
localStorageje jednostavan, sinkron i malen.IndexedDBse 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.
// 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.
// 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,
},
},
});// 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
localStoragemož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#
// 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 operacije | Offline ponašanje | Primjer |
|---|---|---|
| Idempotentni upisi | Queue | Toggle “starred”, update lokalnog nacrta |
| Ne-idempotentni upisi | Blokirati | Plaćanje, kreiranje faktura, slanje emailova |
| Čitanja | Poslužiti iz cachea | Lista 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:
// 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:
// 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:
- 1Otkažite odlazne refetchove za pogođene queryje.
- 2Snimite prethodnu vrijednost cachea (snapshot).
- 3Primijenite patch update na cache.
- 4Pokušajte izvršiti mutaciju.
- 5Na grešku napravite rollback iz snapshota.
- 6Na uspjeh uskladite stanje pomoću server odgovora.
- 7Na 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.
// 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:
| Obrazac | Radi offline | Kada koristiti |
|---|---|---|
| Zamjena server odgovorom | Djelomično | Samo kad očekujete brz uspjeh |
| Patch-based update s rollbackom | Da | Jednostavni updateovi poput togglea |
| Lokalni “pending” flag po entitetu | Da | Liste gdje se redoslijed može mijenjati |
| Client-generated ID-jevi | Da | Kreiranje 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:
- 1Ako server vrati noviji
updatedAt, server pobjeđuje. - 2Ako imate pending lokalne promjene, ponovno ih primijenite kao patcheve nakon refetcha.
- 3Ako API to podržava, šaljite
If-Matchs 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 element | Online stanje | Offline s pending mutacijama |
|---|---|---|
| Stavka liste | Normalno | Prikažite oznaku “Pending” |
| Gumb “Spremi” | Omogućen | “Sinkronizirat će se kad budete online” ili onemogućen |
| Toast / banner | Skriven | Offline 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#
// 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.
// 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
offlineionlineeventova, - 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
Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.
Više iz kategorije Web razvoj
Sve →Vodič za virtualizaciju u Reactu: Windowing velikih lista i gridova s TanStack Virtual (i kada to ne raditi)
Praktičan vodič za 2026. za virtualizaciju u Reactu s TanStack Virtual: izgradite brze liste i gridove, dodajte beskonačno učitavanje i sticky zaglavlja, profilirajte mjerljiva poboljšanja i izbjegnite česte rubne slučajeve poput dinamičkih visina redaka i zamki pristupačnosti.
Next.js Supabase Realtime u 2026: End-to-End nacrt za chat, presence i suradnju
Izgradite production-ready realtime UI s Next.js App Routerom i Supabase Realtime: dizajn sheme, RLS, optimistička ažuriranja, presence, skaliranje i rješavanje problema s dupliciranim eventima i neusklađenim dozvolama.
Autorizacija u Next.js App Routeru: RBAC vs ABAC uz middleware, RLS i obrasce politika
Praktičan vodič za autorizaciju u Next.js App Routeru uz RBAC i ABAC — s provjerama u middlewareu, zaštitama u server komponentama, RLS-om koji se provodi u bazi, matricom odluke i gotovim obrascima politika za copy-paste.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
Strategija testiranja Reacta u 2026.: Vitest + React Testing Library + MSW za sigurne releaseove
Pragmatična strategija testiranja React aplikacija u 2026. uz Vitest, React Testing Library i MSW. Naučite realističnu testnu piramidu, smanjite flaky testove i isporučujte s pouzdanjem uz CI-spremne obrasce.
Moderna React frontend arhitektura: moduli po značajkama, granice i skalabilnost
Praktičan vodič za React frontend arhitekturu temeljenu na modulima po značajkama: jasne granice, zajednički slojevi, pravila ovisnosti i održiva strategija strukture mapa za React i Next.js.
Kontrolna lista za React code review koju koristimo: performanse, pristupačnost i održivost
Praktična kontrolna lista za React code review usmjerena na performanse, pristupačnost i održivost, s primjerima, savjetima za automatizaciju i copy-paste predloškom.