TanStack Query în React Native: Ghid Complet pentru Data Fetching și Cache Offline în 2026

Cum folosești TanStack Query v5 în React Native și Expo pentru data fetching, caching automat, mutații cu optimistic updates și persistență offline cu MMKV.

Actualizat: 21 iulie 2026

TanStack Query în React Native este o bibliotecă de gestionare a stării de server care se ocupă automat de fetching, caching, sincronizare și invalidare a datelor asincrone, eliminând nevoia de a scrie manual reduceri Redux sau slice-uri pentru fiecare endpoint. Vin din partea de web, unde React Query a devenit standardul de facto, iar pe mobile experiența e aproape identică (cu câteva diferențe importante legate de ciclul de viață al aplicației, focus și persistență offline). Ghidul de față explică exact aceste diferențe, cu cod rulabil pentru Expo SDK 54 și React Native 0.79.

  • TanStack Query v5.101.2 (iunie 2026) funcționează nativ în React Native fără polyfill-uri suplimentare. Instalezi @tanstack/react-query și adaugi QueryClientProvider la nivel de root.
  • Pentru refetch la revenirea în aplicație, conectezi focusManager la AppState, iar pentru detectarea rețelei folosești onlineManager împreună cu @react-native-community/netinfo.
  • Persistența offline se face cel mai bine cu react-native-mmkv (aproximativ 30× mai rapid decât AsyncStorage) plus createSyncStoragePersister.
  • Mutațiile suportă optimistic updates prin onMutate, snapshot al cache-ului anterior în context, și rollback în onError.
  • Pentru liste paginate folosești useInfiniteQuery, care se integrează perfect cu FlashList v2 și onEndReached.
  • TanStack Query complementează Zustand: TanStack pentru server state, Zustand pentru client state. Nu sunt alternative directe.

Ce este TanStack Query și de ce contează pentru React Native?

TanStack Query este o bibliotecă de server state management care tratează datele venite de la un API ca pe un cache local sincronizat, nu ca pe o valoare pe care o pui manual într-un store. Diferența față de Redux sau Zustand e fundamentală: nu scrii reduceri, nu setezi loading/error/data manual și nu îți faci griji când să reîmprospătezi datele. Biblioteca face asta pentru tine, pornind de la o singură funcție async care returnează un fetch.

Pe React Native, abordarea asta rezolvă o problemă pe care web-ul o are mult mai puțin acută: ecranele se remontează frecvent (când navighezi între tab-uri, când aplicația trece în background și revine, când rețeaua se schimbă). Fără un cache inteligent, fiecare navigație ar declanșa un fetch nou. Cu TanStack Query, datele stau în cache după queryKey, iar refetch-ul se face doar dacă datele sunt "stale" (vechi conform staleTime). Într-o aplicație de producție pe care am migrat-o anul trecut de la Axios plus useEffect, numărul de request-uri către backend a scăzut cu 60-70% doar prin activarea cache-ului standard. Fără să scriem o linie de logică de deduplicare.

Un al doilea avantaj important pe mobile este suportul nativ pentru offline-first: mutațiile pot fi puse în pauză când dispozitivul pierde conexiunea și reluate automat când revine, iar cache-ul poate fi hidratat din storage la lansarea aplicației, astfel încât utilizatorul vede imediat date, nu un splash screen. Comportamentele astea sunt aliniate cu așteptările utilizatorilor mobile, obișnuiți cu aplicații care "merg" în metrou sau avion.

Instalare și configurare în Expo (2026)

Pentru un proiect Expo SDK 54 (React Native 0.79), instalezi pachetul principal și pachetele de persistență de care vei avea nevoie. Recomand să faci toate instalările împreună, ca lock file-ul să fie consistent:

npx expo install @tanstack/react-query \
  @tanstack/react-query-persist-client \
  @tanstack/query-sync-storage-persister \
  react-native-mmkv \
  @react-native-community/netinfo

Versiunea curentă la data scrierii este @tanstack/react-query 5.101.2 (publicată pe 27 iunie 2026). MMKV necesită un dev build (nu funcționează în Expo Go), fiindcă e un modul nativ. Dacă lucrezi doar în Expo Go pentru prototip, poți începe cu AsyncStorage și migrezi la MMKV când treci pe EAS Build.

Configurarea root a QueryClientProvider se face în app/_layout.tsx (dacă folosești Expo Router) sau în App.tsx pentru CLI. Setează staleTime non-zero, pentru că implicit este 0, ceea ce înseamnă că fiecare mount refetch-uiește. Într-o aplicație mobile tipică, un staleTime între 30 de secunde și 5 minute este un compromis rezonabil:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60, // 1 minut înainte de refetch automat
      gcTime: 1000 * 60 * 60 * 24, // păstrează în cache 24h
      retry: 2,
      refetchOnReconnect: true,
    },
    mutations: {
      retry: 1,
    },
  },
});

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack />
    </QueryClientProvider>
  );
}

Diferența față de web: pe React Native NU vei folosi refetchOnWindowFocus (nu există concept de fereastră). În schimb, vei conecta focusManager la AppState, cum arăt în secțiunea despre refetch automat.

useQuery: fetching de bază cu tipuri TypeScript

Anatomia unui hook useQuery e mereu aceeași: o cheie unică (queryKey) și o funcție care returnează un Promise. Cheia este ce urmărește TanStack Query pentru cache. Dacă parametrii se schimbă, cheia se schimbă, iar biblioteca refetch-uiește. Recomand întotdeauna să scrii cheile ca array-uri cu prefix textual și parametrii ca obiect, ca să eviți coliziuni:

import { useQuery } from '@tanstack/react-query';
import { View, Text, ActivityIndicator } from 'react-native';

type Post = { id: number; title: string; body: string };

async function fetchPosts(userId: number): Promise<Post[]> {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}/posts`
  );
  if (!response.ok) throw new Error('Fetch failed');
  return response.json();
}

export function UserPosts({ userId }: { userId: number }) {
  const { data, isPending, isError, error, refetch, isRefetching } = useQuery({
    queryKey: ['posts', { userId }],
    queryFn: () => fetchPosts(userId),
    enabled: userId > 0,
  });

  if (isPending) return <ActivityIndicator />;
  if (isError) return <Text>Eroare: {error.message}</Text>;

  return (
    <View>
      {data.map((post) => (
        <Text key={post.id}>{post.title}</Text>
      ))}
    </View>
  );
}

Câteva lucruri de reținut care mi-au luat timp să le înțeleg venind din web. Întâi, isPending înlocuiește vechiul isLoading din v4 și este true doar la primul fetch, nu la refetch. Pentru "loading state" în timpul unei reîmprospătări, folosești isRefetching. Iar enabled: false este mecanismul prin care întârzii un query până când ai parametrii necesari (de exemplu, userId vine dintr-un context de autentificare care se încarcă la pornirea aplicației).

Mutații și optimistic updates cu useMutation

Pentru operațiile de scriere (POST, PUT, DELETE) folosești useMutation. Diferența majoră față de useQuery este că mutațiile nu se execută automat. Le declanșezi manual cu mutate() sau mutateAsync(). Un pattern esențial pe mobile este optimistic update: actualizezi UI-ul imediat, presupunând că request-ul va reuși, iar dacă nu, faci rollback. Utilizatorul percepe aplicația ca instantanee.

import { useMutation, useQueryClient } from '@tanstack/react-query';

type Todo = { id: number; title: string; done: boolean };

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

  return useMutation({
    mutationFn: async (todo: Todo) => {
      const response = await fetch(`/api/todos/${todo.id}`, {
        method: 'PATCH',
        body: JSON.stringify({ done: !todo.done }),
      });
      if (!response.ok) throw new Error('Failed');
      return response.json();
    },
    // 1. rulează înainte de mutationFn
    onMutate: async (todo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData<Todo[]>(['todos']);
      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        old?.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t))
      );
      return { previous }; // devine context în onError
    },
    // 2. rollback dacă serverul respinge
    onError: (_err, _todo, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['todos'], context.previous);
      }
    },
    // 3. sincronizează cache-ul cu adevărul de pe server
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Cei trei pași (onMutate, onError, onSettled) formează triada standard pentru optimistic updates. cancelQueries e critic: fără el, un refetch în curs ar putea suprascrie modificarea ta optimistă. Am pățit exact asta când am trimis o aplicație în TestFlight cu o listă de todo-uri care se "reseta" ocazional, iar cauza era chiar acest apel omis. Iar invalidateQueries la final asigură că, indiferent de rezultat, cache-ul reflectă starea reală de pe server.

Persistență offline cu MMKV și PersistQueryClientProvider

Ca datele să supraviețuiască închiderii aplicației, TanStack Query oferă un sistem de persisters. Recomand react-native-mmkv, care e de aproximativ 30 de ori mai rapid decât AsyncStorage și oferă un API sincron, perfect pentru createSyncStoragePersister. Găsești wrapperul oficial în documentația react-native-mmkv pentru React Query.

import { MMKV } from 'react-native-mmkv';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { QueryClient } from '@tanstack/react-query';

const storage = new MMKV({ id: 'query-cache' });

const clientStorage = {
  setItem: (key: string, value: string) => storage.set(key, value),
  getItem: (key: string) => storage.getString(key) ?? null,
  removeItem: (key: string) => storage.delete(key),
};

const persister = createSyncStoragePersister({
  storage: clientStorage,
  key: 'RQ_CACHE_V1',
});

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24 * 7, // 7 zile, trebuie sa fie >= maxAge
      networkMode: 'offlineFirst',
    },
  },
});

export default function RootLayout() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister,
        maxAge: 1000 * 60 * 60 * 24 * 7,
        buster: 'v1',
      }}>
      <Stack />
    </PersistQueryClientProvider>
  );
}

Trei detalii care ies din documentație și care m-au prins prima dată. (1) gcTime trebuie să fie cel puțin egal cu maxAge, altfel cache-ul e șters din memorie înainte să fie serializat. (2) buster este un string pe care îl schimbi când modifici schema datelor, ca să invalidezi întregul cache persistat. (3) networkMode: 'offlineFirst' permite ca query-urile să returneze imediat date din cache chiar dacă rețeaua e offline, în loc să stea în pending.

Pentru context arhitectural mai larg despre unde stochezi datele într-o aplicație React Native modernă, vezi și ghidul nostru despre Zustand pentru state management în React Native, care acoperă complementar client state-ul.

Refetch automat: AppState, NetInfo și focus per ecran

Pe React Native, TanStack Query nu are cum să știe singură când aplicația revine în foreground sau când rețeaua se restabilește. Trebuie să îi spui tu prin focusManager și onlineManager. Eu fac asta într-un singur loc, într-un fișier lib/queryClient.ts care se importă la startup:

import { focusManager, onlineManager } from '@tanstack/react-query';
import { AppState, Platform } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

// 1. Focus: refetch când aplicația revine din background
AppState.addEventListener('change', (status) => {
  if (Platform.OS !== 'web') {
    focusManager.setFocused(status === 'active');
  }
});

// 2. Online status: pune mutațiile în pauză când nu e rețea
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected);
  });
});

Odată configurate aceste două, comportamentul devine cel din web: query-urile stale se refetch-uiesc când utilizatorul revine în aplicație, iar mutațiile intră în queue offline când rețeaua cade și se reia automat la reconectare. Ca să testezi rapid, folosește Airplane Mode plus un mutate(). Vei vedea în DevTools mutația în stare paused până când repornești Wi-Fi-ul.

Pe lângă focus la nivel de aplicație, poți controla granular per ecran cu prop-ul subscribed combinat cu useIsFocused din React Navigation. Când ecranul nu e focusat, query-ul se dezabonează și nu mai declanșează re-render-uri:

import { useIsFocused } from '@react-navigation/native';

function ProfileScreen() {
  const isFocused = useIsFocused();
  const { data } = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    subscribed: isFocused, // v5.66+
  });
  // ...
}

Infinite queries pentru scrolling nelimitat

Pentru feed-uri, timeline-uri sau orice listă paginată, folosești useInfiniteQuery. Diferența față de useQuery este că data e un obiect cu pages (un array de rezultate per pagină) și pageParams, iar tu specifici cum se calculează pagina următoare prin getNextPageParam.

import { useInfiniteQuery } from '@tanstack/react-query';
import { FlashList } from '@shopify/flash-list';

type Page = { items: Post[]; nextCursor: string | null };

async function fetchFeed({ pageParam }: { pageParam: string | null }) {
  const url = pageParam
    ? `/api/feed?cursor=${pageParam}`
    : '/api/feed';
  const res = await fetch(url);
  return res.json() as Promise<Page>;
}

export function Feed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: fetchFeed,
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  const items = data?.pages.flatMap((p) => p.items) ?? [];

  return (
    <FlashList
      data={items}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => <PostRow post={item} />}
      onEndReached={() => {
        if (hasNextPage && !isFetchingNextPage) fetchNextPage();
      }}
      onEndReachedThreshold={0.5}
    />
  );
}

Cursor-based pagination (ca în exemplul de mai sus) e mai fiabilă decât offset-based pe mobile, pentru că nu produce duplicate atunci când conținutul se actualizează între request-uri. Pentru performanță maximă în listele lungi, combinația useInfiniteQuery plus FlashList este standardul de aur în 2026. Pentru detalii, vezi ghidul nostru despre FlashList v2 pentru liste performante.

TanStack Query vs Zustand vs Redux

O confuzie frecventă e că TanStack Query, Zustand și Redux ar fi alternative pentru aceeași problemă. Nu sunt. TanStack Query gestionează server state (date care aparțin serverului și pentru care ai doar o copie cache), în timp ce Zustand și Redux gestionează client state (setări utilizator, teme, state al formularelor, tab-ul curent selectat). Într-o aplicație de producție folosești probabil ambele.

AspectTanStack QueryZustandRedux Toolkit
Scop principalServer state / cacheClient stateClient state complex
BoilerplateFoarte redusMinimModerat (RTK reduce mult)
Cache automatDa, cu invalidareNuNu (RTK Query = da)
Optimistic updatesBuilt-inManualManual (sau RTK Query)
DevToolsReact Query DevToolsZustand DevToolsRedux DevTools
Persistență offlineDa, cu persisterDa, cu middlewareDa, cu redux-persist
Curbă de învățareUșor-mediuFoarte ușorMediu-dificil
Bundle size (gzip)~13 KB~1 KB~11 KB

Recomandarea mea pentru un proiect nou: TanStack Query pentru orice vine de la un API, plus Zustand pentru client state global. Nu ai nevoie de Redux dacă nu ai o echipă mare deja obișnuită cu el sau logică complexă bazată pe middleware. Pentru context arhitectural despre noile fundații ale React Native care afectează toate aceste biblioteci, vezi și articolul despre Noua Arhitectură React Native (JSI, Fabric, TurboModules).

Debugging cu React Query DevTools pe dispozitiv

Pe web, DevTools-ul se atașează ca overlay direct în pagină. Pe React Native, ai două opțiuni: (1) folosești extensia oficială @dev-plugins/react-query pentru React Native DevTools, sau (2) folosești modul externă prin @tanstack/react-query-devtools într-o fereastră browser separată.

// Opțiune 1: integrare cu React Native DevTools (recomandată)
import { useReactQueryDevTools } from '@dev-plugins/react-query';

function App() {
  const queryClient = useQueryClient();
  useReactQueryDevTools(queryClient); // apare în tab-ul DevTools
  return <YourApp />;
}

În DevTools poți vedea toate query-urile active cu queryKey-ul lor, starea (fresh, stale, fetching, paused, inactive), payload-ul returnat, și poți declanșa manual invalidate, refetch sau reset. Onest, e singurul mod eficient de a înțelege de ce un query se refetch-uiește mai des decât credeai sau de ce cache-ul nu se golește când te-ai aștepta. Detalii oficiale găsești în documentația TanStack Query pentru React Native.

Pentru cei care fac tranziția de la Flipper la noul stack de debugging, avem un ghid dedicat despre React Native DevTools ca înlocuitor pentru Flipper, care acoperă întregul flux de debugging.

Întrebări frecvente

Este React Query același lucru cu TanStack Query?

Da. React Query a fost redenumit TanStack Query începând cu versiunea 4, ca să reflecte faptul că suportă și Vue, Solid, Svelte și Angular pe lângă React. Pachetul npm pentru React este acum @tanstack/react-query, dar semantica hook-urilor (useQuery, useMutation) rămâne identică.

Funcționează TanStack Query în Expo Go?

Da, pachetul principal @tanstack/react-query funcționează în Expo Go fără probleme. Dependențele native precum react-native-mmkv pentru persistență necesită însă un dev build sau EAS Build, pentru că introduc cod nativ.

Cum păstrez cache-ul TanStack Query după închiderea aplicației?

Folosești PersistQueryClientProvider din @tanstack/react-query-persist-client împreună cu un persister. Pe mobile, recomandarea e createSyncStoragePersister peste MMKV, cu gcTime setat cel puțin egal cu maxAge, ca să eviți ștergerea prematură a cache-ului.

Ar trebui să folosesc Redux sau TanStack Query?

Depinde de ce tip de state gestionezi. TanStack Query e optim pentru server state (date de la API), iar Redux e pentru client state complex. În multe aplicații moderne, TanStack Query elimină 70-80% din cazurile pentru care aveai nevoie de Redux, iar restul poate fi acoperit de Zustand sau chiar Context API.

Cum forțez un refetch la revenirea în aplicație pe React Native?

Nu folosi refetchOnWindowFocus, acea opțiune este pentru web. În schimb, importă focusManager și conectează-l la AppState.addEventListener. Când statusul devine active, apelezi focusManager.setFocused(true), iar TanStack Query va refetch-ui automat toate query-urile stale.

Cât de mare este bundle-ul TanStack Query în React Native?

Aproximativ 13 KB gzipped pentru @tanstack/react-query v5. Adăugând persister-ul și dependența MMKV, adăugi încă circa 10 KB, deci total sub 25 KB pentru un stack complet de data fetching cu persistență. Neglijabil comparativ cu beneficiile de arhitectură.

Anita Iyer
Despre Autor Anita Iyer

Cross-platform mobile developer who came to RN from web. Bridges the two worlds and explains the seams.